9dab25a5f0
补齐 Space 三类型的最后一种:tenant-kind「全员空间」= 用户最初想要的"共享工作区开关"。 与 project 空间的本质区别:自动纳入全租户成员(含之后新加入的),而非手动逐个邀请。 后端: - EnableTenantSpace(幂等):建 kind=tenant 空间 + 把全部 active 租户成员按租户角色纳入 (owner→owner…billing_admin→viewer);tenantSpaceID 查询;spaceRoleFromTenant 角色映射 - AddMemberByEmail 加租户成员时 autoJoinTenantSpace:若已启用全员空间则自动补纳新成员 - SpaceCreate handler:kind=tenant 路由到 EnableTenantSpace,且须租户 admin/owner(inline 403) 桌面端: - 空间切换器加"启用全员空间"入口(Globe 图标):仅租户 admin/owner 且尚未启用时显示, 带二次确认;启用后该按钮自动隐藏(hasTenantSpace),全员空间进切换器 验证(gateway+docker):member 启用 403 / owner 启用纳入全部现有成员(owner·member·viewer 按租户角色映射) / 新加租户成员自动纳入 / 幂等(再启用返回同一个不重复建) / viewer 在全员空间 入库 403(只读) / owner 建库 200;浏览器实机:启用后全员空间进切换器 + 启用按钮消失 至此 Space 三类型(个人/项目/全员)全部落地。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
8.0 KiB
Go
228 lines
8.0 KiB
Go
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 进不来。
|
|
// kind=tenant(全员空间)走 EnableTenantSpace(幂等纳入全部成员),且须租户 admin。
|
|
func (h *Handler) SpaceCreate(c *gin.Context) {
|
|
var b struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
_ = c.ShouldBindJSON(&b)
|
|
if b.Kind == store.SpaceKindTenant {
|
|
// 启用全员空间:整租户级动作,须租户 admin/owner。
|
|
if store.RoleRank(h.db.MemberRole(c.Request.Context(), tenantID(c), userID(c))) < store.RoleRank(store.RoleAdmin) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "启用全员空间需要租户管理员权限"})
|
|
return
|
|
}
|
|
s, err := h.db.EnableTenantSpace(c.Request.Context(), tenantID(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})
|
|
return
|
|
}
|
|
if strings.TrimSpace(b.Name) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
|
|
return
|
|
}
|
|
s, err := h.db.CreateSpace(c.Request.Context(), tenantID(c), b.Name, store.SpaceKindProject, 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"})
|
|
}
|