194 lines
5.4 KiB
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
|
||
|
|
}
|