package handler import ( "log" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-shared/contract" ) // SpacesList: GET /api/v1/me/spaces —— 当前活跃租户内我所属的工作区(供切换器)+ 当前活跃空间 id。 func (h *Handler) SpacesList(c *gin.Context) { rows, err := h.db.MySpaces(c.Request.Context(), tenantID(c), userID(c)) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"spaces": rows, "active": spaceID(c)}) } // SwitchSpace: POST /api/v1/me/space {space_id} —— 切换当前活跃工作区(须为其成员)。 func (h *Handler) SwitchSpace(c *gin.Context) { var b struct { SpaceID string `json:"space_id"` } if err := c.ShouldBindJSON(&b); err != nil || b.SpaceID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "space_id 必填"}) return } if err := h.db.SetActiveSpace(c.Request.Context(), userID(c), b.SpaceID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "active": b.SpaceID}) } // SpaceCurrent: GET /api/v1/spaces/current —— 当前活跃工作区上下文 + 我在其中的角色。 func (h *Handler) SpaceCurrent(c *gin.Context) { sid := spaceID(c) if sid == "" { c.JSON(http.StatusOK, gin.H{"space": nil}) return } s, _ := h.db.GetSpace(c.Request.Context(), sid) if s == nil { c.JSON(http.StatusOK, gin.H{"space": nil}) return } c.JSON(http.StatusOK, gin.H{ "space": gin.H{"id": s.ID, "name": s.Name, "kind": s.Kind, "creator": s.Creator, "archived": s.Archived}, "role": h.db.SpaceMemberRole(c.Request.Context(), sid, userID(c)), }) } // SpaceCreate: POST /api/v1/spaces {name, kind} —— 在活跃租户建一个工作区(建者成为 owner)。 // 已由 RequireTenantRole(member) 门控:租户只读 viewer 进不来。 func (h *Handler) SpaceCreate(c *gin.Context) { var b struct { Name string `json:"name"` Kind string `json:"kind"` } if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"}) return } kind := b.Kind if kind != store.SpaceKindProject && kind != store.SpaceKindTenant { kind = store.SpaceKindProject // 只允许经此建 project/tenant;personal 由系统建 } s, err := h.db.CreateSpace(c.Request.Context(), tenantID(c), b.Name, kind, userID(c)) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"id": s.ID, "name": s.Name, "kind": s.Kind}) } // spaceAdminOr403 校验当前用户在指定空间是 admin/owner,否则写 403 并返回 false。 func (h *Handler) spaceAdminOr403(c *gin.Context, sid string) bool { if store.RoleRank(h.db.SpaceMemberRole(c.Request.Context(), sid, userID(c))) < store.RoleRank(store.RoleAdmin) { c.JSON(http.StatusForbidden, gin.H{"error": "需要空间管理员权限"}) return false } return true } // SpaceMembers: GET /api/v1/spaces/:id/members —— 空间成员列表(须为该空间成员)。 func (h *Handler) SpaceMembers(c *gin.Context) { sid := c.Param("id") if h.db.SpaceMemberRole(c.Request.Context(), sid, userID(c)) == "" { c.JSON(http.StatusForbidden, gin.H{"error": "你不是该空间的成员"}) return } rows, err := h.db.ListSpaceMembers(c.Request.Context(), sid) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"members": rows}) } // SpaceAddMember: POST /api/v1/spaces/:id/members {email, role} —— 拉租户成员进空间(须空间 admin)。 func (h *Handler) SpaceAddMember(c *gin.Context) { sid := c.Param("id") if !h.spaceAdminOr403(c, sid) { return } var b struct { Email string `json:"email"` Role string `json:"role"` } if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Email) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "email 必填"}) return } if b.Role == "" { b.Role = store.RoleMember } m, err := h.db.AddSpaceMemberByEmail(c.Request.Context(), sid, b.Email, b.Role) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"member": m}) } // SpaceSetMemberRole: PUT /api/v1/spaces/:id/members/:uid {role} —— 改空间成员角色(须空间 admin)。 func (h *Handler) SpaceSetMemberRole(c *gin.Context) { sid := c.Param("id") if !h.spaceAdminOr403(c, sid) { return } var b struct { Role string `json:"role"` } if err := c.ShouldBindJSON(&b); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.db.SetSpaceMemberRole(c.Request.Context(), sid, c.Param("uid"), b.Role); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // SpaceRemoveMember: DELETE /api/v1/spaces/:id/members/:uid —— 移除空间成员(须空间 admin)。 func (h *Handler) SpaceRemoveMember(c *gin.Context) { sid := c.Param("id") if !h.spaceAdminOr403(c, sid) { return } if err := h.db.RemoveSpaceMember(c.Request.Context(), sid, c.Param("uid")); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // MigrateKBStorage: POST /api/v1/admin/migrate-kb-storage —— 一次性把存量 KB 的三库(向量/全文/图谱) // 从旧作用域键 owner/kb 重灌到新键 space_id/kb(增量3 存储层迁移)。RequireAdmin 保护。 // **异步**:为每篇文档在新 space 作用域下入队一条重灌作业(复用 JetStream 入库 worker 池:有界并发+ // 背压+崩溃重投),并先删旧键条目。立即返回入队数,进度经入库 worker 日志/SSE 看。避免同步重嵌几十篇撑爆 HTTP 超时。 func (h *Handler) MigrateKBStorage(c *gin.Context) { docs, err := h.db.DocsForRestorage(c.Request.Context()) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } enqueued, skipped := 0, 0 for _, d := range docs { newScoped := d.SpaceID + "/" + d.KB oldScoped := d.Owner + "/" + d.KB if newScoped == oldScoped { skipped++ continue } text := d.Content if d.ObjectKey != "" && h.blob.Ready() { if obj, oerr := h.blob.Get(c.Request.Context(), d.ObjectKey); oerr == nil { text = obj } } if strings.TrimSpace(text) == "" { skipped++ continue } // 先删旧键条目(best-effort,幂等)。 _, _ = h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_delete"), &contract.ToolCall{Tool: "kb_delete", Args: map[string]any{"kb": oldScoped, "file_id": d.ID}}) // 入队新 space 作用域的重灌作业(forceDoc=原名保持文档身份;worker 走完整入库管线)。 if _, e := h.enqueueIngest(c.Request.Context(), d.SpaceID, d.Owner, d.KB, newScoped, d.Name, "", nil, text); e != nil { log.Printf("[migrate-kb] 入队重灌失败 doc=%s: %v", d.ID, e) continue } enqueued++ } c.JSON(http.StatusOK, gin.H{"total": len(docs), "enqueued": enqueued, "skipped": skipped}) } // SpaceArchive: POST /api/v1/spaces/:id/archive —— 归档空间(须空间 admin;个人空间不可)。 func (h *Handler) SpaceArchive(c *gin.Context) { sid := c.Param("id") if !h.spaceAdminOr403(c, sid) { return } if err := h.db.ArchiveSpace(c.Request.Context(), sid); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) }