chore: sync local updates

This commit is contained in:
2026-05-16 19:03:04 +08:00
parent b86ce4a73a
commit 14cd94c88d
45 changed files with 2983 additions and 4127 deletions

View File

@@ -2,14 +2,15 @@ package main
import (
"context"
"encoding/json"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -23,12 +24,12 @@ import (
// ─── 持久化数据类型 ───────────────────────────────────────────────
type SSHProfile struct {
Name string `json:"name,omitempty"` // 文件名(不含 .json
Alias string `json:"alias"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
Name string `json:"name,omitempty"`
Alias string `json:"alias"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
PrivateKey string `json:"privateKey,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
}
@@ -43,7 +44,7 @@ type ScriptInfo struct {
Content string `json:"content,omitempty"`
}
// 配置与数据目录辅助函数见 config.go
// 配置与数据辅助函数见 config.go / db.go
type wsMessage struct {
Type string `json:"type"`
@@ -71,7 +72,84 @@ func (w *wsWriter) send(msg wsMessage) {
_ = w.conn.WriteJSON(msg)
}
// ─── 会话令牌(服务启动时随机生成一次)────────────────────────────────
var sessionToken string
func initSessionToken() {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Fatalf("failed to generate session token: %v", err)
}
sessionToken = hex.EncodeToString(b)
}
// authMiddleware 校验请求中携带的令牌。
// WebSocket 不支持自定义 Header因此同时接受 ?token= 查询参数。
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := ""
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
token = strings.TrimPrefix(auth, "Bearer ")
}
if token == "" {
token = c.Query("token")
}
if token != sessionToken {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
c.Next()
}
}
// POST /api/auth/login — 用密码换取令牌
func handleLogin(c *gin.Context) {
var body struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if body.Password != accessPassword() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"token": sessionToken}})
}
// GET /api/auth/verify — 校验令牌是否有效(受 authMiddleware 保护)
func handleVerify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"valid": true}})
}
// GET / — 服务说明(无需鉴权,便于网关 / 探活)
func handleAPIRoot(c *gin.Context) {
loc := time.FixedZone("CST", 8*3600)
c.JSON(http.StatusOK, gin.H{
"description": "提供 Web SSH 终端与跳板连接SSH 由服务端发起);浏览器访问需先 POST /api/auth/login 换取令牌,后续 API 与 WebSocket 携带 Bearer 或 ?token=",
"endpoints": gin.H{
"health": "/health",
"auth": "/api/auth/login (POST公开), /api/auth/verify (GET需令牌)",
"websocket_ssh": "/api/ws/ssh (GET 升级 WebSocket需令牌查询参数 token= 或 Header)",
"ssh_profiles": "/api/ssh, /api/ssh/:name (CRUD需令牌)",
"commands": "/api/commands, /api/commands/:index (CRUD需令牌)",
"scripts": "/api/scripts, /api/scripts/:name (CRUD需令牌)",
},
"message": "萌芽 SSH MengyaConnect 后端 API 服务运行中",
"timestamp": time.Now().In(loc).Format(time.RFC3339),
"version": apiVersion(),
})
}
func main() {
initSessionToken()
if err := initDB(); err != nil {
log.Fatalf("db init: %v", err)
}
migrateFromFiles()
if mode := os.Getenv("GIN_MODE"); mode != "" {
gin.SetMode(mode)
}
@@ -88,36 +166,43 @@ func main() {
},
}
// ─── 基本配置 CRUD ──────────────────────────────────────────
// ─── 不需要鉴权的路由 ────────────────────────────────────────
router.GET("/", handleAPIRoot)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
})
router.POST("/api/auth/login", handleLogin)
router.GET("/api/ws/ssh", func(c *gin.Context) {
// ─── 需要鉴权的路由 ──────────────────────────────────────────
auth := router.Group("/api", authMiddleware())
auth.GET("/auth/verify", handleVerify)
auth.GET("/ws/ssh", func(c *gin.Context) {
handleSSHWebSocket(c, upgrader)
})
// ─── SSH 配置 CRUD ──────────────────────────────────────────
router.GET("/api/ssh", handleListSSH)
router.POST("/api/ssh", handleCreateSSH)
router.PUT("/api/ssh/:name", handleUpdateSSH)
router.DELETE("/api/ssh/:name", handleDeleteSSH)
// SSH 配置 CRUD
auth.GET("/ssh", handleListSSH)
auth.POST("/ssh", handleCreateSSH)
auth.PUT("/ssh/:name", handleUpdateSSH)
auth.DELETE("/ssh/:name", handleDeleteSSH)
// ─── 快捷命令 CRUD ─────────────────────────────────────────
router.GET("/api/commands", handleListCommands)
router.POST("/api/commands", handleCreateCommand)
router.PUT("/api/commands/:index", handleUpdateCommand)
router.DELETE("/api/commands/:index", handleDeleteCommand)
// 快捷命令 CRUD
auth.GET("/commands", handleListCommands)
auth.POST("/commands", handleCreateCommand)
auth.PUT("/commands/:index", handleUpdateCommand)
auth.DELETE("/commands/:index", handleDeleteCommand)
// ─── 脚本 CRUD ─────────────────────────────────────────────
router.GET("/api/scripts", handleListScripts)
router.GET("/api/scripts/:name", handleGetScript)
router.POST("/api/scripts", handleCreateScript)
router.PUT("/api/scripts/:name", handleUpdateScript)
router.DELETE("/api/scripts/:name", handleDeleteScript)
// 脚本 CRUD
auth.GET("/scripts", handleListScripts)
auth.GET("/scripts/:name", handleGetScript)
auth.POST("/scripts", handleCreateScript)
auth.PUT("/scripts/:name", handleUpdateScript)
auth.DELETE("/scripts/:name", handleDeleteScript)
addr := getEnv("ADDR", ":"+getEnv("PORT", "8080"))
server := &http.Server{
@@ -127,6 +212,7 @@ func main() {
}
log.Printf("SSH WebSocket server listening on %s", addr)
log.Printf("Access password configured (use ACCESS_PASSWORD env to override)")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
@@ -365,41 +451,33 @@ func streamToWebSocket(ctx context.Context, writer *wsWriter, reader io.Reader)
}
}
// CORS、中间件与环境变量工具函数见 config.go
// ═══════════════════════════════════════════════════════════════════
// SSH 配置 CRUD
// ═══════════════════════════════════════════════════════════════════
// GET /api/ssh — 列出所有 SSH 配置
// GET /api/ssh
func handleListSSH(c *gin.Context) {
entries, err := os.ReadDir(sshDir())
rows, err := DB.Query(
`SELECT name,alias,host,port,username,password,private_key,passphrase
FROM ssh_profiles ORDER BY id`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": []SSHProfile{}})
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
return
}
var profiles []SSHProfile
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
raw, err := os.ReadFile(filepath.Join(sshDir(), e.Name()))
if err != nil {
continue
}
defer rows.Close()
profiles := []SSHProfile{}
for rows.Next() {
var p SSHProfile
if err := json.Unmarshal(raw, &p); err != nil {
if err := rows.Scan(&p.Name, &p.Alias, &p.Host, &p.Port,
&p.Username, &p.Password, &p.PrivateKey, &p.Passphrase); err != nil {
continue
}
p.Name = strings.TrimSuffix(e.Name(), ".json")
profiles = append(profiles, p)
}
if profiles == nil {
profiles = []SSHProfile{}
}
c.JSON(http.StatusOK, gin.H{"data": profiles})
}
// POST /api/ssh — 新建 SSH 配置
// POST /api/ssh
func handleCreateSSH(c *gin.Context) {
var p SSHProfile
if err := c.ShouldBindJSON(&p); err != nil {
@@ -410,33 +488,39 @@ func handleCreateSSH(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"})
return
}
name := p.Name
name := strings.TrimSpace(p.Name)
if name == "" {
name = p.Alias
}
safe, err := sanitizeName(strings.ReplaceAll(name, " ", "-"))
if err != nil {
name = strings.ReplaceAll(name, " ", "-")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
p.Name = ""
raw, _ := json.MarshalIndent(p, "", " ")
if err := os.MkdirAll(sshDir(), 0o750); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"})
if p.Port == 0 {
p.Port = 22
}
_, err := DB.Exec(
`INSERT INTO ssh_profiles (name,alias,host,port,username,password,private_key,passphrase)
VALUES (?,?,?,?,?,?,?,?)`,
name, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "name already exists"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
}
return
}
if err := os.WriteFile(filepath.Join(sshDir(), safe+".json"), raw, 0o600); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
return
}
p.Name = safe
p.Name = name
c.JSON(http.StatusOK, gin.H{"data": p})
}
// PUT /api/ssh/:name — 更新 SSH 配置
// PUT /api/ssh/:name
func handleUpdateSSH(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
@@ -449,34 +533,42 @@ func handleUpdateSSH(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"})
return
}
p.Name = ""
raw, _ := json.MarshalIndent(p, "", " ")
filePath := filepath.Join(sshDir(), name+".json")
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
if p.Port == 0 {
p.Port = 22
}
res, err := DB.Exec(
`UPDATE ssh_profiles
SET alias=?,host=?,port=?,username=?,password=?,private_key=?,passphrase=?,
updated_at=CURRENT_TIMESTAMP
WHERE name=?`,
p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, name,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
return
}
if err := os.WriteFile(filePath, raw, 0o600); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
p.Name = name
c.JSON(http.StatusOK, gin.H{"data": p})
}
// DELETE /api/ssh/:name — 删除 SSH 配置
// DELETE /api/ssh/:name
func handleDeleteSSH(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if err := os.Remove(filepath.Join(sshDir(), name+".json")); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
}
res, err := DB.Exec(`DELETE FROM ssh_profiles WHERE name=?`, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
@@ -486,37 +578,49 @@ func handleDeleteSSH(c *gin.Context) {
// 快捷命令 CRUD
// ═══════════════════════════════════════════════════════════════════
func readCommands() ([]Command, error) {
raw, err := os.ReadFile(cmdFilePath())
func queryAllCommands() ([]Command, error) {
rows, err := DB.Query(`SELECT alias,command FROM commands ORDER BY sort_order, id`)
if err != nil {
if os.IsNotExist(err) {
return []Command{}, nil
}
return nil, err
}
var cmds []Command
if err := json.Unmarshal(raw, &cmds); err != nil {
return nil, err
defer rows.Close()
cmds := []Command{}
for rows.Next() {
var cmd Command
if err := rows.Scan(&cmd.Alias, &cmd.Command); err != nil {
return nil, err
}
cmds = append(cmds, cmd)
}
return cmds, nil
}
func writeCommands(cmds []Command) error {
raw, err := json.MarshalIndent(cmds, "", " ")
// commandIDAtIndex returns the primary-key id of the command at 0-based position idx.
func commandIDAtIndex(idx int) (int64, error) {
rows, err := DB.Query(`SELECT id FROM commands ORDER BY sort_order, id`)
if err != nil {
return err
return 0, err
}
if err := os.MkdirAll(filepath.Dir(cmdFilePath()), 0o750); err != nil {
return err
defer rows.Close()
pos := 0
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return 0, err
}
if pos == idx {
return id, nil
}
pos++
}
return os.WriteFile(cmdFilePath(), raw, 0o600)
return 0, sql.ErrNoRows
}
// GET /api/commands
func handleListCommands(c *gin.Context) {
cmds, err := readCommands()
cmds, err := queryAllCommands()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"data": cmds})
@@ -533,16 +637,16 @@ func handleCreateCommand(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"})
return
}
cmds, err := readCommands()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
return
}
cmds = append(cmds, cmd)
if err := writeCommands(cmds); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
var maxOrder int
_ = DB.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM commands`).Scan(&maxOrder)
if _, err := DB.Exec(
`INSERT INTO commands (alias,command,sort_order) VALUES (?,?,?)`,
cmd.Alias, cmd.Command, maxOrder+1,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
return
}
cmds, _ := queryAllCommands()
c.JSON(http.StatusOK, gin.H{"data": cmds})
}
@@ -562,20 +666,16 @@ func handleUpdateCommand(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"})
return
}
cmds, err := readCommands()
id, err := commandIDAtIndex(idx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
return
}
if idx >= len(cmds) {
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
return
}
cmds[idx] = cmd
if err := writeCommands(cmds); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
if _, err := DB.Exec(`UPDATE commands SET alias=?,command=? WHERE id=?`, cmd.Alias, cmd.Command, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
return
}
cmds, _ := queryAllCommands()
c.JSON(http.StatusOK, gin.H{"data": cmds})
}
@@ -586,20 +686,16 @@ func handleDeleteCommand(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index"})
return
}
cmds, err := readCommands()
id, err := commandIDAtIndex(idx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
return
}
if idx >= len(cmds) {
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
return
}
cmds = append(cmds[:idx], cmds[idx+1:]...)
if err := writeCommands(cmds); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
if _, err := DB.Exec(`DELETE FROM commands WHERE id=?`, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
return
}
cmds, _ := queryAllCommands()
c.JSON(http.StatusOK, gin.H{"data": cmds})
}
@@ -607,45 +703,46 @@ func handleDeleteCommand(c *gin.Context) {
// 脚本 CRUD
// ═══════════════════════════════════════════════════════════════════
// GET /api/scripts — 列出所有脚本名称
// GET /api/scripts
func handleListScripts(c *gin.Context) {
entries, err := os.ReadDir(scriptDir())
rows, err := DB.Query(`SELECT name FROM scripts ORDER BY name`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": []ScriptInfo{}})
return
}
var scripts []ScriptInfo
for _, e := range entries {
if !e.IsDir() {
scripts = append(scripts, ScriptInfo{Name: e.Name()})
defer rows.Close()
scripts := []ScriptInfo{}
for rows.Next() {
var s ScriptInfo
if err := rows.Scan(&s.Name); err != nil {
continue
}
}
if scripts == nil {
scripts = []ScriptInfo{}
scripts = append(scripts, s)
}
c.JSON(http.StatusOK, gin.H{"data": scripts})
}
// GET /api/scripts/:name — 获取脚本内容
// GET /api/scripts/:name
func handleGetScript(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
raw, err := os.ReadFile(filepath.Join(scriptDir(), name))
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read"})
}
var s ScriptInfo
err := DB.QueryRow(`SELECT name,content FROM scripts WHERE name=?`, name).
Scan(&s.Name, &s.Content)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: string(raw)}})
c.JSON(http.StatusOK, gin.H{"data": s})
}
// POST /api/scripts — 新建脚本
// POST /api/scripts
func handleCreateScript(c *gin.Context) {
var s ScriptInfo
if err := c.ShouldBindJSON(&s); err != nil {
@@ -656,59 +753,63 @@ func handleCreateScript(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 为必填项"})
return
}
name, err := sanitizeName(s.Name)
if err != nil {
if err := validateName(s.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if err := os.MkdirAll(scriptDir(), 0o750); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"})
_, err := DB.Exec(`INSERT INTO scripts (name,content) VALUES (?,?)`, s.Name, s.Content)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "name already exists"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
}
return
}
if err := os.WriteFile(filepath.Join(scriptDir(), name), []byte(s.Content), 0o640); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"})
return
}
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}})
c.JSON(http.StatusOK, gin.H{"data": s})
}
// PUT /api/scripts/:name — 更新脚本内容
// PUT /api/scripts/:name
func handleUpdateScript(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
filePath := filepath.Join(scriptDir(), name)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var s ScriptInfo
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if err := os.WriteFile(filePath, []byte(s.Content), 0o640); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"})
res, err := DB.Exec(
`UPDATE scripts SET content=?,updated_at=CURRENT_TIMESTAMP WHERE name=?`,
s.Content, name,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}})
}
// DELETE /api/scripts/:name — 删除脚本
// DELETE /api/scripts/:name
func handleDeleteScript(c *gin.Context) {
name, err := sanitizeName(c.Param("name"))
if err != nil {
name := c.Param("name")
if err := validateName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if err := os.Remove(filepath.Join(scriptDir(), name)); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
}
res, err := DB.Exec(`DELETE FROM scripts WHERE name=?`, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})