Compare commits

...

1 Commits

Author SHA1 Message Date
hongjr03 38d9a9c6cb fix: bootstrap fresh alpha silo 2026-07-11 01:40:20 +08:00
5 changed files with 174 additions and 3 deletions
+81
View File
@@ -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 URLHub 使用飞书长连接。
## 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。
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.3", "version": "0.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.3", "version": "0.0.4",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.3", "version": "0.0.4",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+58
View File
@@ -49,6 +49,7 @@ export async function bootstrapAlphaSilo(
} = {}, } = {},
): Promise<AlphaSiloBootstrapResult> { ): Promise<AlphaSiloBootstrapResult> {
const normalized = validateInput(input); const normalized = validateInput(input);
await removePristineLegacyMigrationOrganization(prisma);
const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId); const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId);
let initialized = false; let initialized = false;
let ownerUserId: string; 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<void> {
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( async function ensureBootstrapTeams(
prisma: PrismaClient, prisma: PrismaClient,
organizationId: string, organizationId: string,
@@ -18,6 +18,21 @@ const input = {
describe("Alpha Silo bootstrap", () => { describe("Alpha Silo bootstrap", () => {
beforeEach(async () => { beforeEach(async () => {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`); 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 () => { afterAll(async () => {
@@ -54,4 +69,21 @@ describe("Alpha Silo bootstrap", () => {
}); });
await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch"); 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();
});
}); });