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:
+182
@@ -0,0 +1,182 @@
|
|||||||
|
# sundynix-agentix · 共享工作区设计(Space 抽象)
|
||||||
|
|
||||||
|
> 版本:v1 | 日期:2026-07-13
|
||||||
|
> 定位:`SAAS_DESIGN.md` **P1 增量3(资源共享 / 共享工作区)** 的落地设计。
|
||||||
|
> 前提(已确认):**核心要灵活、不写死**——不做"整租户一刀切共享",要支持"针对某项目临时组队贡献"。
|
||||||
|
> 基线事实:KB/Agent/Doc 已带 `tenant_id` 且受 gorm 租户插件过滤(见 `store/tenant_scope.go`),
|
||||||
|
> 但仍按 `owner`(userID) 锁到个人;存储作用域键是 `owner/name`。
|
||||||
|
> 相关:`SAAS_DESIGN.md`(多租户脊柱)、RBAC 落地(`store.RoleRank` + `middleware.RequireTenantRole`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话设计
|
||||||
|
|
||||||
|
**资源不再直接绑 `user` 或 `tenant`,而是绑一个中间层「Space(工作区/项目空间)」——一个可大可小、可临时可长期的协作容器。**
|
||||||
|
个人私有、项目临时组队、整租户共享三种场景,全从同一个 Space 模型里长出来。作用域键从 `owner/name` 换成 `space_id/name`,
|
||||||
|
就这一处把"锁"从人解到空间,彻底不写死。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 为什么不能"整租户一刀切"
|
||||||
|
|
||||||
|
用户的核心诉求:**灵活**。评估过的两个"简单"方案都不行:
|
||||||
|
|
||||||
|
| 方案 | 问题 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A. 整租户统一共享**(资源 owner→tenant) | 只有"全私有 / 全租户"两态,没有中间态;一个用户无法"私有一批 + 项目X共享一批 + 项目Y共享一批" | ❌ 死板,且将来想灵活要**再迁一次** |
|
||||||
|
| **B. 每资源加 shared 标记 + ACL** | 无天然分组,"一队人围着一个项目协作这 5 个库"很难表达;per-resource ACL 迅速失控 | ❌ 乱 |
|
||||||
|
| **C. Space 中间容器**(本方案) | 一个抽象覆盖三种场景;匹配"一队人围着一个项目贡献"的心智;一次迁到正确容器、永不返工 | ✅ 采纳 |
|
||||||
|
|
||||||
|
这是成熟产品的通用解法:Notion teamspace、Slack channel、Linear team、GitHub repo+协作者。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心抽象:租户 > Space > 成员
|
||||||
|
|
||||||
|
**层级**:`Tenant`(计费/隔离边界) → `Space`(共享容器) → `SpaceMember`(谁在里面)。
|
||||||
|
资源(KB/Agent/Doc/Run)挂在 Space 上,不再挂在 user 上。
|
||||||
|
|
||||||
|
**三种场景 → 一个模型**:
|
||||||
|
|
||||||
|
| 场景 | Space 表达 | kind |
|
||||||
|
|---|---|---|
|
||||||
|
| 个人私有(= 现状) | 每人**自动一个个人 Space**(1 成员 = 自己),新资源默认落这里 | `personal` |
|
||||||
|
| **针对项目临时组队贡献** | 任意成员**建个 Space**,拉租户里几个人进来,共建 KB/编排,完事**归档** | `project` |
|
||||||
|
| **整租户共享("开关")** | 一个成员 = 全租户的**"全员 Space"**(开关开 = 自动建它、自动纳入全部成员) | `tenant` |
|
||||||
|
|
||||||
|
**"临时"是免费的**:临时团队 = 建 `project` Space → 拉人 → 贡献 → 归档(软删,可选自动过期)。临时只是生命周期属性,不是另一套机制。
|
||||||
|
**grain 提醒**:共享绑 **Space(项目)**,不要绑单次 task(task 是一次执行、跑完即逝);一个 Space 可关联多次 task 运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 现状盘点(改造起点,已核实)
|
||||||
|
|
||||||
|
三层"锁到个人",尽管 tenant_id 已存在:
|
||||||
|
|
||||||
|
| 锁 | 现状(file 参考) | 改造 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 查询显式 `WHERE owner=?` | `ListKB/ListAgents/ListVault/GetDocByID…`(`store/model.go`、`store/tenant.go`)在插件的 tenant 过滤之上又按 userID 过滤 | 去掉 owner 过滤,改按 `space_id`;`owner` 降级为"创建人"归属 |
|
||||||
|
| ② 唯一索引 `(owner,name)` | `KB.idx_kb_owner_name` / `Agent.idx_agent_on` / `Doc.idx_doc_okn`(`store/model.go`) | 改 `(space_id,name)`——名字**按空间唯一** |
|
||||||
|
| ③ 存储作用域键 `owner/name` | `scopedKB = userID+"/"+name`(`handler/kb.go:38`),作 mcp-go 工具 `kb` 参数 | 改 `space_id+"/"+name` |
|
||||||
|
|
||||||
|
**关键有利事实**(决定迁移不重):
|
||||||
|
- **Milvus 用标量字段 `kb`(VarChar) 过滤**(`kb == "owner/name"`,`rag/milvus.go:112/207`),**不是物理分区** → re-key = 改字段值 / 从 MinIO 原文重灌,不用重建分区。
|
||||||
|
- **MinIO 对象键不透明**(`owner/kb/doc`,存进 `Doc.ObjectKey`,按 key 精确取,`handler/kb.go:154`)→ **不用迁**:老对象照常可读,新对象走新键。
|
||||||
|
- **Agent 纯 PG**(Graph JSON,`store/model.go` `ListAgents/SaveAgent/DeleteAgent`,零 Milvus/Neo4j/MinIO 依赖)→ **可先在 Agent 上把整套 Space 打样,零存储风险**。
|
||||||
|
- **偏好记忆(`recall_user_memory`/`remember_user_fact`)保持个人**,不进 Space(个人偏好共享无意义)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
**+2 张表**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Space {
|
||||||
|
id snowflake
|
||||||
|
tenant_id string // 归属租户(隔离/计费边界仍在租户)
|
||||||
|
name string
|
||||||
|
kind string // personal / project / tenant
|
||||||
|
creator string // user.id
|
||||||
|
archived bool // 软归档(临时空间完事即归档)
|
||||||
|
}
|
||||||
|
// 唯一:(tenant_id, kind='personal', creator) 保证每人每租户仅一个个人空间
|
||||||
|
|
||||||
|
SpaceMember {
|
||||||
|
space_id string
|
||||||
|
user_id string
|
||||||
|
role string // 复用租户角色语义:owner/admin/member/viewer(见 §5)
|
||||||
|
}
|
||||||
|
// 唯一:(space_id, user_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**资源改造**(KB / Agent / Doc / DocLink / Task-Run):
|
||||||
|
- 加 `space_id string`(`index`);保留 `tenant_id`(防御纵深 + 跨租户查询仍被插件挡);保留 `owner` 作**创建人归属**。
|
||||||
|
- 唯一索引 `(owner,name)` → `(space_id,name)`。
|
||||||
|
- 作用域键 `owner/name` → `space_id/name`。
|
||||||
|
|
||||||
|
**注**:租户隔离仍靠 `tenant_id`(Space 属租户,`space.tenant_id` 兜底);主作用域切到 `space_id`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 权限(接已落地的 RBAC)
|
||||||
|
|
||||||
|
每个 Space 有自己的成员角色,直接复用 `store.RoleRank` 阶梯(owner>admin>member>viewer/billing_admin):
|
||||||
|
|
||||||
|
| 动作 | viewer | member | admin/owner |
|
||||||
|
|---|:--:|:--:|:--:|
|
||||||
|
| 看 / 搜索空间内资源 | ✅ | ✅ | ✅ |
|
||||||
|
| 建 KB/Agent、入库、改**自己建的** | ❌ | ✅ | ✅ |
|
||||||
|
| 改 / 删**别人建的** | ❌ | ❌ | ✅ |
|
||||||
|
| 管理空间成员(拉人/改角色/移除) | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
- 新增中间件 `RequireSpaceRole(db, minRole)`(照 `RequireTenantRole` 套路,从 ctx 取 space_id + user 判 `SpaceMember.role`)。
|
||||||
|
- **这才让 owner/admin/member/viewer 五个角色真正有区别**——RBAC 落地时留的"架子"在此点亮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 迁移方案
|
||||||
|
|
||||||
|
**一次性迁到正确容器(个人空间),个人租户单成员无冲突**:
|
||||||
|
|
||||||
|
1. **建个人空间**:给每个用户在其每个所属租户建一个 `personal` Space(幂等,启动回填)。
|
||||||
|
2. **资源 re-key**:存量 KB/Agent/Doc/DocLink 的 `space_id` 回填为其 owner 在**对应租户**的个人空间 id;唯一索引 `(owner,name)`→`(space_id,name)`(AutoMigrate 不删旧索引,需显式 migration)。
|
||||||
|
3. **存储层**:
|
||||||
|
- Milvus:`kb` 字段 `owner/name`→`space_id/name`(从 MinIO 原文重灌,原文安全兜底)。
|
||||||
|
- Neo4j:`MATCH (n) WHERE n.kb STARTS WITH 'owner/' SET n.kb = ...` 批量 Cypher。
|
||||||
|
- MinIO:**不动**(不透明键)。
|
||||||
|
4. **命名冲突**:`(space_id,name)` 上唯一约束前,多成员租户里两人同名资源需先改名(个人空间单成员天然无冲突,只有后续"移进共享空间"时才判重)。
|
||||||
|
|
||||||
|
**"把个人资源共享进项目"= 一等操作**:改资源 `space_id` + re-key 存储 `kb` 字段(单资源,非全量)→ 天然支持"默认私有 + 显式共享"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 分层落地(先 Agent 打样,再 KB)
|
||||||
|
|
||||||
|
### 阶段 3a:Agent 编排共享 —— 纯 PG,轻,先行 ✅ 推荐起点
|
||||||
|
- 建 `Space`/`SpaceMember` 表 + `Agent.space_id`;个人空间自动建 + 存量 Agent re-key。
|
||||||
|
- `ListAgents/SaveAgent/DeleteAgent` 去 owner 过滤、改按 space_id;唯一 `(space_id,name)`;owner 存创建人。
|
||||||
|
- 空间管理接口:建 project 空间 / 拉人(邮箱,复用 `AddMemberByEmail` 思路)/ 改角色 / 移除 / 归档。
|
||||||
|
- `RequireSpaceRole` 门控 + 前端**空间切换器**(顶栏,类似租户切换器)+ StudioView 按空间角色 readOnly。
|
||||||
|
- **一周内端到端验证协作 + 权限 + 切换 UX,零存储风险。**
|
||||||
|
|
||||||
|
### 阶段 3b:KB 知识库共享 —— 存储 re-key,随后
|
||||||
|
- 把同一套 `space_id` 作用域推到 KB/Doc/DocLink + `scopedKB` 改 `space_id/name`。
|
||||||
|
- Milvus 重灌 / Neo4j 批量 SET / MinIO 不迁 / 存量数据迁移 + 命名冲突处理(§6)。
|
||||||
|
- 模式已被 3a 验证,风险最低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 计费衔接(无新工作)
|
||||||
|
|
||||||
|
- 运行共享 Agent / 搜共享 KB 的**计费目标不变**:仍由 `ResolveBillingTenantID(user, activeTenant)` 按活跃租户 + `shared_billing` 决定记谁的池子(`SAAS_DESIGN.md` 增量2)。
|
||||||
|
- Space 只管"看得见/改得动谁的资源",**不改"谁付钱"**——两个维度正交,互不干扰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 开放问题(待定,别提前写死)
|
||||||
|
|
||||||
|
| 议题 | 倾向 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **默认落哪** | 个人空间 | 新资源默认私有,要共享才移/复制进项目空间 |
|
||||||
|
| **删别人建的** | admin/owner 才行 | member 只能删自己建的(§5) |
|
||||||
|
| **Space 嵌套** | v1 不做 | 扁平:租户下平铺 Space;有需求再说 |
|
||||||
|
| **临时空间自动过期** | v1 手动归档 | 先给 `archived` + 手动归档;自动过期(TTL)后置 |
|
||||||
|
| **跨租户 Space** | 不做 | Space 严格属单租户,隔离边界不破 |
|
||||||
|
| **个人偏好记忆** | 保持个人 | 不进 Space |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 明确不做(防过度设计)
|
||||||
|
|
||||||
|
- ❌ 不做 per-resource ACL(用 Space 分组替代)。
|
||||||
|
- ❌ v1 不做嵌套空间 / 跨租户空间 / 空间模板市场。
|
||||||
|
- ❌ 不动 MinIO 对象键、不重建 Milvus 分区(现状标量字段 + 不透明键让迁移可控)。
|
||||||
|
- ❌ 不碰计费目标解析(正交,复用增量2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:一句话给未来的自己
|
||||||
|
|
||||||
|
**Space 相对"整租户统一"的额外成本 ≈ 两张小表 + 空间成员管理 + 切换器 UI;最重的存储 re-key 两方案一样。
|
||||||
|
用对容器几乎免费,还省掉二次迁移。先拿纯 PG 的 Agent 打样跑通协作+权限+切换,再上 KB 存储 re-key。**
|
||||||
@@ -15,7 +15,7 @@ import { Placeholder } from "./views/Placeholder";
|
|||||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||||
import { UpdateBanner } from "./components/UpdateBanner";
|
import { UpdateBanner } from "./components/UpdateBanner";
|
||||||
import { Login } from "./views/Login";
|
import { Login } from "./views/Login";
|
||||||
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, type Identity, type AuthUser, type TenantCtx, type MyTenant } from "./lib/api";
|
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
||||||
import type { TaskDsl } from "./lib/dsl";
|
import type { TaskDsl } from "./lib/dsl";
|
||||||
import { emptyRun, type RunState } from "./lib/run";
|
import { emptyRun, type RunState } from "./lib/run";
|
||||||
import { ToastProvider } from "./ui";
|
import { ToastProvider } from "./ui";
|
||||||
@@ -44,6 +44,8 @@ export default function App() {
|
|||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [tenant, setTenant] = useState<TenantCtx | null>(null);
|
const [tenant, setTenant] = useState<TenantCtx | null>(null);
|
||||||
const [tenants, setTenants] = useState<MyTenant[]>([]);
|
const [tenants, setTenants] = useState<MyTenant[]>([]);
|
||||||
|
const [space, setSpace] = useState<SpaceCtx | null>(null);
|
||||||
|
const [spaces, setSpaces] = useState<MySpace[]>([]);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
|
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
|
||||||
const [run, setRun] = useState<RunState>(emptyRun);
|
const [run, setRun] = useState<RunState>(emptyRun);
|
||||||
@@ -88,7 +90,7 @@ export default function App() {
|
|||||||
setTenant(null);
|
setTenant(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 租户上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时(顶栏余额芯片用)。
|
// 租户 + 工作区(Space)上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时。
|
||||||
const refreshTenant = useCallback(() => {
|
const refreshTenant = useCallback(() => {
|
||||||
tenantCurrent()
|
tenantCurrent()
|
||||||
.then(setTenant)
|
.then(setTenant)
|
||||||
@@ -96,11 +98,28 @@ export default function App() {
|
|||||||
myTenants()
|
myTenants()
|
||||||
.then((r) => setTenants(r.tenants))
|
.then((r) => setTenants(r.tenants))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
spaceCurrent()
|
||||||
|
.then(setSpace)
|
||||||
|
.catch(() => {});
|
||||||
|
mySpaces()
|
||||||
|
.then((r) => setSpaces(r.spaces))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
const onSwitchTenant = useCallback(
|
const onSwitchTenant = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await switchTenant(id);
|
await switchTenant(id);
|
||||||
|
refreshTenant(); // 切租户会重置活跃空间到该租户个人空间,一并刷新
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshTenant],
|
||||||
|
);
|
||||||
|
const onSwitchSpace = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await switchSpace(id);
|
||||||
refreshTenant();
|
refreshTenant();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -108,10 +127,24 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
[refreshTenant],
|
[refreshTenant],
|
||||||
);
|
);
|
||||||
|
const onCreateSpace = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
try {
|
||||||
|
const { id } = await createSpace(name, "project");
|
||||||
|
await switchSpace(id); // 建完即切入
|
||||||
|
refreshTenant();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error).message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshTenant],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setTenant(null);
|
setTenant(null);
|
||||||
setTenants([]);
|
setTenants([]);
|
||||||
|
setSpace(null);
|
||||||
|
setSpaces([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshTenant();
|
refreshTenant();
|
||||||
@@ -255,7 +288,7 @@ export default function App() {
|
|||||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||||
/>
|
/>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||||
<ApprovalBar run={run} />
|
<ApprovalBar run={run} />
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
@@ -263,7 +296,7 @@ export default function App() {
|
|||||||
{view === "home" ? (
|
{view === "home" ? (
|
||||||
<Home onSelect={setView} />
|
<Home onSelect={setView} />
|
||||||
) : view === "studio" ? (
|
) : view === "studio" ? (
|
||||||
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} />
|
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
||||||
) : view === "kb" ? (
|
) : view === "kb" ? (
|
||||||
<KbView identity={identity} />
|
<KbView identity={identity} />
|
||||||
) : view === "report" ? (
|
) : view === "report" ? (
|
||||||
|
|||||||
@@ -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 {
|
export interface UsageDay {
|
||||||
day: string; // YYYYMMDD
|
day: string; // YYYYMMDD
|
||||||
@@ -341,6 +388,9 @@ export interface AgentInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
graph: string; // {nodes,edges} JSON
|
graph: string; // {nodes,edges} JSON
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
owner?: string; // 创建人 user.id(共享工作区)
|
||||||
|
creator?: string; // 创建人名字/邮箱(显示用)
|
||||||
|
mine?: boolean; // 是否本人创建(决定能否删/覆盖)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2 } from "lucide-react";
|
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus } from "lucide-react";
|
||||||
import type { AuthUser, TenantCtx, MyTenant } from "../lib/api";
|
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
||||||
import { useHealth } from "../lib/health";
|
import { useHealth } from "../lib/health";
|
||||||
import { isMacDesktop } from "../lib/desktop";
|
import { isMacDesktop } from "../lib/desktop";
|
||||||
import { useTheme } from "../lib/theme";
|
import { useTheme } from "../lib/theme";
|
||||||
@@ -65,8 +65,45 @@ function TenantSwitcher({ tenants, activeId, onSwitch }: { tenants: MyTenant[];
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
// 工作区切换器:切换活跃 Space(共享工作区 — 个人/项目/全员)。含"新建项目空间"。
|
||||||
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
// 个人空间恒有,故 ≥1 即显示(让用户随时能建/进项目协作空间)。
|
||||||
|
function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate }: { spaces: MySpace[]; activeId?: string; onSwitch?: (id: string) => void; onCreate?: (name: string) => void }) {
|
||||||
|
if (spaces.length === 0) return null;
|
||||||
|
const label = (s: MySpace) => (s.kind === "personal" ? "个人空间" : s.name) + (s.kind !== "personal" ? ` · ${s.members}人` : "");
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1" style={NODRAG}>
|
||||||
|
<div className="relative">
|
||||||
|
<UsersIcon className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||||
|
<select
|
||||||
|
value={activeId ?? ""}
|
||||||
|
onChange={(e) => onSwitch?.(e.target.value)}
|
||||||
|
title="切换当前工作区(共享资源作用域)"
|
||||||
|
className="appearance-none rounded-md border border-line bg-ink-800 py-1 pl-7 pr-7 text-xs text-slate-300 focus:border-brand focus:outline-none"
|
||||||
|
>
|
||||||
|
{spaces.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{label(s)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const name = window.prompt("新建项目空间名称(团队成员可共享此空间的编排/知识库)");
|
||||||
|
if (name && name.trim()) onCreate?.(name.trim());
|
||||||
|
}}
|
||||||
|
title="新建项目空间"
|
||||||
|
className="flex items-center rounded-md border border-line bg-ink-800 px-1.5 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||||
|
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||||
const h = useHealth();
|
const h = useHealth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -110,6 +147,7 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, onLogout, o
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||||
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
||||||
|
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} />
|
||||||
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
|
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function buildExample(): { nodes: Node[]; edges: Edge[] } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 编排 Studio:左(节点面板 + 我的编排) · 中画布 · 右检查器 · 顶工具栏。
|
// 编排 Studio:左(节点面板 + 我的编排) · 中画布 · 右检查器 · 顶工具栏。
|
||||||
export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity; readOnly?: boolean }) {
|
export function StudioView({ onRun, phase, identity, readOnly = false, spaceId = "", spaceReadOnly = false }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity; readOnly?: boolean; spaceId?: string; spaceReadOnly?: boolean }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
@@ -69,7 +69,8 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
|
|||||||
const refreshAgents = useCallback(() => {
|
const refreshAgents = useCallback(() => {
|
||||||
listAgents(identity).then(setAgents).catch(() => {});
|
listAgents(identity).then(setAgents).catch(() => {});
|
||||||
}, [identity]);
|
}, [identity]);
|
||||||
useEffect(() => refreshAgents(), [refreshAgents]);
|
// 切换工作区(spaceId 变)后重新拉取该空间的编排(共享工作区隔离)。
|
||||||
|
useEffect(() => refreshAgents(), [refreshAgents, spaceId]);
|
||||||
|
|
||||||
const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]);
|
const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]);
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
|
|||||||
};
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权保存编排");
|
||||||
const nm = name.trim();
|
const nm = name.trim();
|
||||||
if (!nm) return toast.push("error", "先填编排名");
|
if (!nm) return toast.push("error", "先填编排名");
|
||||||
if (nodes.length === 0) return toast.push("error", "画布为空");
|
if (nodes.length === 0) return toast.push("error", "画布为空");
|
||||||
@@ -219,18 +221,21 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col border-t border-line p-2">
|
<div className="flex min-h-0 flex-1 flex-col border-t border-line p-2">
|
||||||
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] font-semibold text-slate-500">
|
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] font-semibold text-slate-500">
|
||||||
<Workflow className="h-3.5 w-3.5" /> 我的编排 {agents.length > 0 && `(${agents.length})`}
|
<Workflow className="h-3.5 w-3.5" /> 空间编排 {agents.length > 0 && `(${agents.length})`}
|
||||||
</div>
|
</div>
|
||||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-auto">
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-auto">
|
||||||
{agents.length === 0 && <li className="px-1 text-[11px] text-slate-600">保存后在此列出,跨会话可载入。</li>}
|
{agents.length === 0 && <li className="px-1 text-[11px] text-slate-600">保存后在此列出,同空间成员共享。</li>}
|
||||||
{agents.map((a) => (
|
{agents.map((a) => (
|
||||||
<li key={a.name} className="group flex items-center gap-1 rounded hover:bg-ink-800">
|
<li key={a.name} className="group flex items-center gap-1 rounded hover:bg-ink-800">
|
||||||
<button onClick={() => openAgent(a)} className="flex-1 truncate px-2 py-1.5 text-left text-xs text-slate-300" title={`载入「${a.name}」`}>
|
<button onClick={() => openAgent(a)} className="flex-1 truncate px-2 py-1.5 text-left text-xs text-slate-300" title={`载入「${a.name}」${a.creator ? ` · 由 ${a.creator} 创建` : ""}`}>
|
||||||
{a.name}
|
{a.name}
|
||||||
|
{a.creator && !a.mine && <span className="ml-1 text-[10px] text-slate-500">· {a.creator}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => removeAgent(a.name)} className="px-1 text-slate-600 opacity-0 hover:text-danger group-hover:opacity-100" title="删除">
|
{(a.mine !== false && !spaceReadOnly) && (
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<button onClick={() => removeAgent(a.name)} className="px-1 text-slate-600 opacity-0 hover:text-danger group-hover:opacity-100" title="删除">
|
||||||
</button>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -255,7 +260,7 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
|
|||||||
</Button>
|
</Button>
|
||||||
<span className="mx-1 h-4 w-px bg-line" />
|
<span className="mx-1 h-4 w-px bg-line" />
|
||||||
<Input className="h-8 w-32" value={name} onChange={(e) => setName(e.target.value)} placeholder="编排名" />
|
<Input className="h-8 w-32" value={name} onChange={(e) => setName(e.target.value)} placeholder="编排名" />
|
||||||
<Button size="sm" variant="primary" icon={Save} onClick={save} disabled={nodes.length === 0}>
|
<Button size="sm" variant="primary" icon={Save} onClick={save} disabled={nodes.length === 0 || spaceReadOnly} title={spaceReadOnly ? "当前工作区你是只读成员(viewer)" : undefined}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
<span className="ml-auto text-[11px] text-slate-500">
|
<span className="ml-auto text-[11px] text-slate-500">
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ func main() {
|
|||||||
if err := db.BackfillRowTenants(context.Background()); err != nil {
|
if err := db.BackfillRowTenants(context.Background()); err != nil {
|
||||||
log.Printf("[startup] 存量行 tenant_id 回填失败: %v", err)
|
log.Printf("[startup] 存量行 tenant_id 回填失败: %v", err)
|
||||||
}
|
}
|
||||||
|
// 增量3 共享工作区:给每个租户成员补个人空间,再把存量 Agent re-key 到其个人空间(幂等;顺序:
|
||||||
|
// 空间先建、Agent 后迁——迁移内先回填 space_id 再建唯一索引,避免存量空 space_id 撞车)。
|
||||||
|
if n, err := db.BackfillPersonalSpaces(context.Background()); err != nil {
|
||||||
|
log.Printf("[startup] 个人空间回填失败: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("[startup] 为 %d 个租户成员补建了个人空间", n)
|
||||||
|
}
|
||||||
|
if err := db.MigrateAgentSpaces(context.Background()); err != nil {
|
||||||
|
log.Printf("[startup] Agent 空间作用域迁移失败: %v", err)
|
||||||
|
}
|
||||||
cache := store.OpenRedis(redisAddr) // CacheDB: Session / Rate Limit(连不上则降级)
|
cache := store.OpenRedis(redisAddr) // CacheDB: Session / Rate Limit(连不上则降级)
|
||||||
defer cache.Close()
|
defer cache.Close()
|
||||||
bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流
|
bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流
|
||||||
|
|||||||
@@ -5,23 +5,31 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentList: GET /api/v1/agents —— 当前用户保存的全部 Agent 编排(owner 隔离,最近在前)。
|
// AgentList: GET /api/v1/agents —— 当前活跃工作区(space)的全部编排(共享:同空间成员都可见,含创建人)。
|
||||||
func (h *Handler) AgentList(c *gin.Context) {
|
func (h *Handler) AgentList(c *gin.Context) {
|
||||||
rows, err := h.db.ListAgents(c.Request.Context(), userID(c))
|
rows, err := h.db.ListAgents(c.Request.Context(), spaceID(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(rows))
|
out := make([]gin.H, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
out = append(out, gin.H{"name": r.Name, "graph": r.Graph, "updated_at": r.UpdatedAt})
|
creator := r.CreatorName
|
||||||
|
if creator == "" {
|
||||||
|
creator = r.CreatorMail
|
||||||
|
}
|
||||||
|
out = append(out, gin.H{"name": r.Name, "graph": r.Graph, "updated_at": r.UpdatedAt,
|
||||||
|
"owner": r.Owner, "creator": creator, "mine": r.Owner == userID(c)})
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"agents": out})
|
c.JSON(http.StatusOK, gin.H{"agents": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentSave: POST /api/v1/agents {name, graph} —— 保存/更新一份编排(graph 为 {nodes,edges} JSON)。
|
// AgentSave: POST /api/v1/agents {name, graph} —— 保存/更新编排到当前空间(空间内重名覆盖)。
|
||||||
|
// 已由 RequireSpaceRole(member) 门控:viewer 只读进不来。
|
||||||
func (h *Handler) AgentSave(c *gin.Context) {
|
func (h *Handler) AgentSave(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -31,21 +39,42 @@ func (h *Handler) AgentSave(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name/graph required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name/graph required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.db.SaveAgent(c.Request.Context(), userID(c), strings.TrimSpace(body.Name), body.Graph); err != nil {
|
name := strings.TrimSpace(body.Name)
|
||||||
|
// 覆盖他人建的编排需 admin/owner;member 只能改自己建的(新建不受限)。
|
||||||
|
if existing, _ := h.db.GetAgent(c.Request.Context(), spaceID(c), name); existing != nil &&
|
||||||
|
existing.Owner != userID(c) && store.RoleRank(h.db.SpaceMemberRole(c.Request.Context(), spaceID(c), userID(c))) < store.RoleRank(store.RoleAdmin) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "该编排由他人创建,仅管理员可覆盖"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.SaveAgent(c.Request.Context(), spaceID(c), userID(c), name, body.Graph); err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"name": strings.TrimSpace(body.Name)})
|
c.JSON(http.StatusOK, gin.H{"name": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentDelete: DELETE /api/v1/agents?name= —— 删除一份编排。
|
// AgentDelete: DELETE /api/v1/agents?name= —— 删除当前空间的一份编排。
|
||||||
|
// 删他人建的需 admin/owner;member 只能删自己建的。
|
||||||
func (h *Handler) AgentDelete(c *gin.Context) {
|
func (h *Handler) AgentDelete(c *gin.Context) {
|
||||||
name := strings.TrimSpace(c.Query("name"))
|
name := strings.TrimSpace(c.Query("name"))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.db.DeleteAgent(c.Request.Context(), userID(c), name); err != nil {
|
existing, err := h.db.GetAgent(c.Request.Context(), spaceID(c), name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 已不存在,幂等
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing.Owner != userID(c) && store.RoleRank(h.db.SpaceMemberRole(c.Request.Context(), spaceID(c), userID(c))) < store.RoleRank(store.RoleAdmin) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "该编排由他人创建,仅管理员可删除"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.DeleteAgent(c.Request.Context(), spaceID(c), name); err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"})
|
||||||
|
}
|
||||||
@@ -532,6 +532,16 @@ func tenantID(c *gin.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spaceID 取当前请求的活跃工作区标识 —— 由 SpaceContext 中间件注入(共享工作区作用域用;未解析返回空)。
|
||||||
|
func spaceID(c *gin.Context) string {
|
||||||
|
if v, ok := c.Get("space_id"); ok {
|
||||||
|
if s, _ := v.(string); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// TenantCurrent: GET /api/v1/tenants/current —— 当前用户的租户上下文 + 角色(前端 + 联调验证)。
|
// TenantCurrent: GET /api/v1/tenants/current —— 当前用户的租户上下文 + 角色(前端 + 联调验证)。
|
||||||
func (h *Handler) TenantCurrent(c *gin.Context) {
|
func (h *Handler) TenantCurrent(c *gin.Context) {
|
||||||
tid := tenantID(c)
|
tid := tenantID(c)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CtxSpaceID 是当前活跃工作区(Space) id 在 gin.Context 的键(增量3 共享工作区)。
|
||||||
|
const CtxSpaceID = "space_id"
|
||||||
|
|
||||||
|
// SpaceContext 解析当前用户在活跃租户内的活跃工作区并注入 space_id。
|
||||||
|
// 须挂在 Auth + TenantContext 之后(依赖 uid 与 tenant_id);未登录/无租户则跳过。
|
||||||
|
// 活跃空间无效(跨租户/已归档/非成员)时由 ActiveSpaceForUser 兜底回退个人空间。
|
||||||
|
func SpaceContext(db *store.Postgres) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid, _ := c.Get(CtxUserID)
|
||||||
|
id, _ := uid.(string)
|
||||||
|
tid, _ := c.Get(CtxTenantID)
|
||||||
|
tenant, _ := tid.(string)
|
||||||
|
if id != "" && tenant != "" {
|
||||||
|
if s, err := db.ActiveSpaceForUser(c.Request.Context(), tenant, id); err == nil && s != nil {
|
||||||
|
c.Set(CtxSpaceID, s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceRoleResolver 解析用户在某空间的角色(*store.Postgres 天然满足)。抽接口便于单测门控。
|
||||||
|
type SpaceRoleResolver interface {
|
||||||
|
SpaceMemberRole(ctx context.Context, spaceID, userID string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireSpaceRole 要求当前用户在活跃空间中的角色不低于 minRole(按 store.RoleRank 阶梯)。
|
||||||
|
// 须挂在 RequireAuth + SpaceContext 之后。非成员/无空间 → rank 0 → 拦下。
|
||||||
|
// 用于共享工作区的写操作门控(建/存/删编排要 ≥member;viewer 只读)。
|
||||||
|
func RequireSpaceRole(db SpaceRoleResolver, minRole string) gin.HandlerFunc {
|
||||||
|
min := store.RoleRank(minRole)
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid, _ := c.Get(CtxUserID)
|
||||||
|
id, _ := uid.(string)
|
||||||
|
if id == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sid, _ := c.Get(CtxSpaceID)
|
||||||
|
space, _ := sid.(string)
|
||||||
|
if store.RoleRank(db.SpaceMemberRole(c.Request.Context(), space, id)) < min {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "当前空间角色为只读,无权执行该操作"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSpaceRole struct{ role string }
|
||||||
|
|
||||||
|
func (f fakeSpaceRole) SpaceMemberRole(context.Context, string, string) string { return f.role }
|
||||||
|
|
||||||
|
func spaceGateEngine(uid, space, memberRole, minRole string) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
if uid != "" {
|
||||||
|
c.Set(CtxUserID, uid)
|
||||||
|
}
|
||||||
|
if space != "" {
|
||||||
|
c.Set(CtxSpaceID, space)
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
r.POST("/agents", RequireSpaceRole(fakeSpaceRole{role: memberRole}, minRole),
|
||||||
|
func(c *gin.Context) { c.String(http.StatusOK, "saved") })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireSpaceRole_Gate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
uid string
|
||||||
|
space string
|
||||||
|
memberRole string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{"owner 放行", "u1", "s1", store.RoleOwner, http.StatusOK},
|
||||||
|
{"admin 放行", "u1", "s1", store.RoleAdmin, http.StatusOK},
|
||||||
|
{"member 放行", "u1", "s1", store.RoleMember, http.StatusOK},
|
||||||
|
{"viewer 拦下(空间只读)", "u1", "s1", store.RoleViewer, http.StatusForbidden},
|
||||||
|
{"非空间成员 拦下", "u1", "s1", "", http.StatusForbidden},
|
||||||
|
{"未登录 401", "", "s1", store.RoleOwner, http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := spaceGateEngine(tc.uid, tc.space, tc.memberRole, store.RoleMember)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/agents", nil))
|
||||||
|
if w.Code != tc.wantCode {
|
||||||
|
t.Errorf("状态码=%d, 期望 %d(body=%s)", w.Code, tc.wantCode, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
r.Use(cors()) // 桌面端/浏览器跨源访问
|
r.Use(cors()) // 桌面端/浏览器跨源访问
|
||||||
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
|
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
|
||||||
r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
|
r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
|
||||||
|
r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
|
||||||
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
|
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
|
||||||
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event)
|
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event)
|
||||||
|
|
||||||
@@ -81,9 +82,20 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||||
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||||
p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离)
|
p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人)
|
||||||
p.POST("/agents", h.AgentSave) // 保存/更新编排
|
p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下
|
||||||
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
p.DELETE("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentDelete) // 删除编排:viewer 拦下(删他人另需 admin,见 handler)
|
||||||
|
|
||||||
|
// 共享工作区(Space):切换 / 列表 / 建 / 成员管理(增量3)
|
||||||
|
p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
|
||||||
|
p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
|
||||||
|
p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
|
||||||
|
p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
|
||||||
|
p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
|
||||||
|
p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin)
|
||||||
|
p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
|
||||||
|
p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
|
||||||
|
p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
|
||||||
p.POST("/reports", h.GenerateReport) // 报告生成
|
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||||
p.GET("/billing", h.Billing)
|
p.GET("/billing", h.Billing)
|
||||||
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
|
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
|
||||||
|
|||||||
@@ -49,46 +49,80 @@ func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error
|
|||||||
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
|
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent 是一份保存的 Agent 编排(React Flow 图 JSON,按 owner 隔离)。
|
// Agent 是一份保存的 Agent 编排(React Flow 图 JSON)。
|
||||||
// 表名 sundynix_agent。(owner,name) 唯一 —— 同一用户下编排名不重复。
|
// 表名 sundynix_agent。增量3:作用域从 owner 改为 space_id——(space_id,name) 唯一,
|
||||||
|
// 同一工作区内编排名不重复;owner 降级为"创建人"归属(谁建的,供 UI 显示 / 删他人鉴权)。
|
||||||
|
// 唯一索引 idx_agent_sn(space_id,name) 不在结构体标记里建(否则 AutoMigrate 早于回填、存量 space_id 空会撞车),
|
||||||
|
// 而由启动迁移 MigrateAgentSpaces 在回填 space_id 后显式建;旧 idx_agent_on(owner,name) 同步 DROP。
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
TenantID string `gorm:"size:64;index"` // 多租户作用域(tenant 插件自动填/过滤)
|
TenantID string `gorm:"size:64;index"` // 多租户作用域(tenant 插件自动填/过滤,防越权写他租)
|
||||||
Owner string `gorm:"size:64;uniqueIndex:idx_agent_on"`
|
SpaceID string `gorm:"size:64;index"` // 所属工作区(共享单位;唯一约束见 MigrateAgentSpaces)
|
||||||
Name string `gorm:"size:128;uniqueIndex:idx_agent_on"`
|
Owner string `gorm:"size:64;index"` // 创建人 user.id(归属,非隔离键)
|
||||||
|
Name string `gorm:"size:128"`
|
||||||
Graph string `gorm:"type:text"` // {nodes,edges} 的 JSON(含布局)
|
Graph string `gorm:"type:text"` // {nodes,edges} 的 JSON(含布局)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Agent) TableName() string { return "sundynix_agent" }
|
func (Agent) TableName() string { return "sundynix_agent" }
|
||||||
func (Agent) isTenantScoped() {}
|
func (Agent) isTenantScoped() {}
|
||||||
|
|
||||||
// ListAgents 返回某 owner 的全部编排(最近更新在前)。
|
// AgentInfo 是编排列表视图(含创建人邮箱/名字,供共享工作区显示"谁建的")。
|
||||||
func (p *Postgres) ListAgents(ctx context.Context, owner string) ([]Agent, error) {
|
type AgentInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Graph string `json:"graph"`
|
||||||
|
Owner string `json:"owner"` // 创建人 user.id
|
||||||
|
CreatorName string `json:"creator_name"`
|
||||||
|
CreatorMail string `json:"creator_mail"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAgents 返回某工作区(space)的全部编排(最近更新在前,join 创建人名字)。
|
||||||
|
// tenant 插件仍会按 ctx 租户过滤(防越权);空间是主作用域。
|
||||||
|
func (p *Postgres) ListAgents(ctx context.Context, spaceID string) ([]AgentInfo, error) {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var rows []Agent
|
var out []AgentInfo
|
||||||
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("updated_at desc").Find(&rows).Error
|
err := p.db.WithContext(ctx).Table("sundynix_agent a").
|
||||||
return rows, err
|
Select("a.name, a.graph, a.owner, u.name as creator_name, u.email as creator_mail, a.updated_at").
|
||||||
|
Joins("LEFT JOIN sundynix_user u ON u.id = a.owner").
|
||||||
|
Where("a.space_id = ? AND a.deleted_at IS NULL", spaceID).
|
||||||
|
Order("a.updated_at desc").Scan(&out).Error
|
||||||
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveAgent 新建/更新一份编排(owner+name 唯一,重名覆盖图与更新时间)。
|
// SaveAgent 新建/更新一份编排(space_id+name 唯一,重名覆盖图与更新时间;owner 首建时记创建人,覆盖不改)。
|
||||||
func (p *Postgres) SaveAgent(ctx context.Context, owner, name, graph string) error {
|
func (p *Postgres) SaveAgent(ctx context.Context, spaceID, creator, name, graph string) error {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
return errStoreDisabled
|
return errStoreDisabled
|
||||||
}
|
}
|
||||||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
|
Columns: []clause.Column{{Name: "space_id"}, {Name: "name"}},
|
||||||
DoUpdates: clause.Assignments(map[string]any{"graph": graph, "updated_at": time.Now()}),
|
DoUpdates: clause.Assignments(map[string]any{"graph": graph, "updated_at": time.Now()}),
|
||||||
}).Create(&Agent{Owner: owner, Name: name, Graph: graph}).Error
|
}).Create(&Agent{SpaceID: spaceID, Owner: creator, Name: name, Graph: graph}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAgent 删除某 owner 的一份编排。
|
// GetAgent 取某工作区内一份编排(含 owner,供删他人鉴权判定)。
|
||||||
func (p *Postgres) DeleteAgent(ctx context.Context, owner, name string) error {
|
func (p *Postgres) GetAgent(ctx context.Context, spaceID, name string) (*Agent, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var a Agent
|
||||||
|
if err := p.db.WithContext(ctx).Where("space_id = ? AND name = ?", spaceID, name).First(&a).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAgent 删除某工作区的一份编排。
|
||||||
|
func (p *Postgres) DeleteAgent(ctx context.Context, spaceID, name string) error {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
return errStoreDisabled
|
return errStoreDisabled
|
||||||
}
|
}
|
||||||
return p.db.WithContext(ctx).Where("owner = ? AND name = ?", owner, name).Delete(&Agent{}).Error
|
return p.db.WithContext(ctx).Where("space_id = ? AND name = ?", spaceID, name).Delete(&Agent{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doc 是入库的一份文件/笔记 —— 文件主表(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
|
// Doc 是入库的一份文件/笔记 —— 文件主表(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type User struct {
|
|||||||
Name string `gorm:"size:64"`
|
Name string `gorm:"size:64"`
|
||||||
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
|
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
|
||||||
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
|
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
|
||||||
|
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 是一次提交的 Agent 编排任务(DSL)。
|
// Task 是一次提交的 Agent 编排任务(DSL)。
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
migrateLegacyIntIDs(db)
|
migrateLegacyIntIDs(db)
|
||||||
migrateDocLinkToID(db)
|
migrateDocLinkToID(db)
|
||||||
|
|
||||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}); err != nil {
|
||||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Space 是一个「工作区/协作空间」——共享的资源容器(增量3,见 SPACE_DESIGN.md)。
|
||||||
|
// 资源(Agent/KB…)挂在 Space 上而非 user 上,让"个人私有 / 项目临时组队 / 整租户共享"三种场景
|
||||||
|
// 出自同一模型。Space 属单一租户(隔离/计费边界仍在租户)。
|
||||||
|
// 与 Tenant/TenantMember 一样是"基础设施表",不标 isTenantScoped(tenant 过滤在查询里显式做)。
|
||||||
|
type Space struct {
|
||||||
|
BaseModel
|
||||||
|
TenantID string `gorm:"size:64;index"`
|
||||||
|
Name string `gorm:"size:128"`
|
||||||
|
Kind string `gorm:"size:16;index"` // personal / project / tenant
|
||||||
|
Creator string `gorm:"size:64;index"` // 建者 user.id
|
||||||
|
Archived bool // 软归档(临时空间完事即归档,从切换器隐藏)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Space) TableName() string { return "sundynix_space" }
|
||||||
|
|
||||||
|
// SpaceMember 是空间成员关系(谁在这个空间 + 空间内角色)。复用租户角色语义(RoleRank 阶梯)。
|
||||||
|
type SpaceMember struct {
|
||||||
|
BaseModel
|
||||||
|
SpaceID string `gorm:"size:64;uniqueIndex:idx_space_user;index"`
|
||||||
|
UserID string `gorm:"size:64;uniqueIndex:idx_space_user;index"`
|
||||||
|
Role string `gorm:"size:16"`
|
||||||
|
Status string `gorm:"size:16"` // active / removed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SpaceMember) TableName() string { return "sundynix_space_member" }
|
||||||
|
|
||||||
|
// 空间类型常量。
|
||||||
|
const (
|
||||||
|
SpaceKindPersonal = "personal"
|
||||||
|
SpaceKindProject = "project"
|
||||||
|
SpaceKindTenant = "tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 基础查询 ----
|
||||||
|
|
||||||
|
// GetSpace 按 id 取空间(不存在返回 nil)。
|
||||||
|
func (p *Postgres) GetSpace(ctx context.Context, id string) (*Space, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
var s Space
|
||||||
|
if err := p.db.WithContext(ctx).First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceMemberRole 返回用户在某空间的角色(无 active 成员关系返回空)。
|
||||||
|
func (p *Postgres) SpaceMemberRole(ctx context.Context, spaceID, userID string) string {
|
||||||
|
if p.db == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var m SpaceMember
|
||||||
|
if err := p.db.WithContext(ctx).
|
||||||
|
Where("space_id = ? AND user_id = ? AND status = ?", spaceID, userID, "active").
|
||||||
|
First(&m).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSpaceMember 幂等加成员(命中唯一 (space_id,user_id) 则改角色并置 active,复活已移除者)。
|
||||||
|
func (p *Postgres) addSpaceMember(ctx context.Context, spaceID, userID, role string) error {
|
||||||
|
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "space_id"}, {Name: "user_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{"role": role, "status": "active", "updated_at": time.Now()}),
|
||||||
|
}).Create(&SpaceMember{SpaceID: spaceID, UserID: userID, Role: role, Status: "active"}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 个人空间(迁移地基)----
|
||||||
|
|
||||||
|
// personalSpaceID 返回用户在某租户的个人空间 id(无则空)。
|
||||||
|
func (p *Postgres) personalSpaceID(ctx context.Context, tenantID, userID string) string {
|
||||||
|
var s Space
|
||||||
|
if err := p.db.WithContext(ctx).Select("id").
|
||||||
|
Where("tenant_id = ? AND creator = ? AND kind = ?", tenantID, userID, SpaceKindPersonal).
|
||||||
|
Order("created_at asc").First(&s).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsurePersonalSpace 幂等保证用户在某租户有个人空间(无则建 + 建者为 owner 成员)。
|
||||||
|
func (p *Postgres) EnsurePersonalSpace(ctx context.Context, tenantID, userID string) (*Space, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
if id := p.personalSpaceID(ctx, tenantID, userID); id != "" {
|
||||||
|
return p.GetSpace(ctx, id)
|
||||||
|
}
|
||||||
|
s := &Space{TenantID: tenantID, Name: "个人空间", Kind: SpaceKindPersonal, Creator: userID}
|
||||||
|
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := p.addSpaceMember(ctx, s.ID, userID, RoleOwner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 活跃空间解析 / 切换 ----
|
||||||
|
|
||||||
|
// ActiveSpaceForUser 解析用户在某租户的活跃空间:user.active_space_id 若有效(存在、属该租户、且用户是
|
||||||
|
// active 成员)→ 用之;否则回退该租户的个人空间(并保证其存在)。SpaceContext 中间件每请求调用。
|
||||||
|
func (p *Postgres) ActiveSpaceForUser(ctx context.Context, tenantID, userID string) (*Space, error) {
|
||||||
|
def, err := p.EnsurePersonalSpace(ctx, tenantID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var u User
|
||||||
|
if e := p.db.WithContext(ctx).Select("active_space_id").First(&u, "id = ?", userID).Error; e == nil {
|
||||||
|
if u.ActiveSpaceID != "" && u.ActiveSpaceID != def.ID {
|
||||||
|
if s, _ := p.GetSpace(ctx, u.ActiveSpaceID); s != nil && s.TenantID == tenantID && !s.Archived &&
|
||||||
|
p.SpaceMemberRole(ctx, s.ID, userID) != "" {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveSpace 切换用户活跃空间(须为其 active 成员)。
|
||||||
|
func (p *Postgres) SetActiveSpace(ctx context.Context, userID, spaceID string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
if p.SpaceMemberRole(ctx, spaceID, userID) == "" {
|
||||||
|
return errors.New("你不是该空间的成员")
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Update("active_space_id", spaceID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 空间列表 / 创建 ----
|
||||||
|
|
||||||
|
// SpaceInfo 是空间列表一行(含当前用户角色 + 成员数)。
|
||||||
|
type SpaceInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
Role string `json:"role"` // 当前用户在此空间的角色
|
||||||
|
Members int64 `json:"members"` // active 成员数
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySpaces 返回某用户在某租户内所属(active 成员)的空间(供切换器;含角色 + 成员数)。
|
||||||
|
func (p *Postgres) MySpaces(ctx context.Context, tenantID, userID string) ([]SpaceInfo, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []SpaceInfo
|
||||||
|
err := p.db.WithContext(ctx).Table("sundynix_space s").
|
||||||
|
Select("s.id, s.tenant_id, s.name, s.kind, s.creator, s.archived, m.role, "+
|
||||||
|
"(SELECT count(*) FROM sundynix_space_member m2 WHERE m2.space_id = s.id AND m2.status = 'active') as members").
|
||||||
|
Joins("JOIN sundynix_space_member m ON m.space_id = s.id").
|
||||||
|
Where("m.user_id = ? AND m.status = 'active' AND s.tenant_id = ? AND s.deleted_at IS NULL", userID, tenantID).
|
||||||
|
Order("s.kind = 'personal' desc, s.created_at asc").Scan(&out).Error
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSpace 在某租户建一个空间(建者自动成为 owner 成员)。kind 空则 project。
|
||||||
|
func (p *Postgres) CreateSpace(ctx context.Context, tenantID, name, kind, creator string) (*Space, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
kind = SpaceKindProject
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil, errors.New("空间名必填")
|
||||||
|
}
|
||||||
|
s := &Space{TenantID: tenantID, Name: strings.TrimSpace(name), Kind: kind, Creator: creator}
|
||||||
|
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := p.addSpaceMember(ctx, s.ID, creator, RoleOwner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveSpace 归档空间(软;个人空间不可归档)。
|
||||||
|
func (p *Postgres) ArchiveSpace(ctx context.Context, spaceID string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
s, err := p.GetSpace(ctx, spaceID)
|
||||||
|
if err != nil || s == nil {
|
||||||
|
return errors.New("空间不存在")
|
||||||
|
}
|
||||||
|
if s.Kind == SpaceKindPersonal {
|
||||||
|
return errors.New("个人空间不可归档")
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Model(&Space{}).Where("id = ?", spaceID).Update("archived", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 空间成员 CRUD(照 TenantMember 套路)----
|
||||||
|
|
||||||
|
// ListSpaceMembers 返回某空间成员(含邮箱/名字/角色),排除已移除。
|
||||||
|
func (p *Postgres) ListSpaceMembers(ctx context.Context, spaceID string) ([]MemberInfo, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []MemberInfo
|
||||||
|
err := p.db.WithContext(ctx).Table("sundynix_space_member m").
|
||||||
|
Select("m.user_id, u.email, u.name, m.role, m.status, m.created_at as joined_at").
|
||||||
|
Joins("JOIN sundynix_user u ON u.id = m.user_id").
|
||||||
|
Where("m.space_id = ? AND m.status <> ?", spaceID, "removed").
|
||||||
|
Order("m.created_at asc").Scan(&out).Error
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSpaceMemberByEmail 按邮箱把已注册用户加入空间(幂等;须对方先注册)。
|
||||||
|
// 调用方应先确保该用户是租户成员(空间成员应是租户成员的子集)。
|
||||||
|
func (p *Postgres) AddSpaceMemberByEmail(ctx context.Context, spaceID, email, role string) (*MemberInfo, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
if !ValidRole(role) {
|
||||||
|
return nil, errors.New("非法角色")
|
||||||
|
}
|
||||||
|
u, err := p.GetUserByEmail(ctx, strings.ToLower(strings.TrimSpace(email)))
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) || u == nil {
|
||||||
|
return nil, errors.New("该邮箱尚未注册,请对方先注册后再加入")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s, err := p.GetSpace(ctx, spaceID)
|
||||||
|
if err != nil || s == nil {
|
||||||
|
return nil, errors.New("空间不存在")
|
||||||
|
}
|
||||||
|
if p.MemberRole(ctx, s.TenantID, u.ID) == "" {
|
||||||
|
return nil, errors.New("对方不是本租户成员,请先加入租户")
|
||||||
|
}
|
||||||
|
if err := p.addSpaceMember(ctx, spaceID, u.ID, role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MemberInfo{UserID: u.ID, Email: u.Email, Name: u.Name, Role: role, Status: "active"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpaceMemberRole 改空间成员角色(不能改/授 owner)。
|
||||||
|
func (p *Postgres) SetSpaceMemberRole(ctx context.Context, spaceID, userID, role string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
if !ValidRole(role) {
|
||||||
|
return errors.New("非法角色")
|
||||||
|
}
|
||||||
|
if p.SpaceMemberRole(ctx, spaceID, userID) == RoleOwner {
|
||||||
|
return errors.New("不能改 owner 的角色")
|
||||||
|
}
|
||||||
|
if role == RoleOwner {
|
||||||
|
return errors.New("不能直接授予 owner")
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Model(&SpaceMember{}).
|
||||||
|
Where("space_id = ? AND user_id = ?", spaceID, userID).Update("role", role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSpaceMember 软移除空间成员(不能移除 owner)。
|
||||||
|
func (p *Postgres) RemoveSpaceMember(ctx context.Context, spaceID, userID string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
if p.SpaceMemberRole(ctx, spaceID, userID) == RoleOwner {
|
||||||
|
return errors.New("不能移除 owner")
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Model(&SpaceMember{}).
|
||||||
|
Where("space_id = ? AND user_id = ?", spaceID, userID).Update("status", "removed").Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 迁移回填(启动时幂等调用)----
|
||||||
|
|
||||||
|
// BackfillPersonalSpaces 给每个 active 租户成员补建个人空间(幂等)。返回补建数。
|
||||||
|
func (p *Postgres) BackfillPersonalSpaces(ctx context.Context) (int, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return 0, errStoreDisabled
|
||||||
|
}
|
||||||
|
var members []TenantMember
|
||||||
|
if err := p.db.WithContext(ctx).Where("status = ?", "active").Find(&members).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, m := range members {
|
||||||
|
if p.personalSpaceID(ctx, m.TenantID, m.UserID) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := p.EnsurePersonalSpace(ctx, m.TenantID, m.UserID); err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateAgentSpaces 把存量 Agent 的 space_id 回填为其 (tenant_id, owner) 个人空间,
|
||||||
|
// 然后 DROP 旧唯一索引 idx_agent_on(owner,name) 并建新唯一索引 idx_agent_sn(space_id,name)。
|
||||||
|
// 顺序关键:先回填再建唯一索引,否则存量 space_id 空会撞车。全程幂等。
|
||||||
|
func (p *Postgres) MigrateAgentSpaces(ctx context.Context) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
// 回填 space_id:按 agent.tenant_id + agent.owner 命中个人空间。
|
||||||
|
sub := "SELECT id FROM sundynix_space s WHERE s.tenant_id = a.tenant_id AND s.creator = a.owner AND s.kind = 'personal' ORDER BY s.created_at ASC LIMIT 1"
|
||||||
|
if err := p.db.WithContext(ctx).Exec(
|
||||||
|
"UPDATE sundynix_agent a SET space_id = ("+sub+") WHERE (a.space_id IS NULL OR a.space_id = '') AND a.owner <> ''",
|
||||||
|
).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 旧唯一索引 DROP(AutoMigrate 不删;留着会过约束"同人跨空间不能重名")。
|
||||||
|
if err := p.db.WithContext(ctx).Exec("DROP INDEX IF EXISTS idx_agent_on").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 新唯一索引(回填后建,安全)。
|
||||||
|
if err := p.db.WithContext(ctx).Exec(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sn ON sundynix_agent(space_id, name)",
|
||||||
|
).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user