OpenStock API 与架构解析:Inngest 事件驱动后台任务与多 Provider AI 降级机制
【免费下载链接】OpenStockOpenStock is an open-source alternative to expensive market platforms. Track real-time prices, set personalized alerts, and explore detailed company insights — built openly, for everyone, forever free.项目地址: https://gitcode.com/GitHub_Trending/ope/OpenStock
OpenStock 的 API 层采用 Inngest 构建事件驱动架构,通过多 Provider AI 策略(Gemini 主用、Siray.ai 等备用)保障生成式功能的可用性。本文基于仓库文档 API_DOCS.md 展开,并深入源码逐层剖析:如何搭建事件/Cron 触发的后台任务流水线(个性化欢迎邮件、每周市场摘要、价格预警轮询、沉睡用户召回),以及 AI 路由、Kit 邮件广播、Finnhub 行情与 MongoDB 连接细节的具体实现,读完可完整理解并本地复现这套「行情 + AI + 自动化」的集成方案。
架构总览:事件驱动 + 智能模型路由
OpenStock 的后台能力由 Inngest 支撑,核心理念是不依赖单一故障点:AI 基础设施会在主 Provider 出错或限流时自动路由到备用 Provider。官方文档给出的调用链路如下:
这条链路在源码中对应三个环节:
- 触发层:用户行为(如注册)通过
inngest.send()发出事件,或由 Inngest Cron 定时触发; - 函数层:所有后台函数集中定义在 lib/inngest/functions.ts,并通过 app/api/inngest/route.ts 中的
serve()暴露给 Inngest 平台; - 执行层:函数内部调用 lib/ai-provider.ts 的
callAIProviderWithFallback()完成 AI 推理,最终经 Nodemailer 或 Kit 投递邮件。
Inngest 客户端在 lib/inngest/client.ts 中初始化,id为openStock,并注入了INNGEST_SIGNING_KEY(Vercel 部署时必需)与 Gemini API Key:
import {Inngest} from "inngest" export const inngest = new Inngest({ id: "openStock", ai: {gemini: {apiKey: process.env.GEMINI_API_KEY}}, // Add signing key for Vercel deployment signingKey: process.env.INNGEST_SIGNING_KEY, })路由层则将四个函数一次性注册进 Inngest 服务:
export const { GET, POST, PUT } = serve({ client: inngest, functions: [sendSignUpEmail, sendWeeklyNewsSummary, checkStockAlerts, checkInactiveUsers], })AI Provider 抽象层:配置、路由与降级
API_DOCS 声明的「Primary: Google Gemini / Fallback: Siray.ai」策略,其完整实现位于 lib/ai-provider.ts。该模块通过AI_PROVIDER环境变量支持三种后端,均返回纯文本字符串:
| Provider | 环境变量 | 默认 Base URL | 默认模型 |
|---|---|---|---|
gemini(默认) | GEMINI_API_KEY | https://generativelanguage.googleapis.com/v1beta/models | gemini-2.5-flash-lite(可经GEMINI_MODEL覆盖) |
minimax | MINIMAX_API_KEY | https://api.minimax.io/v1(可覆盖) | MiniMax-M2.7(可覆盖) |
siray | SIRAY_API_KEY | https://api.siray.ai/v1 | siray-1.0-ultra(固定) |
配置解析集中在getProviderConfig()(lib/ai-provider.ts#L24-L60),优先级为「显式传参 >AI_PROVIDER环境变量 > 默认值gemini」。
降级策略由getFallbackProviderName()(lib/ai-provider.ts#L66-L76)决定:从当前源码结构看,若主 Provider 是 Gemini,则优先选择 MiniMax(当其 Key 可用),其次 Siray;若主 Provider 非 Gemini,则回退到 Gemini。这与 API_DOCS 中「Gemini 失败即切 Siray.ai」的早期描述相比有所演进,以源码为准。
降级调用的入口是callAIProviderWithFallback()(lib/ai-provider.ts#L168-L184):先尝试主 Provider,抛出异常后打印告警并切换备用 Provider 重试一次。底层两种调用协议分别是:
callGemini():POST 到{baseUrl}/{model}:generateContent?key=...,从candidates[0].content.parts[0].text提取文本;callOpenAICompatible():MiniMax 与 Siray 共用 OpenAI 兼容协议,POST{baseUrl}/chat/completions,Bearer 鉴权,temperature: 0.7,从choices[0].message.content提取文本。
任一 Provider 缺少 Key 或返回空响应都会抛错,从而触发上层的降级或兜底逻辑——这正是「Zero Downtime Guarantee」的实现基础:即使两级 Provider 全部失败,业务函数仍会用硬编码的兜底文案继续执行(见下文各函数分析)。相关行为亦有测试覆盖,可参考tests/ai-provider.test.ts 与tests/ai-provider.integration.test.ts。
Inngest 后台任务清单
四个后台函数的 ID、触发方式与用途如下(引自 API_DOCS.md,触发器细节已对照 lib/inngest/functions.ts 源码核实):
| ID | 类型 | Schedule/Trigger | Purpose |
|---|---|---|---|
sign-up-email | Event | app/user.created | 个性化 Onboarding。基于用户注册问卷结果生成定制欢迎信息 |
weekly-news-summary | Cron | 0 9 * * 1(每周一 9:00) | 市场情报。汇总最新财经新闻并通过 Kit 广播给全部用户 |
check-stock-alerts | Cron | */5 * * * *(每 5 分钟) | 实时监控。将用户价格目标与实时行情比对 |
check-inactive-users | Cron | 0 10 * * *(每天 10:00) | 用户召回。识别沉睡用户(>30 天)并发送回归提醒 |
sign-up-email:注册事件的个性化欢迎邮件
触发链路始于认证动作:lib/actions/auth.actions.ts#L14-L17 中,注册成功后调用inngest.send()发出app/user.created事件,携带country、investmentGoals、riskTolerance、preferredIndustry四份问卷数据;事件发送失败不会导致注册失败(仅记录日志),实现了前端流程与后台异步解耦。
函数本体(lib/inngest/functions.ts#L10-L53)分两步执行:
generate-welcome-intro:将问卷数据填入PERSONALIZED_WELCOME_EMAIL_PROMPT模板(定义于 lib/inngest/prompts.ts),经callAIProviderWithFallback()生成两句话、35–50 词的个性化 HTML 段落;若所有 Provider 均失败,回退为固定文案"Thanks for joining Openstock...",保证邮件必然发出;send-welcome-email:调用 lib/nodemailer/index.ts 的sendWelcomeEmail(),把 AI 文本填入{{intro}}占位符后经 Gmail SMTP 发送。
该模块的容错设计值得注意:Nodemailer 在未配置NODEMAILER_EMAIL/NODEMAILER_PASSWORD时不创建 transporter,发送函数直接返回{ status: 'skipped' }而非抛错,本地开发无需邮箱凭据也不会中断工作流。
weekly-news-summary:Cron 驱动的市场新闻广播
weekly-news-summary(lib/inngest/functions.ts#L56-L203)同时具备 Cron(0 9 * * 1)与手动事件(app/send.weekly.news)两类触发器。其流程为:
- 拉取新闻:
fetch-general-news步骤调用 Finnhub 的getNews()(lib/actions/finnhub.actions.ts)并截取前 10 条;无新闻则提前返回; - 生成摘要:将新闻 JSON 注入
NEWS_SUMMARY_EMAIL_PROMPT,并将提示词中的 "daily" 批量替换为 "weekly",再经 AI 降级链生成结构化 HTML(含h3分节、要点列表、Bottom Line 解读等约定格式,见 lib/inngest/prompts.ts#L50-L99);全部 Provider 失败时回退为"Market is moving. Log in to see more."; - Kit 广播:
send-kit-broadcast步骤先调用kit.listSubscribers()拉取订阅者并在 Inngest 日志中输出收件人清单(便于排障),随后以内联样式 HTML(黑色背景 +#20c997青色强调色)封装整封邮件,最后调用kit.sendBroadcast(subject, content)立即投递。
check-stock-alerts:5 分钟轮询的价格预警
该函数(lib/inngest/functions.ts#L205-L294)每 5 分钟执行一次,完整链路为:
- 取有效预警:从 MongoDB 查询
Alert集合(模型见 database/models/alert.model.ts),条件为active: true && triggered: false && expiresAt > now; - 批量拉价:按 symbol 去重后逐个调用 Finnhub 的
getQuote(),构造priceMap,单只标的失败不影响其余标的; - 条件判定:
ABOVE条件在currentPrice >= targetPrice时触发,BELOW在currentPrice <= targetPrice时触发; - 落库标记:触发的预警被
Alert.findByIdAndUpdate()更新为triggered: true, active: false,防止重复触发。
从源码结构看,当前触发后的通知处理以日志记录为主(ALERT FIRED),注释表明后续可接入 Kit 定向推送,即检测逻辑已完备、通知渠道仍可演进。
check-inactive-users:沉睡用户召回
check-inactive-users(lib/inngest/functions.ts#L296-L466)每天 10:00 运行,直接操作user集合。查询条件包含双重防打扰设计:
- 活跃判定:
lastActiveAt早于 30 天前,或无lastActiveAt且createdAt早于 30 天前(该字段由登录时更新,见 lib/actions/auth.actions.ts#L44-L47); - 防重复:
lastReengagementSentAt不存在或同样早于 30 天前才纳入名单,且每轮最多处理 50 人; - 发送后回写
lastReengagementSentAt,形成闭环避免重复触达。
API 集成细节
Stock Data:Finnub / Finnhub
- Base URL:
https://finnhub.io/api/v1(对应FINNHUB_BASE_URL) - 核心能力:实时报价、技术指标、市场新闻,支撑股票搜索、公司资料与新闻流
- 鉴权:
NEXT_PUBLIC_FINNHUB_API_KEY
在架构中的落点:check-stock-alerts依赖getQuote()取最新价(返回体字段c即现价),weekly-news-summary依赖getNews()获取新闻,实现均位于 lib/actions/finnhub.actions.ts。注意NEXT_PUBLIC_前缀意味着该 Key 会暴露给浏览器,且免费套餐可能存在报价延迟与速率限制(详见 MARKET_SUPPORT.md)。
Email & Marketing:Kit(ConvertKit)
- 角色:高容量用户广播与标签管理
- 鉴权:
KIT_API_KEY+KIT_API_SECRET,缺失时任一 Kit 调用都会抛错(见 lib/kit.ts#L8-L17)
API_DOCS 列出的关键端点为POST /v3/tags/{tag_id}/subscribe(用户迁移)与POST /v3/broadcasts(新闻简报)。对照当前源码 lib/kit.ts 的实际实现:
kit.addSubscriber():POST /v3/forms/{formId}/subscribe,表单 ID 取自KIT_WELCOME_FORM_ID环境变量,未配置时跳过并告警;kit.sendBroadcast():POST /v3/broadcasts,public: true立即发送,send_at设定在 1 分钟后以确保处理;特别处理「发件地址未确认时广播被存为草稿」的 API 返回,将其降级为警告而非失败;kit.listSubscribers():GET /v3/subscribers,仅用于验证与日志输出。
一个值得留意的工程取舍:Kit 的 Broadcast API 面向群发,做 1:1 事务邮件并不标准,因此召回邮件步骤中对单用户投递采用了受限的替代方案(见函数内注释说明),这属于该集成的已知边界。
Database:MongoDB Atlas
- 连接:标准 URI。文档强调「DNS SRV bypassed for maximum reliability」——其实现位于 database/mongoose.ts#L5-L16:在建立连接前显式设置
dns.setServers(['8.8.8.8'])、dns.setDefaultResultOrder('ipv4first'),并在mongoose.connect()中指定family: 4、bufferCommands: false,以规避 Node 17+ 下querySrv的 IPv6 连接被拒问题 - 集合:
users(Better Auth 的user集合,含lastActiveAt、lastReengagementSentAt等运营字段)、watchlists(模型见 database/models/watchlist.model.ts)、alerts(模型见 database/models/alert.model.ts)
连接采用global.mongooseCache缓存单例模式(database/mongoose.ts#L31-L52),在 Next.js 热重载场景下避免重复建连。本地可用pnpm test:db(对应 scripts/test-db.mjs)验证连通性。
本地运行与验证
要完整跑通上述 API 与自动化链路,前置条件与步骤如下(依据 README.md Quick Start):
- 配置
.env:至少包含MONGODB_URI、BETTER_AUTH_SECRET、NEXT_PUBLIC_FINNHUB_API_KEY、GEMINI_API_KEY;启用 Kit 广播需补充KIT_API_KEY/KIT_API_SECRET;启用 Inngest 云部署需INNGEST_SIGNING_KEY;启用个性化邮件需NODEMAILER_EMAIL/NODEMAILER_PASSWORD。AI_PROVIDER默认gemini,可切换为minimax或siray; - 启动应用:
pnpm dev(Next.js + Turbopack); - 本地启动 Inngest(承载事件、Cron 与 AI 流程):
npx inngest-cli@latest dev- 验证数据库连通:
pnpm test:db; - 验证路径:注册新用户可触发
app/user.created→ 观察欢迎邮件流程;通过 Inngest 控制台手动触发app/send.weekly.news可提前演练每周摘要;Cron 类函数在本地 dev 环境下按各自周期自动运行。
小结
OpenStock 的 API 层以 lib/inngest/functions.ts 中的四个函数为骨架,把「用户行为 / 定时任务 → AI 生成 → 邮件投递」串成可重试、可观测的事件流水线;lib/ai-provider.ts 的多 Provider 降级与业务层的兜底文案共同构成其可用性保障;Finnhub 提供行情与新闻数据,Kit 承担大规模广播,MongoDB(含 DNS/SRV 处理)提供持久化。各组件的环境变量与端点均以当前仓库源码为准,可对照本文列出的文件路径逐层核查。
【免费下载链接】OpenStockOpenStock is an open-source alternative to expensive market platforms. Track real-time prices, set personalized alerts, and explore detailed company insights — built openly, for everyone, forever free.项目地址: https://gitcode.com/GitHub_Trending/ope/OpenStock
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考