我忘记了域名会自动续费,于是为我的副项目构建了一个仪表盘。
Source: Dev.to
我忘记了一个域名会自动续费,于是我为我的副项目们构建了一个仪表盘
背景
几个月前,我收到了一个意外的账单——一个我已经不再使用的域名自动续费了。
这让我意识到,我对所有副项目(域名、托管、API、数据库等)的费用和到期时间根本没有一个统一的视图。
手动检查每个服务的控制面板既费时又容易出错。
于是,我决定 自己动手,做一个小仪表盘,把所有副项目的关键信息集中在一起,随时可以查看哪些即将到期、哪些已经超支。
目标
- 统一视图:在一个页面展示所有项目的名称、类型、费用、到期日期以及状态(活跃/已到期)。
- 提醒功能:在项目即将到期前 7 天发送邮件或 Slack 通知。
- 可扩展:以后可以轻松添加新的项目或服务(例如 Supabase、Vercel、DigitalOcean 等)。
- 开源:把代码放在 GitHub 上,供其他人参考或自行部署。
技术栈
| 技术 | 作用 |
|---|---|
| Next.js | 前端框架,支持服务器端渲染(SSR)和 API 路由 |
| Tailwind CSS | 快速构建响应式 UI |
| Supabase | 作为后端数据库(PostgreSQL)和认证服务 |
| Vercel | 部署平台,自动构建和预览 |
| Node‑Mailer | 发送电子邮件提醒 |
| GitHub Actions | 定时任务(每天检查一次到期日期) |
数据模型
create table projects (
id uuid primary key default uuid_generate_v4(),
name text not null,
type text not null, -- domain, hosting, api, db, etc.
provider text not null, -- Namecheap, Vercel, Supabase, ...
cost numeric not null, -- 年费(USD)
renewal_date date not null, -- 下一次续费日期
status text not null default 'active', -- active / expired
notes text
);
实现步骤
1. 初始化项目
npx create-next-app@latest side-project-dashboard
cd side-project-dashboard
npm install tailwindcss@latest postcss@latest autoprefixer@latest
npx tailwindcss init -p
2. 配置 Tailwind
在 tailwind.config.js 中加入:
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
在 styles/globals.css 中添加:
@tailwind base;
@tailwind components;
@tailwind utilities;
3. 连接 Supabase
在根目录创建 .env.local:
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY=YOUR_SUPABASE_SERVICE_ROLE_KEY
在 lib/supabaseClient.js 中初始化:
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
4. 创建 API 路由获取项目列表
文件:pages/api/projects.js
import { supabase } from '../../lib/supabaseClient'
export default async function handler(req, res) {
const { data, error } = await supabase
.from('projects')
.select('*')
.order('renewal_date', { ascending: true })
if (error) return res.status(500).json({ error: error.message })
res.status(200).json(data)
}
5. 前端页面展示
文件:pages/index.js
import useSWR from 'swr'
import axios from 'axios'
const fetcher = url => axios.get(url).then(res => res.data)
export default function Home() {
const { data, error } = useSWR('/api/projects', fetcher)
if (error) return <div>加载失败</div>
if (!data) return <div>加载中…</div>
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">副项目仪表盘</h1>
<table className="min-w-full bg-white">
<thead>
<tr>
<th className="py-2">名称</th>
<th className="py-2">类型</th>
<th className="py-2">提供商</th>
<th className="py-2">费用 (USD)</th>
<th className="py-2">续费日期</th>
<th className="py-2">状态</th>
</tr>
</thead>
<tbody>
{data.map(project => (
<tr key={project.id} className="text-center border-t">
<td className="py-2">{project.name}</td>
<td>{project.type}</td>
<td>{project.provider}</td>
<td>{project.cost}</td>
<td>{new Date(project.renewal_date).toLocaleDateString()}</td>
<td className={project.status === 'expired' ? 'text-red-600' : 'text-green-600'}>
{project.status}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
6. 添加到期提醒(GitHub Actions + Node‑Mailer)
在仓库根目录创建 .github/workflows/reminder.yml:
name: Daily Renewal Reminder
on:
schedule:
- cron: '0 9 * * *' # 每天 UTC 09:00 运行
jobs:
send-reminder:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run reminder script
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
EMAIL_USER: ${{ secrets.EMAIL_USER }}
EMAIL_PASS: ${{ secrets.EMAIL_PASS }}
run: node scripts/sendReminder.js
scripts/sendReminder.js(核心逻辑):
import { createClient } from '@supabase/supabase-js'
import nodemailer from 'nodemailer'
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY)
async function main() {
const { data: projects, error } = await supabase
.from('projects')
.select('*')
.gte('renewal_date', new Date())
.lte('renewal_date', new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)) // 未来 7 天
if (error) {
console.error('Supabase error:', error)
process.exit(1)
}
if (!projects.length) {
console.log('No upcoming renewals.')
return
}
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
})
const html = `
<h2>即将到期的副项目</h2>
<ul>
${projects
.map(
p => `<li>${p.name} (${p.provider}) - ${new Date(p.renewal_date).toLocaleDateString()}</li>`
)
.join('')}
</ul>
`
await transporter.sendMail({
from: `"Side Project Dashboard" <${process.env.EMAIL_USER}>`,
to: process.env.EMAIL_USER,
subject: '🔔 副项目续费提醒',
html,
})
console.log('Reminder sent.')
}
main()
结果展示

