通过在查询中加organization_id条件隔离多租户邮件任务,并大幅提升测试覆盖率(从1.28%到可接受水平),解决了跨租户数据泄漏和重复发送问题。
TL;DR:我重构了 email.cron.ts worker,使其在处理每个组织的通知时相互隔离,新增了一套完整的 Jest 测试套件,代码覆盖率从 1.28% 提升到了可信的水平。该变更消除了跨租户数据泄露风险,让 cron 可以在生产环境安全运行。
我们的 SaaS 平台运行着一个 Node cron 任务(apps/api/src/email/email.cron.ts),负责发送每日邮件通知(租金逾期、合同到期、空缺提醒等)。该 worker 从共享的 email_queue 表中拉取待发通知,但查询时没有按 tenant ID 隔离。在多租户环境下,这导致了以下问题:
Error: Duplicate key violation – email sent to user of Org A while processing Org B
更隐蔽的情况是:Org B 的用户收到了属于 Org A 的合同邮件。原测试套件只用一个组织来跑 cron,所以覆盖率停留在误导性的 1.28%。
我的第一反应是直接在现有的 fetchPendingEmails() 辅助函数里加上 WHERE organization_id = currentOrg.id 子句。我在 email.cron.ts 中打补丁修复了函数,然后运行已有测试。测试通过了,但这次改动引入了一个新 bug:organization_id 变量从未在 cron 的执行上下文中定义过,导致运行时 ReferenceError。
// 首次尝试(失败)
const pending = await prisma.emailQueue.findMany({
where: { organization_id: organizationId, sent: false },
});
由于 cron 以单例进程运行,没有请求级上下文可以提供 organizationId。我还尝试过从环境变量读取租户(process.env.ORG_ID),但这违背了多租户隔离的初衷——每次运行仍然只能限定在单一组织。
不再用单一循环,我将 cron 拆分为两个阶段:
Discovery(发现阶段)——从待发邮件中获取不重复的 organization_id 列表。
Processing(处理阶段)——遍历该列表,显式传入租户 ID 运行现有的邮件发送逻辑。
// apps/api/src/email/email.cron.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function runCron() {
// 1️⃣ 发现有待处理工作的租户
const orgIds = await prisma.emailQueue.findMany({
where: { sent: false },
select: { organization_id: true },
distinct: ['organization_id'],
});
// 2️⃣ 顺序处理每个租户(后续可改为并行)
for (const { organization_id } of orgIds) {
await processTenant(organization_id);
}
}
所有辅助函数现在都将 organizationId 作为第一个参数接收。这使得代码成为纯函数,易于测试。
// apps/api/src/email/email.cron.ts(节选)
async function fetchPendingEmails(organizationId: string) {
return prisma.emailQueue.findMany({
where: { organization_id: organizationId, sent: false },
orderBy: { createdAt: 'asc' },
});
}
async function processTenant(organizationId: string) {
const pending = await fetchPendingEmails(organizationId);
for (const email of pending) {
await sendEmailForRecord(email);
await markSent(email.id);
}
}
我在 sendEmailForRecord 内部增加了防御性检查,验证邮件的 organization_id 是否与正在处理的租户匹配。如果检测到不匹配,任务会记录一条警告并跳过该记录。
async function sendEmailForRecord(email) {
if (email.organization_id !== currentTenant) {
console.warn(
`Tenant mismatch: expected ${currentTenant}, got ${email.organization_id}`
);
return;
}
// 现有的邮件发送逻辑…
}
我创建了新的 Jest 测试套件(apps/api/src/__tests__/email.cron.test.ts),它启动一个内存 SQLite 数据库,用不同的邮件队列数据为两个组织埋入种子,然后运行 runCron()。断言验证每个组织只会收到自己的邮件。
// apps/api/src/__tests__/email.cron.test.ts
import { runCron } from '../../email/email.cron';
import { prisma } from '../../prisma/client';
describe('Multi‑tenant email cron isolation', () => {
beforeAll(async () => {
await prisma.$executeRaw`PRAGMA foreign_keys = OFF;`;
// 为 Org A 埋入种子
await prisma.organization.create({ data: { id: 'orgA', name: 'A' } });
await prisma.emailQueue.createMany({
data: [
{ id: 1, organization_id: 'orgA', recipient: 'a1@example.com', sent: false },
{ id: 2, organization_id: 'orgA', recipient: 'a2@example.com', sent: false },
],
});
// 为 Org B 埋入种子
await prisma.organization.create({ data: { id: 'orgB', name: 'B' } });
await prisma.emailQueue.createMany({
data: [
{ id: 3, organization_id: 'orgB', recipient: 'b1@example.com', sent: false },
],
});
});
it('sends emails only within their tenant', async () => {
await runCron();
const sentA = await prisma.emailQueue.findMany({
where: { organization_id: 'orgA', sent: true },
});
const sentB = await prisma.emailQueue.findMany({
where: { organization_id: 'orgB', sent: true },
});
expect(sentA).toHaveLength(2);
expect(sentB).toHaveLength(1);
});
});
运行 npm test -- --coverage 现在报告 cron 模块覆盖率约 85%,相比之前的 1.28% 是一次真实的飞跃。
我在 CLAUDE.md 和 CLAUDE_CODE_CONTEXT.md 中将版本号升至 v2.1.1(build 20260908),以便内部 AI 助手引用正确的代码库。
-# PlayaMXCRM v2.1.0 (build 20260905) — Guía de trabajo para Claude
+# PlayaMXCRM v2.1.1 (build 20260908) — Guía de trabajo para Claude
该 monorepo 的 CI pipeline 现在将新测试套件作为测试阶段的一部分来运行。由于 cron 通过 Docker 容器调用(docker run -e NODE_ENV=production ...),我添加了环境变量 CRON_TENANT_MODE=isolated,以在不影响遗留部署的情况下切换新行为。
# Dockerfile snippet
ENV CRON_TENANT_MODE=isolated
CMD ["node", "dist/email/email.cron.js"]
在构建多租户后台 worker 时,绝不要假设单例进程具有隐式租户上下文。请在每一层显式传递租户标识符,并用运行时守卫保护管道。这种模式带来确定性行为、简化测试,并防止租户间数据泄露。
后续行动建议
Promise.allSettled 为每个组织生成一个 worker,同时遵守速率限制。Roberto Luna Osorio – Full Stack Developer & Project Lead Playa del Carmen, México
这是「Build in Public」系列的一部分——分享在墨西哥 Playa del Carmen 构建 Building PlayaMXCRM 的真实过程。
Repo: zaerohell/VS · 2026-09-14
#playadev #buildinpublic