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 // 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 }