我如何将语音AI演示转化为真正的SaaS应用(身份验证、访问权限和限制)
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
在第一部分中,我分享了如何使用 Vapi、Next.js 和 GPT-4 构建一个语音优先的 AI 导师。它速度快、表达能力强,而且出乎意料地实用。
但它同时也是……一片开阔地带。
没有用户账户。没有访问控制。没有使用限制。这只是一个游乐场。
现在是时候将原型变成真正的产品了——具备身份验证、受保护的仪表板、基于积分的使用以及知道谁在实际使用它的方法。
为什么大多数人工智能MVP在没有访问控制的情况下都会失败
早期人工智能工具常常只是华而不实的演示,缺乏真正的产品边界。
它们在上市当天令人印象深刻,但很快就销声匿迹:
- 没有登录账号?您就不知道您的用户是谁。
- 没有限制?用户会滥用 GPT-4 程序,让你付出代价。
- 没有架构?难以规模化或盈利。
如果你正在开发以人工智能为核心的工具,那么将GPT产品化就必不可少。最成功的AI应用会将访问、使用和用户体验视为首要功能,而不是事后考虑的因素。
这就是我接下来要做的事情。
我需要寄送的物品
为了将 Learnflow AI 从演示版转变为 SaaS 服务,我需要:
- 身份验证——注册、登录、注销
- 受保护的路由——仪表盘、助手、会话记录
- 用户记录——用于跟踪会话和积分使用情况
- 使用限制——防止滥用并鼓励升级
以下是我如何使用Kinde进行身份验证,以及使用Convex进行后端逻辑和实时数据处理来实现这一目标的方法。
为什么选择这个堆栈?
| 忧虑 | 工具 | 我为什么选择它 |
|---|---|---|
| 授权 + 计费 | 善良 | 内置身份验证、Google 登录和结算 API 集中在一个地方 |
| 实时后端 | 凸面 | 响应式数据、无服务器逻辑、TypeScript 原生 |
| 路线保护 | Next.js | 应用路由中间件简化了门控机制。 |
| 使用情况跟踪 | 凸面 | 使用文档数据库的简易信用逻辑 |
额外好处:这两款工具都可以免费上手,所以我可以快速体验并测试实际使用效果。
步骤 1:设置 Kindle 身份验证
Kinde处理了身份验证的所有难点:登录、会话、社交注册等等。
添加您的.env.local配置:
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
KINDE_ISSUER_URL=https://yourproject.kinde.com
NEXT_PUBLIC_KINDE_ISSUER_URL=https://yourproject.kinde.com
KINDE_SITE_URL=http://localhost:3000
KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000/dashboard
KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000
NEXT_PUBLIC_KINDE_CONNECTION_EMAIL_CODE=your_email_code
NEXT_PUBLIC_KINDE_CONNECTION_GOOGLE=your_google_code
安装 SDK:
npm install @kinde-oss/kinde-auth-nextjs
步骤二:添加登录、注册和注销流程
Kinde 提供了用于登录和注册的高级组件,但我添加了我们自己的自定义 UI,使体验感觉像是 Learnflow AI 的原生应用。
登录页面(邮箱+社交账号)
我使用了LoginLinkKinde,它接受authUrlParams——允许我们传递用户的电子邮件、首选连接方式和重定向目标。
为了让登录过程更快、更符合用户情境,我追踪了用户输入的电子邮件地址,并将其用作登录提示:
import { LoginLink } from "@kinde-oss/kinde-auth-nextjs/components";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function LoginForm() {
const [email, setEmail] = useState("");
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
return (
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={handleEmailChange}
required
/>
</div>
<LoginLink
authUrlParams={{
connection_id: process.env.NEXT_PUBLIC_KINDE_CONNECTION_EMAIL_CODE!,
login_hint: email,
post_login_redirect_url: `${window.location.origin}/dashboard`,
}}
>
<Button className="w-full">Sign in with Email</Button>
</LoginLink>
</div>
);
}
这意味着用户只需输入一次电子邮件地址,无需在 Kinde 托管页面上重新输入。
我还提供了使用相同方式登录的 Google 登录选项LoginLink,但使用的是 Google 连接 ID:
import { LoginLink } from "@kinde-oss/kinde-auth-nextjs/components";
import { Button } from "@/components/ui/button";
export function LoginForm() {
return (
<LoginLink
authUrlParams={{
connection_id: process.env.NEXT_PUBLIC_KINDE_CONNECTION_GOOGLE!,
post_login_redirect_url: `${window.location.origin}/dashboard`,
}}
>
<Button variant="outline" className="w-full">
Continue with Google
</Button>
</LoginLink>
);
}
为了防止已通过身份验证的用户再次看到登录页面,我使用了Kinde 的 useKindeBrowserClient()hook 并进行了重定向useEffect:
import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";
export function LoginForm() {
const router = useRouter();
const { isAuthenticated } = useKindeBrowserClient();
useEffect(() => {
if (isAuthenticated) {
router.push('/dashboard');
}
}, [isAuthenticated]);
}
注册页面(含价格表)
在注册流程中,我使用了RegisterLink一个可选的选项pricing_table_key——稍后会向用户显示不同的计划级别(在第 3 部分中处理)。
import { RegisterLink } from "@kinde-oss/kinde-auth-nextjs/components";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function SignupForm() {
const [email, setEmail] = useState("");
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
return (
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={handleEmailChange}
required
/>
</div>
<RegisterLink
authUrlParams={{
connection_id: process.env.NEXT_PUBLIC_KINDE_CONNECTION_EMAIL_CODE!,
login_hint: email,
pricing_table_key: "user_plans",
}}
>
<Button className="w-full">Sign up with Email</Button>
</RegisterLink>
</div>
);
}
Google 社交注册遵循相同的结构,但带有RegisterLink.
我为已有账户的用户添加了一个重定向 CTA:
<p className="text-sm text-center">
Already have an account?{' '}
<button
type="button"
className="underline hover:text-primary"
onClick={() => router.push("/auth#login")}
>
Sign in
</button>
</p>
无论用户是通过电子邮件登录、使用社交认证注册,还是在不同流程之间切换,都能获得流畅的体验。
这些小细节——登录提示、重定向控制和原生用户界面——使整个身份验证系统感觉紧密集成、完善,并直接与您的 Kinde 控制面板连接,用于分析、元数据和计费层级同步。
接下来,我保护了这些登录背后的路由。
步骤 3:使用 Next.js 中间件 + Kinde 实现路由控制
现在用户可以登录了,我需要锁定私有路由。
我使用了withAuthKinde 的中间件包,保护了除公共路由(如 、 和 )之外的/auth所有/about路由/terms。
以下是实际内容middleware.ts:
import { withAuth } from "@kinde-oss/kinde-auth-nextjs/middleware";
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: [
'/((?!api|about|privacy|terms|_next/static|_next/image|images|favicon.ico|sitemap.xml|robots.txt|$).*)',
],
}
export default withAuth(
function middleware(request: NextRequest) {
const token = request.cookies.get('kinde_token');
const { pathname } = request.nextUrl;
const isAuthPage = pathname === '/auth';
if (token && isAuthPage) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
},
{
loginPage: '/auth',
isReturnToCurrentPage: false
}
);
这种设置可确保:
- 任何非公共路线都设有路障。
- 如果已认证用户尝试访问
/auth,则会被重定向到/dashboard - 未经身份验证的用户访问受保护的路由将被重定向到
/auth
您还可以使用 getKindeServerSession() 在服务器操作或布局中获取用户信息——这对于额外的门控或获取元数据非常有用。
步骤 4:定义凸模式
是时候存储用户、会话和积分了。Convex 让这一切感觉就像在定义 TypeScript 类型:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
email: v.string(),
kindeId: v.string(),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
imageUrl: v.optional(v.string()),
imageStorageId: v.optional(v.id("_storage")),
credits.optional(v.number()), // represents the number of call minutes
plan: v.optional(v.union(
v.literal('free'),
v.literal('pro'),
v.literal('enterprise')
)),
features: v.optional(v.array(v.string())), // For storing individual feature flags
companionLimit: v.optional(v.number()), // Optional: Cache the computed limit
}),
companions: defineTable({
userId: v.id("users"),
name: v.string(),
subject: v.string(),
topic: v.string(),
style: v.string(),
voice: v.string(),
duration: v.number(),
author: v.string(),
updatedAt: v.string(),
}),
sessions: defineTable({
userId: v.id("users"),
companionId: v.id("companions"),
updatedAt: v.string(),
}),
bookmarks: defineTable({
userId: v.id("users"),
companionId: v.id("companions"),
updatedAt: v.string(),
}),
});
然后运行:
npx convex dev
这样就能立即创建一个响应式、类型安全的数据库。
为什么这样设计
- 用户:集中存储身份信息和计划数据、功能标志、伴随限制
- 助手:可配置的语音导师——每个用户可以创建多个
- 会话:跟踪交互历史记录,与用户和伴侣关联
- 书签:允许用户保存或收藏导师——可选,但对用户体验很有帮助。
这为你提供了以下结构:
- 构建个性化仪表盘
- 跟踪每个用户的使用情况
- 按计划控制功能访问权限
- 随着时间的推移,该模式能够安全地扩展。
我将使用此架构来创建用户帐户、存储会话并强制执行使用逻辑。
步骤 5:首次登录时创建用户
用户通过 Kinde 登录后,我会将其记录插入 Convex 中(如果该记录不存在):
const event = await validateKindeRequest(request);
if (!event) {
return new Response("Invalid request", { status: 400 });
}
switch (event.type) {
case "user.created":
await ctx.runMutation(internal.users.create, {
kindeId: event.data.user.id,
email: event.data.user.email,
firstName: event.data.user.first_name || "",
lastName: event.data.user.last_name || "",
imageUrl: event.data.user.image_url || ""
});
break;
}
export const create = internalMutation({
args: {
kindeId: v.string(),
email: v.string(),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
imageUrl: v.optional(v.string()),
imageStorageId: v.optional(v.id("_storage")),
credits v.optional(v.number)),
plan: v.optional(v.union(
v.literal('free'),
v.literal('pro'),
v.literal('enterprise')
)),
features: v.optional(v.array(v.string())),
companionLimit: v.optional(v.number())
},
handler: async (ctx, args) => {
try {
const exists = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("email"), args.email))
.unique();
if (exists) return exists;
return await ctx.db.insert("users", {
kindeId: args.kindeId,
email: args.email,
firstName: args.firstName || "",
lastName: args.lastName || "",
imageUrl: args.imageUrl,
imageStorageId: args.imageStorageId,
credits: args.credits || 10,
plan: args.plan || 'free',
features: args.features || [],
companionLimit: args.companionLimit
});
} catch (error) {
console.error("Error creating user:", error);
throw new ConvexError("Failed to create user.");
}
}
});
我在这篇文章中详细介绍了如何正确设置。
快速绕道:导致所有演职员表丢失的漏洞
在早期测试中,我允许用户/sessions在不检查余额的情况下访问该路线。这意味着即使余额为零的用户也可以触发 GPT-4 调用(费用由我承担)。
教训:在调用昂贵的 API之前,务必先检查其使用情况。
第六步:强制执行基于积分的使用方式
每次会话开始前,我都会调用一个凸查询来检查用户的信用余额:
import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
const { user } = useKindeBrowserClient();
const userId = user?.id;
const userData = useQuery(
api.users.getUserKinde,
userId ? { kindeId: userId } : "skip"
);
if (userData.credits <= 0) {
throw new Error("You’re out of credits. Please upgrade.");
}
会后:
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
const deductCredit = useMutation(api.users.deductCredit);
await deductCredit({
kindeId: userId,
credits: userData.credits - 1
});
这个简单的模式可以让你:
- 试验流程
- 升级奖励
- 可预测的使用成本
额外功能:管理员视图,可编辑鸣谢信息
我添加了一条app/admin/dashboard/page.tsx路由,允许我手动重置或充值测试用户的积分:
export const updateCredits = mutation({
args: { email: v.string(), credits: v.number() },
handler: async (ctx, args) => {
const user = await ctx.db.query("users").filter(q => q.eq(q.field("email"), args.email)).unique();
if (!user) throw new Error("User not found");
return await ctx.db.patch("users", user._id, { credits: args.credits });
},
});
文件夹结构快照
learnflow-ai/
├── app/
│ ├── auth/ ← Login/signup/logout pages
│ ├── (root)/
│ ├── dashboard/ ← Protected dashboard
│ ├── companions/ ← Tutor configuration
│ ├── sessions/ ← GPT-4 transcripts
│ └── layout.tsx
├── convex/
│ ├── users.ts ← Credit logic
│ ├── sessions.ts ← Conversation storage
│ └── schema.ts ← DB structure
├── lib/
│ ├── utils.ts ← App helpers
│ └── vapi.sdk.ts ← Vapi initialization
├── middleware.ts ← Route protection
你现在拥有什么
你已经从游乐场走向了产品:
- 使用社交账号登录进行身份验证
- 通过中间件进行访问控制
- 用户账户 + 信用额度
- 无需基础设施的实时后端
它结构清晰,安全可靠,而且免费开始使用。
第三部分:Kindle Billing的盈利模式
下一篇文章我将:
- 添加付费订阅
- 按计划查看大门特征
- 跟踪用户层级状态
- 使用 Stripe 式计费升级流程
最后想说
你越早将你的 AI MVP 当作产品来对待,就能越快验证其真实使用情况。
Kinde和Convex等工具可以让你做到这一点——无需费力地构建自定义后端或从头开始构建登录系统。
你可以在周末搭建真正的SaaS基础设施。我做到了。
文章来源:https://dev.to/sholajgede/how-i-turned-a-voice-ai-demo-into-a-real-saas-app-auth-access-and-limits-14fl