Files
backend/internal/server/handlers_apps.go

194 lines
5.4 KiB
Go

package server
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
)
type appReq struct {
Slug string `json:"slug"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ModelConfigID *int64 `json:"model_config_id"`
Status string `json:"status"`
}
// GET /admin/apps
func (d Deps) handleListApps(c *gin.Context) {
var apps []store.App
d.DB.Order("id DESC").Find(&apps)
c.JSON(http.StatusOK, apps)
}
// GET /admin/apps/:id
func (d Deps) handleGetApp(c *gin.Context) {
a, ok := d.loadApp(c)
if !ok {
return
}
c.JSON(http.StatusOK, a)
}
// POST /admin/apps — slug auto-derived from name when omitted. A fresh
// access token is generated and returned ONCE in the response; only its hash
// is persisted, so the operator must save it immediately.
func (d Deps) handleCreateApp(c *gin.Context) {
var req appReq
if !bind(c, &req) {
return
}
slug := security.Slugify(req.Slug)
if slug == "" {
slug = security.Slugify(req.Name)
}
if slug == "" {
// Name had no ASCII slug chars (e.g. pure CJK). Derive a short random
// slug so the app is still addressable on disk and in URLs.
slug = security.RandomSlug()
}
// Ensure slug uniqueness.
var existing store.App
err := d.DB.Where("slug = ?", slug).First(&existing).Error
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "slug 已存在"})
return
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return
}
token, err := security.GenerateToken("ca")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成 token 失败"})
return
}
status := strings.TrimSpace(req.Status)
if status == "" {
status = "active"
}
a := store.App{
Slug: slug,
Name: req.Name,
Description: req.Description,
TokenHash: security.HashToken(token),
ModelConfigID: req.ModelConfigID,
Status: status,
}
if err := d.DB.Create(&a).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
return
}
d.audit(c, &a.ID, nil, "app.create", map[string]any{"slug": a.Slug})
// Plain token is returned only here; subsequent reads only have the hash.
c.JSON(http.StatusOK, gin.H{
"app": a,
"token": token,
})
}
// PUT /admin/apps/:id — updates mutable fields. Slug is intentionally not
// editable post-create because skills live under <SkillsDir>/<slug>/ and a
// rename would orphan them.
func (d Deps) handleUpdateApp(c *gin.Context) {
a, ok := d.loadApp(c)
if !ok {
return
}
var req appReq
if !bind(c, &req) {
return
}
a.Name = req.Name
a.Description = req.Description
a.ModelConfigID = req.ModelConfigID
if s := strings.TrimSpace(req.Status); s != "" {
a.Status = s
}
if err := d.DB.Save(a).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
return
}
d.audit(c, &a.ID, nil, "app.update", nil)
c.JSON(http.StatusOK, a)
}
// DELETE /admin/apps/:id — cascades to skills (rows + their markdown files),
// sessions, and messages. Wrapped in a transaction so a partial failure
// leaves the app intact.
func (d Deps) handleDeleteApp(c *gin.Context) {
a, ok := d.loadApp(c)
if !ok {
return
}
err := d.DB.Transaction(func(tx *gorm.DB) error {
// Collect skill file paths so we can remove them on disk after the
// transaction commits.
var skills []store.Skill
tx.Where("app_id = ?", a.ID).Find(&skills)
if err := tx.Where("app_id = ?", a.ID).Delete(&store.Skill{}).Error; err != nil {
return err
}
// Delete messages that belong to this app's sessions, then sessions.
tx.Where("session_id IN (SELECT id FROM sessions WHERE app_id = ?)", a.ID).Delete(&store.Message{})
if err := tx.Where("app_id = ?", a.ID).Delete(&store.Session{}).Error; err != nil {
return err
}
return tx.Delete(a).Error
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
// Best-effort: remove the app's skill directory on disk.
removeAppSkillDir(d.SkillsDir, a.Slug)
d.audit(c, &a.ID, nil, "app.delete", map[string]any{"slug": a.Slug})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// POST /admin/apps/:id/token/rotate — invalidates the old token and returns a
// new plaintext token once.
func (d Deps) handleRotateToken(c *gin.Context) {
a, ok := d.loadApp(c)
if !ok {
return
}
token, err := security.GenerateToken("ca")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成 token 失败"})
return
}
a.TokenHash = security.HashToken(token)
if err := d.DB.Save(a).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
return
}
d.audit(c, &a.ID, nil, "app.token_rotate", nil)
c.JSON(http.StatusOK, gin.H{"token": token})
}
// loadApp fetches :id and writes a 404 on miss. Returns the app and true on
// success so handlers can `if !ok { return }` at the top.
func (d Deps) loadApp(c *gin.Context) (*store.App, bool) {
id := c.Param("id")
var a store.App
if err := d.DB.First(&a, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "应用未找到"})
return nil, false
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return nil, false
}
return &a, true
}