用 Next.js + Prisma + Gemini 打造 AI 替代风险追踪平台
凌晨两点,某跨国咨询公司宣布用 AI 再裁员 2000 人。新闻刷屏一天后,你想知道:下一个会轮到你的岗位吗?
这不是科幻问题,而是 2024-2026 年每天都在发生的现实。Role Radar(职危图谱) 正是为了回答这个问题而诞生的------一个追踪不同岗位被 AI 替代风险的开源工具。本文从技术架构到底层实现,把这个项目完整拆给你看。
目录
- 一、整体架构概览
- [二、Next.js App Router:Server/Client 组件的优雅分离](#二、Next.js App Router:Server/Client 组件的优雅分离)
- [三、Prisma Schema:从 ER 图到 SQLite 的完整设计](#三、Prisma Schema:从 ER 图到 SQLite 的完整设计)
- [四、双语 i18n:一个字典文件搞定中英文切换](#四、双语 i18n:一个字典文件搞定中英文切换)
- [五、AI Provider 集成:Gemini + MiniMax 的结构化输出](#五、AI Provider 集成:Gemini + MiniMax 的结构化输出)
- [六、信息源 Ingest 流程:抓取→标准化→归因→存储](#六、信息源 Ingest 流程:抓取→标准化→归因→存储)
- [七、角色发现:用 Brave Search 扩展证据边界](#七、角色发现:用 Brave Search 扩展证据边界)
- 八、风险评分模型:六维度结构化评分体系
- 九、邮箱认证:无密码邮箱验证码的完整实现
- [十、通知系统:周报摘要 + 突变告警双模式](#十、通知系统:周报摘要 + 突变告警双模式)
- [十一、生产部署:Docker Compose + Caddy + systemd](#十一、生产部署:Docker Compose + Caddy + systemd)
- 十二、总结与延伸阅读
一、整体架构概览
Background Jobs
Repository + Prisma
Application Layer
Next.js App Router 前端
pages/roles
Server Components
Client Components
View Models
Use Cases
Prisma ORM
SQLite
Source Ingest
Role Discovery
Risk Refresh
Trend Snapshots
RSS/HTTP Fetcher
Brave Search API
Gemini / MiniMax
核心理念:公开页面只读 SQLite,模型调用发生在后台任务中。这保证了用户访问页面时零延迟,没有 API 限速、没有费用波动。
实战建议
- 所有模型调用(归因、风险评分)都走后台队列,不要在请求路径中同步调用
- SQLite 适合读多写少场景(公开页面),但 watchlist 写入频率高的功能要单独评估是否需要迁移到 PostgreSQL
- 用
Prisma.join()避免 N+1 查询
网站截图
二、Next.js App Router:Server/Client 组件的优雅分离
Role Radar 的前端基于 Next.js 15 App Router,这是自 Next.js 13 以来最重大的架构变化。
目录结构
app/
├── [locale]/ ← 动态路由段,承载 i18n
│ ├── layout.tsx ← 国际化布局(locale 作为 params)
│ ├── page.tsx ← 主页
│ ├── roles/
│ │ └── [slug]/
│ │ └── page.tsx ← 岗位详情页
│ ├── methodology/
│ ├── sources/
│ └── watchlist/
├── api/ ← API Route Handlers(Route TS)
│ ├── auth/
│ │ ├── request-code/
│ │ ├── verify-code/
│ │ ├── session/
│ │ └── logout/
│ └── watchlist/
└── layout.tsx
Server vs Client 组件划分
tsx
// app/[locale]/page.tsx ------ Server Component(默认)
// 直接在服务器端读取数据库,零客户端 JavaScript 开销
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const vm = await getHomepageViewModel(locale); // 直接读 SQLite
return <RoleGrid viewModel={vm} />;
}
tsx
// components/shared/site-header.tsx ------ Client Component
"use client";
// 需要交互:搜索框、国际化切换按钮
export function SiteHeader({ locale }: { locale: string }) {
const [query, setQuery] = useState("");
return <SearchInput value={query} onChange={setQuery} />;
}
关键点 :params 在 Next.js 15 中变成了 Promise<{ locale: string }>,必须 await 后再解构。
tsx
// ✅ Next.js 15 正确写法
const { locale } = await params;
// ❌ 旧版写法(Next.js 14 及以前)
const { locale } = params;
实战建议
- 所有数据获取逻辑放在 Server Components 中,Client Components 只做 UI 交互
- 用
loading.tsx配合Suspense做流式加载骨架屏 - 国际化路由用
[locale]动态段而不是 query string,更利于 SEO
三、Prisma Schema:从 ER 图到 SQLite 的完整设计
Role Radar 使用 Prisma + SQLite,对于一个读多写少的信息聚合应用,这个组合既简单又足够用。
核心数据模型
prisma
model Role {
id String @id @default(cuid())
slug String @unique
nameEn String
nameZh String
replacementRate Int? // 0-100,替代率百分比
riskLevel RiskLevel // LOW | MEDIUM | HIGH | SEVERE
riskReasons Json? // 结构化风险原因数组
riskModelProvider String? // 哪个模型打的分
riskModelName String?
riskCachedAt DateTime? // 缓存时间戳
// 六维度评分
repetitionScore Int // 重复性
ruleClarityScore Int // 规则明确性
transformationScore Int // 数字化转型程度
workflowAutomationScore Int // 工作流自动化程度
interpersonalScore Int // 人际交往要求(保护性因素)
physicalityScore Int // 体力要求(保护性因素)
ambiguityScore Int // 模糊性(保护性因素)
signals Signal[]
riskSnapshots RoleRiskSnapshot[]
subscriptions WatchSubscription[]
}
model RoleRiskSnapshot {
id String @id @default(cuid())
roleId String
snapshotAt DateTime
replacementRate Int?
riskLevel RiskLevel
ratingStatus RatingStatus
wasRecomputed Boolean @default(false)
createdAt DateTime @default(now())
role Role @relation(...)
@@unique([roleId, snapshotAt])
}
model Signal {
id String @id @default(cuid())
roleId String
sourceUrl String
sourceTitle String
sourceType SourceType // BLOG | NEWS | COMPANY_UPDATE | JOB_POSTING
signalType SignalType // ADOPTION | HIRING_SHIFT | TOOLING
strength SignalStrength // LOW | MEDIUM | HIGH
summaryEn String
summaryZh String
role Role @relation(...)
@@unique([roleId, sourceUrl])
}
为什么用 SQLite 而不是 PostgreSQL?
| 维度 | SQLite | PostgreSQL |
|---|---|---|
| 部署复杂度 | 单文件,无需服务 | 需要独立进程 |
| 并发写入 | 写锁粒度粗 | MVCC 并发好 |
| 适用场景 | 读多写少,本地/小规模 | 高并发写入 |
| Prisma 支持 | ✅ 完整 | ✅ 完整 |
| Role Radar 场景 | ✅ 完全够用 | ⚠️ 过度工程 |
实战建议
typescript
// 在 Repository 层封装复杂查询,避免业务层直接写 Prisma
export async function listHomepageReplacementRanking(): Promise<Role[]> {
return prisma.role.findMany({
where: { replacementRate: { not: null } },
orderBy: { replacementRate: "desc" },
take: 10,
});
}
- 迁移用
prisma migrate dev生成版本化迁移文件 - 所有
Json类型字段在 TypeScript 侧要有 Zod schema 校验
四、双语 i18n:一个字典文件搞定中英文切换
Role Radar 是中英双语的,信息量比单语网站大 2 倍。团队选择了一个极简但高效的方案。
字典文件结构
typescript
// lib/i18n/dictionaries/en.ts
export const en = {
brand: "Role Radar",
heroTitle: "Understand which roles are feeling real AI replacement pressure",
searchPlaceholder: "Search a role",
highRiskTitle: "High-risk roles",
replacementRankingMetricLabel: "Replacement rate",
};
// lib/i18n/dictionaries/zh.ts
export const zh = {
brand: "职危图谱",
heroTitle: "了解哪些岗位正承受真实的 AI 替代压力",
searchPlaceholder: "搜索岗位",
highRiskTitle: "高风险岗位",
replacementRankingMetricLabel: "替代率",
};
字典注入
typescript
// lib/i18n/config.ts
import { en } from "@/lib/i18n/dictionaries/en";
import { zh } from "@/lib/i18n/dictionaries/zh";
export const locales = ["en", "zh"] as const;
export type Locale = (typeof locales)[number];
const dictionaries: Record<Locale, Dictionary> = { en, zh };
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale];
}
在 Server Component 中使用
tsx
// app/[locale]/layout.tsx
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// locale 直接决定用哪套字典
return (
<div className="site-shell">
<SiteHeader locale={locale} />
<main className="site-main">{children}</main>
</div>
);
}
View Model 层聚合本地化
typescript
// lib/view-models/homepage.ts
export async function getHomepageViewModel(locale: "en" | "zh") {
const copy = locale === "zh" ? zh : en;
const roles = await listHomepageRoles(760);
// 中英文模式下过滤不同的岗位
const visibleRoles = locale === "zh"
? roles.filter(r => r.nameZh.trim().length > 0)
: roles;
return {
heroTitle: copy.heroTitle,
replacementRanking: visibleRoles.map(role => ({
name: locale === "zh" ? role.nameZh : role.nameEn,
replacementRate: role.replacementRate,
})),
};
}
实战建议
- 字典 key 统一用 camelCase(如
replacementRankingMetricLabel)方便 IDE 自动补全 - 避免在字典里嵌套对象,所有值都是基础类型(
string | number | boolean) - 大段文案(如岗位描述、风险原因)存在数据库的
summaryEn/summaryZh字段中,不放在字典里
五、AI Provider 集成:Gemini + MiniMax 的结构化输出
Role Radar 大量使用结构化输出(Structured Generation)------让模型输出的 JSON 完全符合 TypeScript 类型,而不是靠正则解析。
Provider 抽象
typescript
// lib/ai/provider-types.ts
export interface StructuredGenerationResult<T> {
data: T;
model: string;
cached: boolean;
}
typescript
// lib/ai/gemini-client.ts
import { GoogleGenAI } from "@google/genai";
import { zodToJsonSchema } from "zod-to-json-schema";
export function createGeminiClient(env: NodeJS.ProcessEnv = process.env) {
const settings = getGeminiSettings(env);
return {
ai: new GoogleGenAI({ apiKey: settings.apiKey }),
enabled: settings.enabled,
model: settings.model,
};
}
export async function generateStructred<TSchema extends ZodTypeAny>(
args: ProviderGenerateArgs
): Promise<StructuredGenerationResult<z.infer<TSchema>>> {
const { schema, prompt, systemInstruction, model } = args;
const response = await client.chat.complete({
model,
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
responseMimeType: "application/json",
responseJsonSchema: zodToJsonSchema(schema, { $refStrategy: "none" }),
},
});
return {
data: JSON.parse(response.text ?? "{}"),
model,
cached: false,
};
}
MiniMax 作为备份 Provider
typescript
// lib/ai/minimax-client.ts
export function createMiniMaxClient(env: NodeJS.ProcessEnv = process.env) {
return new MiniMaxAI({ apiKey: env.MAILMIN_API_KEY! });
}
信号归因任务
typescript
// lib/ingest/classify-signal.ts
const signalSchema = z.object({
signalType: z.enum(["ADOPTION", "HIRING_SHIFT", "TOOLING"]),
strength: z.enum(["LOW", "MEDIUM", "HIGH"]),
summaryEn: z.string(),
summaryZh: z.string(),
rationaleEn: z.string(),
rationaleZh: z.string(),
});
const result = await generateStructred({
prompt: `Given this job posting excerpt, classify the AI signal...`,
schema: signalSchema,
model: "gemini-2.5-flash",
});
// result.data.signalType === "TOOLING"
实战建议
- 结构化输出用
zod-to-json-schema+responseJsonSchema参数,不依赖JSON Mode(容易输出不合规的 JSON) - Gemini 的
gemini-2.5-flash是性价比最优选:速度快,结构化输出质量足够 - 所有模型调用结果都要用 Zod 校验后再使用,防止脏数据
六、信息源 Ingest 流程:抓取→标准化→归因→存储
信息源是 Role Radar 的核心资产。系统维护了一个人工筛选的信息源目录,而不是无差别爬虫。
信息源目录
typescript
// lib/ingest/source-catalog.ts
export const sourceCatalog: SourceCatalogEntry[] = [
{
id: "official-openai-news",
label: "OpenAI Newsroom",
class: "official",
transport: "rss",
tier: "structured",
enabledByDefault: true,
locale: "en",
url: "https://openai.com/news/rss.xml",
mappingMode: "observe_only",
maxItems: 14,
sourceType: "COMPANY_UPDATE",
},
{
id: "official-anthropic-news",
label: "Anthropic News",
class: "official",
transport: "html", // 需要 HTML 解析器
tier: "manual_html",
enabledByDefault: false,
url: "https://www.anthropic.com/news",
sourceType: "COMPANY_UPDATE",
},
// ... 更多源
];
完整 Ingest 流程
Source Catalog
Fetch RSS/HTTP
Normalize Item
Map to SourceItem
AI Classification
Signal Extraction
Persist to DB
Risk Refresh
Risk Snapshots
关键步骤实现
typescript
// lib/ingest/fetch-rss.ts
export async function fetchRssSource(url: string, maxItems: number) {
const response = await fetch(url, { timeout: 10000 });
const xml = await response.text();
const parsed = parseXML(xml); // 轻量 XML 解析
return parsed.items.slice(0, maxItems).map(normalizeRssItem);
}
// lib/ingest/normalize-item.ts
export function normalizeRssItem(raw: RssItem): NormalizedItem {
return {
sourceUrl: raw.link,
sourceTitle: raw.title,
publishedAt: new Date(raw.pubDate),
summaryEn: stripHtml(raw.description ?? "").slice(0, 500),
summaryZh: "", // 需要翻译时由 AI 补充
sourceType: inferSourceType(raw),
};
}
// lib/ingest/classify-signal.ts
export async function classifySignal(item: NormalizedItem): Promise<Signal> {
const result = await generateStructred({
prompt: buildClassificationPrompt(item),
schema: signalSchema,
});
return result.data;
}
存储为 SourceItem
prisma
model SourceItem {
id String @id @default(cuid())
sourceCatalogId String
sourceUrl String @unique
sourceType SourceType
title String
summaryEn String
summaryZh String?
publishedAt DateTime
// 模型输出缓存
classificationModelName String?
classificationCachedAt DateTime?
decisions SourceItemRoleDecision[]
inference SourceItemInference?
}
实战建议
- RSS 源用标准 XML 解析库(不要用正则)处理 namespace 和 CDATA
- 每个 SourceItem 存储
classificationInputHash,相同内容不重复调用模型 mappingMode: "observe_only"的源不触发 Role 关联,仅记录证据
七、角色发现:用 Brave Search 扩展证据边界
除了人工维护的信息源目录,Role Radar 还用 Brave Search API 发现新的证据来源。
typescript
// lib/role-discovery/brave-search-client.ts
const BRAVE_WEB_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
export async function searchRoleSignals(
roleName: string,
settings: BraveSearchSettings
): Promise<SearchResultHit[]> {
const params = new URLSearchParams({
q: `${roleName} AI automation job replacement`,
count: "10",
country: settings.country,
ui_language: settings.searchLang,
});
const response = await fetch(`${BRAVE_WEB_SEARCH_ENDPOINT}?${params}`, {
headers: { "X-Subscription-Token": settings.apiKey },
});
const data: BraveSearchResponse = await response.json();
return data.web?.results?.map(normalizeBraveResult) ?? [];
}
证据权重策略
重要设计决策:搜索发现(Search Discovery)的证据权重低于人工维护的主源(Primary Sources)。
typescript
// lib/ingest/signal-policy.ts
export const SIGNAL_WEIGHTS = {
// 人工维护的主源,权重最高
primary: { HIGH: 3, MEDIUM: 2, LOW: 1 },
// 搜索发现的来源,降低权重
searchDiscovery: { HIGH: 1, MEDIUM: 0.5, LOW: 0.25 },
};
这个策略确保了数据质量的可控性------搜索发现的新证据不会轻易推翻已有结论,而是作为补充参考。
候选角色匹配
typescript
// lib/ingest/infer-role-candidates.ts
export async function inferRoleCandidates(
item: SourceItem,
roles: Role[]
): Promise<RoleCandidate[]> {
const result = await generateStructred({
prompt: buildCandidatePrompt(item, roles),
schema: roleCandidateSchema,
});
return result.data.candidates;
}
八、风险评分模型:六维度结构化评分体系
这是 Role Radar 最核心的业务逻辑------一个岗位的 AI 替代风险到底怎么算出来的?
六维度评分
typescript
// lib/domain/risk-types.ts
interface RiskInput {
structural: {
repetitionScore: number; // 重复性(越高越危险)
ruleClarityScore: number; // 规则明确性(越高越危险)
transformationScore: number; // 数字化转型程度(越高越危险)
workflowAutomationScore: number; // 工作流自动化潜力(越高越危险)
interpersonalScore: number; // 人际交往要求(越高越安全)
physicalityScore: number; // 体力要求(越高越安全)
ambiguityScore: number; // 模糊性/判断力(越高越安全)
};
signals: Array<{
strength: "LOW" | "MEDIUM" | "HIGH";
}>;
}
评分算法
typescript
// lib/domain/risk-model.ts
export function computeRoleRisk(input: RiskInput): RiskResult {
// 结构风险 = 加分项 - 保护项
const baseScore =
input.structural.repetitionScore +
input.structural.ruleClarityScore +
input.structural.transformationScore +
input.structural.workflowAutomationScore -
input.structural.interpersonalScore -
input.structural.physicalityScore -
input.structural.ambiguityScore;
// 信号增强:有新闻/证据的岗位获得额外加分
const signalBoost = input.signals.reduce((total, signal) => {
if (signal.strength === "HIGH") return total + 2;
if (signal.strength === "MEDIUM") return total + 1;
return total;
}, 0);
const total = baseScore + signalBoost;
// 评分阈值(基于历史数据校准)
if (total < 2 && input.signals.length === 0) {
return { status: "INSUFFICIENT_SIGNAL", persistedLevel: "LOW", trend: "STABLE" };
}
if (total >= 14) return { status: "RATED", level: "SEVERE", trend: "RISING" };
if (total >= 7) return { status: "RATED", level: "HIGH", trend: signalBoost > 0 ? "RISING" : "STABLE" };
if (total >= 4) return { status: "RATED", level: "MEDIUM", trend: signalBoost > 0 ? "RISING" : "STABLE" };
return { status: "RATED", level: "LOW", trend: "STABLE" };
}
评分结果
typescript
// lib/domain/risk-types.ts
type RiskResult =
| { status: "INSUFFICIENT_SIGNAL"; persistedLevel: RiskLevel; trend: "STABLE" }
| { status: "RATED"; level: RiskLevel; trend: "RISING" | "FALLING" | "STABLE" };
风险等级计算示意
| 总分范围 | 风险等级 | 信号加成后趋势 |
|---|---|---|
| ≥ 14 | SEVERE | RISING |
| 7-13 | HIGH | RISING(有信号)/ STABLE |
| 4-6 | MEDIUM | RISING(有信号)/ STABLE |
| < 4 | LOW | STABLE |
| < 2 且无证据 | INSUFFICIENT_SIGNAL | STABLE |
实战建议
- 六维度评分由领域专家人工标注,不靠模型自动推断(因为模型对职业结构理解有限)
- 评分版本化(
riskPromptVersion),便于 A/B 测试不同评分逻辑 riskInputHash记录每次评分的输入快照,相同输入不重复计算
九、邮箱认证:无密码邮箱验证码的完整实现
Role Radar 不需要密码,只需要邮箱验证码。这是一个安全又无摩擦的认证方案。
验证码流程
SMTP DB API 用户 SMTP DB API 用户 POST /api/auth/request-code { email } 创建 EmailVerificationChallenge(codeHash, expiresAt) 发送验证码邮件(6位数字) POST /api/auth/verify-code { email, code } 校验 codeHash(bcrypt),验证未过期 返回 challenge 创建 AuthSession(sessionToken) Set-Cookie: role_radar_session
核心实现
typescript
// lib/auth/email-auth.ts
const VERIFICATION_CODE_LENGTH = 6;
const VERIFICATION_CODE_TTL_MS = 10 * 60 * 1000; // 10 分钟
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天
export async function requestVerificationCode(
email: string,
repo: AuthRepository
): Promise<void> {
// 1. 防滥用:检查请求频率
const challenge = await repo.findChallenge(email);
if (challenge && isWithinCooldown(challenge)) {
throw new AuthThrottleError("请稍后再试");
}
// 2. 生成 6 位验证码,bcrypt hash 存储
const code = crypto.randomInt(100000, 999999).toString();
const codeHash = await bcrypt.hash(code, 10);
await repo.upsertChallenge({
email,
codeHash,
expiresAt: new Date(Date.now() + VERIFICATION_CODE_TTL_MS),
});
// 3. 发送邮件(实际通过 SMTP)
await sendEmail(email, `您的验证码是 ${code},10分钟内有效`);
}
验证与 Session
typescript
export async function verifyCode(
email: string,
code: string,
repo: AuthRepository
): Promise<string> {
const challenge = await repo.findChallenge(email);
if (!challenge || challenge.expiresAt < new Date()) {
throw new AuthError("验证码已过期");
}
if (challenge.failedAttempts >= 5) {
throw new AuthThrottleError("失败次数过多,15分钟后再试");
}
const valid = await bcrypt.compare(code, challenge.codeHash);
if (!valid) {
await repo.recordFailedAttempt(email);
throw new AuthError("验证码错误");
}
await repo.consumeChallenge(email); // 验证成功后作废
const sessionToken = crypto.randomBytes(32).toString("hex");
await repo.createSession({
email,
sessionTokenHash: sha256(sessionToken), // Session token 只存 hash
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
});
return sessionToken; // 明文 token 通过 Set-Cookie 返回客户端
}
防滥用机制
| 保护层 | 策略 |
|---|---|
| 请求频率 | 每 60 秒只能请求一次 |
| 请求窗口 | 1 小时内最多请求 5 次 |
| 失败次数 | 连续失败 5 次后锁定 15 分钟 |
| 一次性 | 验证成功后 challenge 立即作废 |
| 过期 | 10 分钟后自动过期 |
实战建议
- 验证码存储 bcrypt hash 而不是明文,即使 DB 被拖库也安全
- Session token 用
crypto.randomBytes(32)生成,存 SHA-256 hash - Cookie 加
HttpOnly + Secure + SameSite=Strict
十、通知系统:周报摘要 + 突变告警双模式
登录用户可以为特定岗位订阅通知,支持两种模式:
通知策略
typescript
// lib/notifications/policy.ts
export const WEEKLY_DIGEST_INTERVAL_DAYS = 7;
export const SIGNIFICANT_CHANGE_THRESHOLD = 8; // 替代率变化超过 8% 才触发
// 判断是否应该发送周报
export function isWeeklyDigestDue({
lastDigestSentAt,
oldestSubscriptionCreatedAt,
now,
}: {
lastDigestSentAt: Date | null;
oldestSubscriptionCreatedAt: Date | null;
now: Date;
}) {
const basis = lastDigestSentAt ?? oldestSubscriptionCreatedAt;
if (!basis) return false;
const elapsed = now.getTime() - basis.getTime();
return elapsed >= WEEKLY_DIGEST_INTERVAL_DAYS * 24 * 60 * 60 * 1000;
}
// 判断是否应该发送突变告警
export function hasSignificantReplacementRateChange({
baselineReplacementRate,
currentReplacementRate,
lastRatedAt,
lastAlertSentAt,
}: {
baselineReplacementRate: number | null;
currentReplacementRate: number | null;
lastRatedAt: Date | null;
lastAlertSentAt: Date | null;
}) {
if (!baselineReplacementRate || !currentReplacementRate || !lastRatedAt) {
return false;
}
const delta = Math.abs(currentReplacementRate - baselineReplacementRate);
const freshnessBoundary = lastAlertSentAt ?? /* 或使用订阅创建时间 */;
return delta >= SIGNIFICANT_CHANGE_THRESHOLD &&
lastRatedAt.getTime() > freshnessBoundary.getTime();
}
通知存储
prisma
model NotificationDispatch {
id String @id @default(cuid())
email String
roleId String?
kind NotificationKind // WEEKLY_DIGEST | SIGNIFICANT_CHANGE
status NotificationStatus // PENDING | SENT | SKIPPED | FAILED
subjectEn String
subjectZh String
payload Json // 邮件内容模板数据
previewPath String? // 邮件预览 URL
errorMessage String?
sentAt DateTime?
}
邮件渲染
邮件内容通过模板渲染,支持中英文:
typescript
// lib/notifications/render.ts
export function renderWeeklyDigest(payload: DigestPayload): EmailContent {
const locale = payload.emailLocale ?? "en";
const copy = locale === "zh" ? digestCopyZh : digestCopyEn;
return {
subject: copy.subjectTemplate(payload.roleName, payload.weekNumber),
html: renderTemplate("weekly-digest", {
...copy,
...payload,
}),
};
}
`
欢迎关注收藏我,获取更多硬核技术干货 🔥
如果你觉得这篇文章有帮助,欢迎分享给正在担心 AI 替代问题的同事朋友们。
项目的完整源码:pony-maggie/role_radar

