发布于 2026-01-06 2 阅读
0

我如何将语音 AI 演示变成真正的 SaaS 应用(身份验证、访问和限制)DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

我如何将语音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
Enter fullscreen mode Exit fullscreen mode

安装 SDK:

npm install @kinde-oss/kinde-auth-nextjs
Enter fullscreen mode Exit fullscreen mode

步骤二:添加登录、注册和注销流程

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

这意味着用户只需输入一次电子邮件地址,无需在 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>
    );
}
Enter fullscreen mode Exit fullscreen mode

为了防止已通过身份验证的用户再次看到登录页面,我使用了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]);
}
Enter fullscreen mode Exit fullscreen mode

注册页面(含价格表)

在注册流程中,我使用了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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

无论用户是通过电子邮件登录、使用社交认证注册,还是在不同流程之间切换,都能获得流畅的体验。

这些小细节——登录提示、重定向控制和原生用户界面——使整个身份验证系统感觉紧密集成、完善,并直接与您的 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
  }
);
Enter fullscreen mode Exit fullscreen mode

这种设置可确保:

  • 任何非公共路线都设有路障。
  • 如果已认证用户尝试访问/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(),
  }),
});

Enter fullscreen mode Exit fullscreen mode

然后运行:

npx convex dev
Enter fullscreen mode Exit fullscreen mode

这样就能立即创建一个响应式、类型安全的数据库。

为什么这样设计

  • 用户:集中存储身份信息和计划数据、功能标志、伴随限制
  • 助手:可配置的语音导师——每个用户可以创建多个
  • 会话:跟踪交互历史记录,与用户和伴侣关联
  • 书签:允许用户保存或收藏导师——可选,但对用户体验很有帮助。

这为你提供了以下结构:

  • 构建个性化仪表盘
  • 跟踪每个用户的使用情况
  • 按计划控制功能访问权限
  • 随着时间的推移,该模式能够安全地扩展。

我将使用此架构来创建用户帐户、存储会话并强制执行使用逻辑。

步骤 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;
  }
Enter fullscreen mode Exit fullscreen mode
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.");
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

我在这篇文章中详细介绍了如何正确设置。

快速绕道:导致所有演职员表丢失的漏洞

在早期测试中,我允许用户/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.");
}
Enter fullscreen mode Exit fullscreen mode

会后:

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
});
Enter fullscreen mode Exit fullscreen mode

这个简单的模式可以让你:

  • 试验流程
  • 升级奖励
  • 可预测的使用成本

额外功能:管理员视图,可编辑鸣谢信息

我添加了一条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 });
  },
});
Enter fullscreen mode Exit fullscreen mode

文件夹结构快照

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

Enter fullscreen mode Exit fullscreen mode

你现在拥有什么

你已经从游乐场走向了产品:

  • 使用社交账号登录进行身份验证
  • 通过中间件进行访问控制
  • 用户账户 + 信用额度
  • 无需基础设施的实时后端

它结构清晰,安全可靠,而且免费开始使用。

第三部分:Kindle Billing的盈利模式

下一篇文章我将:

  • 添加付费订阅
  • 按计划查看大门特征
  • 跟踪用户层级状态
  • 使用 Stripe 式计费升级流程

最后想说

你越早将你的 AI MVP 当作产品来对待,就能越快验证其真实使用情况。

KindeConvex等工具可以让你做到这一点——无需费力地构建自定义后端或从头开始构建登录系统。

你可以在周末搭建真正的SaaS基础设施。我做到了。

文章来源:https://dev.to/sholajgede/how-i-turned-a-voice-ai-demo-into-a-real-saas-app-auth-access-and-limits-14fl