用 Next.js + Prisma + Gemini 打造 AI 替代风险追踪平台

用 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 关联,仅记录证据

除了人工维护的信息源目录,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

相关推荐
段一凡-华北理工大学1 小时前
【高炉炼铁领域炉温监测、预警、调控智能体设计与应用】~系列文章10:实时预警机制:跑在问题前面!
网络·人工智能·python·知识图谱·高炉炼铁·工业智能体
β添砖java1 小时前
深度学习(20)深度卷积神经网络AlexNet
人工智能·深度学习·cnn
weixin_408099672 小时前
身份证OCR识别如何做到99.9%准确率?揭秘石榴智能六大核心技术(矫正/完整度/翻拍检测/头像提取)
图像处理·人工智能·ocr·api接口·身份证识别·石榴智能
林小卫很行2 小时前
Obsidian 入门39:怎么创建自己的 Skill?我把五步拆给你看
人工智能
Baihai_IDP2 小时前
为什么 AI Agent 重新爱上了文件系统(Filesystems)
人工智能·llm·agent
灵机一物2 小时前
灵机一物AI原生电商小程序、PC端(已上线)-Token成产研新KPI:2026年,AI提效、数字员工与研发效能变革
人工智能
薛定猫AI2 小时前
【深度解析】Pi 极简终端 Coding Agent:为什么 4 个工具反而更适合 AI 编程?
人工智能
冷小鱼2 小时前
AI+时代的算力基石:CPU、GPU、NPU的技术革命与产业博弈
人工智能