From 38d9a9c6cb65510e8e61ae5608a10bda3ffef81b Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 11 Jul 2026 01:40:20 +0800 Subject: [PATCH] fix: bootstrap fresh alpha silo --- docs/para-26071100-feishu-setup.md | 81 +++++++++++++++++++++ hub/package-lock.json | 4 +- hub/package.json | 2 +- hub/src/deployment/bootstrap-silo.ts | 58 +++++++++++++++ hub/test/integration/silo-bootstrap.test.ts | 32 ++++++++ 5 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 docs/para-26071100-feishu-setup.md diff --git a/docs/para-26071100-feishu-setup.md b/docs/para-26071100-feishu-setup.md new file mode 100644 index 0000000..4d5fefc --- /dev/null +++ b/docs/para-26071100-feishu-setup.md @@ -0,0 +1,81 @@ +# para-26071100 飞书应用配置清单 + +本文供 `para-26071100` 的飞书企业管理员操作。不要把 App Secret 粘贴到群聊、工单或本文档中;请通过约定的安全渠道交给平台部署人员。 + +## 1. 创建企业自建应用 + +1. 打开飞书开放平台开发者后台。 +2. 在目标企业下创建“企业自建应用”。 +3. 应用名称可填写 `Educraft para-26071100`。 +4. 在“凭证与基础信息”记录: + - App ID(通常以 `cli_` 开头) + - App Secret +5. 添加并启用“机器人”能力。 + +Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/App Secret 调用飞书 Bot Info API 获取,并在 bootstrap 时校验它确实属于这一个应用。 + +## 2. 开通权限 + +在“权限管理”中搜索并申请下列能力。飞书控制台的中文名称可能随版本调整;如控制台同时显示 scope,可优先核对括号中的 scope。 + +- 接收群聊中 @ 机器人的消息(`im:message.group_at_msg:readonly`) +- 以应用身份发送消息(`im:message:send_as_bot`) +- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`) +- 获取与上传图片或文件资源(`im:resource`) +- 添加、删除消息表情回复(`im:message.reactions:write_only`) +- 获取用户基本信息(`contact:user.base:readonly`) + +如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限。 + +## 3. 配置事件与卡片回调 + +1. 进入“事件与回调”。 +2. 订阅方式选择“使用长连接接收事件”。 +3. 添加事件 `im.message.receive_v1`(接收消息)。 +4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。 +5. 不需要填写公网 Event Callback URL;Hub 使用飞书长连接。 + +## 4. 配置 OAuth 回调 + +域名 DNS 和 TLS 生效后,在安全设置/重定向 URL 中添加: + +```text +https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback +``` + +该回调用于 OWNER 登录受控的 Host Console。飞书群机器人长连接本身不依赖这个 URL。 + +## 5. 发布并安装应用 + +1. 创建应用版本并提交企业管理员审核。 +2. 将应用可用范围至少包含试点 OWNER 和试点群成员。 +3. 发布版本。 +4. 将机器人加入准备试用的飞书群。 + +## 6. 获取首位 OWNER 身份 + +平台 bootstrap 需要 OWNER 的飞书 Open ID 和显示名称。可通过飞书 API 调试台的用户信息接口查询;Open ID 通常以 `ou_` 开头。Union ID 可选,不影响首次部署。 + +请把以下结果通过安全渠道交给平台部署人员: + +```text +Organization: para-26071100 +App ID: cli_... +App Secret: (安全渠道发送) +OWNER Open ID: ou_... +OWNER 显示名称: +OWNER Union ID: (可选) +试点群名称: (可选,便于验收) +``` + +## 7. 验收动作 + +平台通知部署完成后: + +1. OWNER 打开 Host Console,完成飞书 OAuth 登录。 +2. 在试点群中 @机器人发送一条纯文本消息。 +3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。 +4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。 +5. 再测试一个小文件附件,以及运行中断按钮。 + +任何一步失败时,请保留发生时间、群名、消息截图和飞书 request/log ID;不要在截图中包含 App Secret 或 Provider token。 diff --git a/hub/package-lock.json b/hub/package-lock.json index 395c7f9..e400dc7 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index 3817dee..f379ac6 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.3", + "version": "0.0.4", "private": true, "type": "module", "engines": { diff --git a/hub/src/deployment/bootstrap-silo.ts b/hub/src/deployment/bootstrap-silo.ts index 8ff6486..3af6397 100644 --- a/hub/src/deployment/bootstrap-silo.ts +++ b/hub/src/deployment/bootstrap-silo.ts @@ -49,6 +49,7 @@ export async function bootstrapAlphaSilo( } = {}, ): Promise { const normalized = validateInput(input); + await removePristineLegacyMigrationOrganization(prisma); const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId); let initialized = false; let ownerUserId: string; @@ -113,6 +114,63 @@ export async function bootstrapAlphaSilo( }; } +const LEGACY_MIGRATION_ORGANIZATION = { + id: "org_default", + slug: "legacy-default", + name: "Legacy Default Organization", + inboxId: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa", +} as const; + +/** + * A fresh database still receives the compatibility Organization inserted by + * the tenant-root migration, plus its later default settings and Inbox. An + * Alpha Silo bootstrap may replace only that exact, otherwise-unused scaffold. + * Any tenant data or shape drift remains a hard manual-repair error. + */ +async function removePristineLegacyMigrationOrganization(prisma: PrismaClient): Promise { + await prisma.$transaction(async (tx) => { + const organization = await tx.organization.findUnique({ + where: { id: LEGACY_MIGRATION_ORGANIZATION.id }, + include: { + memberships: { take: 1, select: { id: true } }, + projects: { take: 1, select: { id: true } }, + teams: { take: 1, select: { id: true } }, + externalDirectoryConnections: { take: 1, select: { id: true } }, + providerConnections: { take: 1, select: { id: true } }, + feishuApplicationConnection: { select: { id: true } }, + auditEntries: { take: 1, select: { id: true } }, + projectSettings: true, + folders: { + take: 2, + select: { id: true, parentId: true, name: true, sortKey: true, archivedAt: true }, + }, + }, + }); + if (organization === null) return; + const pristine = + organization.slug === LEGACY_MIGRATION_ORGANIZATION.slug && + organization.name === LEGACY_MIGRATION_ORGANIZATION.name && + organization.status === "ACTIVE" && + organization.memberships.length === 0 && + organization.projects.length === 0 && + organization.teams.length === 0 && + organization.externalDirectoryConnections.length === 0 && + organization.providerConnections.length === 0 && + organization.feishuApplicationConnection === null && + organization.auditEntries.length === 0 && + organization.projectSettings?.membersCanCreateProjects === true && + organization.folders.length === 1 && + organization.folders[0]?.id === LEGACY_MIGRATION_ORGANIZATION.inboxId && + organization.folders[0].parentId === null && + organization.folders[0].name === "Inbox" && + organization.folders[0].sortKey === "000000" && + organization.folders[0].archivedAt === null; + if (pristine) { + await tx.organization.delete({ where: { id: organization.id } }); + } + }); +} + async function ensureBootstrapTeams( prisma: PrismaClient, organizationId: string, diff --git a/hub/test/integration/silo-bootstrap.test.ts b/hub/test/integration/silo-bootstrap.test.ts index e8f91d2..abe1769 100644 --- a/hub/test/integration/silo-bootstrap.test.ts +++ b/hub/test/integration/silo-bootstrap.test.ts @@ -18,6 +18,21 @@ const input = { describe("Alpha Silo bootstrap", () => { beforeEach(async () => { await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`); + await prisma.organization.create({ + data: { + id: "org_default", + slug: "legacy-default", + name: "Legacy Default Organization", + projectSettings: { create: { membersCanCreateProjects: true } }, + folders: { + create: { + id: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa", + name: "Inbox", + sortKey: "000000", + }, + }, + }, + }); }); afterAll(async () => { @@ -54,4 +69,21 @@ describe("Alpha Silo bootstrap", () => { }); await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch"); }); + + it("does not delete a legacy migration Organization that contains tenant data", async () => { + await prisma.project.create({ + data: { + organizationId: "org_default", + folderId: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa", + name: "Legacy Project", + workspaceDir: "legacy-project", + }, + }); + + await expect(bootstrapAlphaSilo(prisma, testSecretEnvelope, input, { + feishu: async () => {}, + provider: async () => {}, + })).rejects.toThrow("bootstrap database is not the configured single-Organization Silo"); + await expect(prisma.organization.findUnique({ where: { id: "org_default" } })).resolves.not.toBeNull(); + }); });