- 所有项目按续费日期升序排列,颜色直观区分 活跃 与 已到期。
- 点击表头可以排序(后续计划实现)。
- 每天自动检查并通过邮件提醒即将到期的项目。
后续计划
- 过滤与搜索:支持按提供商、类型或状态过滤。
- 图表视图:使用 Chart.js 展示年度费用趋势。
- 多语言:添加 i18n 支持,方便国际化团队使用。
- 自托管:提供 Docker 镜像,方便在自有服务器上部署。
结语
这次意外的续费提醒让我意识到,把碎片化的资源集中管理是多么重要。
通过几天的编码,我不仅避免了未来的意外费用,还收获了一个可以随时扩展的仪表盘。
如果你也有类似的需求,欢迎 fork 项目、提交 PR,或者直接在自己的 Supabase 实例上复用这套代码。
Happy coding! 🚀
我在业余时间做了很多小事
我为朋友的论文写了一个网站,接了几单全栈委托工作,还有一个真正的副业正在尝试赚钱。这些项目都不在同一个技术栈上。我总是挑选对特定需求免费额度最慷慨的服务商。
- 论文网站使用 Firebase。
- 委托的项目托管在其他主机上。
- 副业分布在三个提供商上。
不同的免费计划、不同的仪表盘、不同的邮件。上个月我发现一个早已忘记的项目的域名自动续费。那一刻,我放弃了在脑子里记住这些信息,或者在永远不会更新的 Notion 中维护它们的想法。
于是我构建了 StackMemo —— 为每个项目提供一个看板,通过连接各服务商的 API,让费用和 KPI 数据自动更新。
本文讨论了几项最终证明很重要的设计决策。它写给考虑构建类似工具的任何人,或是对一个包含大量移动部件的 Next.js + Postgres 小应用如何组合感兴趣的读者。
技术栈
- Next.js 16(App Router) + TypeScript + Tailwind v4
- Postgres 在 Neon 上,使用原生
pg(不使用 ORM —— 我想看到 SQL) - NextAuth v5(凭证、GitHub、Google)
- Stripe 计费用于付费层级
- 通过 Next.js
instrumentation.ts实现的进程内定时任务
1. 连接器注册表
每个提供商(GitHub、Stripe、Neon、Cloudflare、Koyeb 等)实现相同的接口,并只注册一次。添加新提供商只需要新增一个文件并在注册表中添加一行。
// lib/connectors/types.ts
export type ConnectorImpl = {
provider: ConnectorProvider;
displayName: string;
iconPath?: string;
authType: "api_key" | "token" | "oauth";
credentialsSchema: CredentialsSchema;
setupGuide?: SetupGuide;
kpiCatalog: KpiDefinition[];
listResources(creds: ConnectorCredentials): Promise;
sync(args: SyncArgs): Promise;
};
// lib/connectors/index.ts
const registry: Partial<ConnectorImpl> = {
github,
stripe,
neon,
cloudflare,
koyeb,
};
export function getAllProviderMetadata(): ProviderMetadata[] {
/* … */
}
kpiCatalog 是关键所在。每个连接器声明它能够获取的指标(github.stars、stripe.mrr、cloudflare.requests_24h,……),用户在每个项目中选择想要激活的指标。同步仅获取已启用的 KPI,从而让 API 预算保持紧张。
接口有意返回
type SyncResult = { kpis: Record<string, any>; warnings: string[] };
而不是在部分失败时抛异常。真实世界的 API 经常在受计划限制的端点返回 403;如果把它当作硬性失败处理,单个被锁定的 KPI 就会导致整个同步中断。
营销着陆页同样读取这个注册表,所以当我添加新连接器时,芯片会自动出现在首页的轮播中——无需维护第二个同步列表。
2. Edge‑safe 认证拆分(Next.js 16 + NextAuth v5)
NextAuth v5 要求你将认证配置放在 Edge 运行时,以便中间件能够使用它。但完整的配置需要 Postgres 适配器和 bcrypt 来实现凭证登录,而这两者都无法在 Edge 中运行。
解决方案: 将配置拆分为两个文件。
// lib/auth.config.ts — Edge‑safe (no pg, no bcrypt)
export default {
providers: [GitHub({ /* … */ }), Google({ /* … */ })],
pages: { signIn: "/login" },
callbacks: { authorized: ({ auth }) => !!auth?.user },
} satisfies NextAuthConfig;
// lib/auth.ts — Node runtime (full version)
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PostgresAdapter(pool),
providers: [...authConfig.providers, Credentials({ /* … */ })],
});
中间件导入 Edge‑safe 配置;其余代码导入完整配置。事后回想,这个坑很明显,但如果不知情的话会浪费半天时间。
3. 营销与应用 Chrome 的路由组
首个版本只有一个 app/layout.tsx,其中已经写入了用户头部。当我添加营销着陆页时,那个头部也出现在着陆页的顶部——氛围不对(着陆页有自己的导航)。
解决方案: Next.js 路由组。
app/
layout.tsx ← 只包含 html/body/fonts
(marketing)/
layout.tsx ← 营销导航 + 页脚
page.tsx ← /
pricing/page.tsx ← /pricing
(app)/
layout.tsx ← 应用头部,带认证状态
dashboard/page.tsx ← /dashboard
projects/[id]/...
URL 保持不变,路由组对路由器是透明的。每个组都有自己的 Chrome。营销页面从不导入认证代码,因此未登录的访客在加载着陆页时不会触发数据库查询。
我还更新了中间件匹配器,使其仅保护实际的应用路由,/, /login, /signup 以及公开的分享链接(/p/[slug])仍可在不进行认证的情况下访问。
4. Encrypted credentials at rest
连接器会存储 API 密钥。以明文形式存储是绝对不可接受的;KMS 对于副项目来说又显得过于繁重。我选择使用 pgcrypto 并在环境变量中放置密钥。
// lib/crypto.ts
export function encryptCred(plaintext: string): Buffer {
// pgp_sym_encrypt(plaintext, CONNECTOR_CRED_KEY)
}
export function decryptCred(ciphertext: Buffer): string {
// pgp_sym_decrypt(ciphertext, CONNECTOR_CRED_KEY)
}
该密钥有意与 AUTH_SECRET 分离;旋转其中一个不应导致另一个失效。使用 openssl rand -base64 32 生成一次即可。
5. In‑process cron, no separate worker
每小时的 KPI 同步在 Next.js 服务器内部通过实验性的 instrumentation.ts 钩子运行。它会调度一个类似 setInterval 的任务,该任务:
- 查询所有已启用 KPI 的项目。
- 调用每个连接器的
sync方法。 - 将结果(以及任何警告)持久化到 Postgres。
由于该任务与应用进程共享同一进程,无需额外的基础设施来维护。唯一的注意点是同步仅在至少有一个实例的应用处于运行状态时才会执行——这对于业余级别的服务来说已经足够。
// instrumentation.ts (Next.js calls this once per worker on startup)
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startCron } = await import("@/lib/cron");
startCron();
}
}
要点
| 决策 | 重要性说明 |
|---|---|
| 连接器注册表 | 为 UI 和同步逻辑提供唯一可信来源;便于添加新供应商。 |
| Edge 安全认证拆分 | 允许中间件在 Edge 上运行,同时仍可使用全栈认证功能。 |
| 路由分组 | 在不更改 URL 的情况下,实现营销页面与已认证应用 UI 的清晰分离。 |
| 加密凭证 | 在无需完整 KMS 方案的情况下,确保 API 密钥安全。 |
| 进程内定时任务 | 无需额外的工作服务;足以应对低频率的后台任务。 |
如果你正在构建一个与众多第三方 API 交互的个人仪表盘,这些模式可以让代码库易于维护并保持运行成本低。祝开发愉快!
我想添加的内容
我目前有五个连接器(GitHub、Stripe、Neon、Cloudflare、Koyeb)。接下来计划添加:
- Vercel
- Plausible
- Supabase
- Resend
- Netlify
如果你曾经忘记了某个副项目的订阅,你最希望有哪些服务能提供连接器?我现在更想听取建议,而不是报名。
Live demo + free tier: StackMemo – 如果你觉得这篇文章有帮助,请留下你的反馈!