feat(space): 共享工作区增量3a —— Agent 编排按 Space 共享(纯 PG 打样)
引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。
后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
(如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)
桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除
验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,53 @@ export async function switchTenant(tenantId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 共享工作区(Space,增量3):活跃空间上下文 + 切换 + 列表 + 建 ----
|
||||
export interface SpaceCtx {
|
||||
space: { id: string; name: string; kind: string; creator: string; archived: boolean } | null;
|
||||
role: string; // 当前用户在此空间的角色(owner/admin/member/viewer)
|
||||
}
|
||||
export interface MySpace {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
kind: string; // personal / project / tenant
|
||||
creator: string;
|
||||
archived: boolean;
|
||||
role: string;
|
||||
members: number;
|
||||
}
|
||||
|
||||
export async function spaceCurrent(): Promise<SpaceCtx | null> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces/current`, { headers: bearer() }));
|
||||
if (!res.ok) return null;
|
||||
const d = (await res.json()) as Partial<SpaceCtx>;
|
||||
return { space: d.space ?? null, role: d.role ?? "" };
|
||||
}
|
||||
|
||||
export async function mySpaces(): Promise<{ spaces: MySpace[]; active: string }> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/spaces`, { headers: bearer() }));
|
||||
if (!res.ok) return { spaces: [], active: "" };
|
||||
const d = (await res.json()) as { spaces?: MySpace[]; active?: string };
|
||||
return { spaces: d.spaces ?? [], active: d.active ?? "" };
|
||||
}
|
||||
|
||||
export async function switchSpace(spaceId: string): Promise<void> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/space`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ space_id: spaceId }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `switch space failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSpace(name: string, kind = "project"): Promise<{ id: string }> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ name, kind }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `create space failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as { id: string };
|
||||
}
|
||||
|
||||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||
export interface UsageDay {
|
||||
day: string; // YYYYMMDD
|
||||
@@ -341,6 +388,9 @@ export interface AgentInfo {
|
||||
name: string;
|
||||
graph: string; // {nodes,edges} JSON
|
||||
updated_at?: string;
|
||||
owner?: string; // 创建人 user.id(共享工作区)
|
||||
creator?: string; // 创建人名字/邮箱(显示用)
|
||||
mine?: boolean; // 是否本人创建(决定能否删/覆盖)
|
||||
}
|
||||
|
||||
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
||||
|
||||
Reference in New Issue
Block a user