ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Next.js的个人IP内容矩阵系统实战

基于Next.js的个人IP内容矩阵系统实战 做个人IP最痛苦的事情不是写不出内容而是内容生产全靠脑子记、流程全靠人肉盯。公众号写一篇小红书要改一版抖音要再拆成短视频脚本知乎还得整理成问答体这些改版、排期、素材存档、平台数据回收以前全靠表格和个人备忘录硬撑。撑到内容量稍微上来一点表格就开始失控漏发、重复选题、数据找不到对应版本各种事故反复出现。所以我自己动手做了这套个人IP内容矩阵系统技术栈上选了Next.js PostgreSQL Prisma Tailwind这套全栈组合后端逻辑全部写在API Routes里没拆独立服务。这套系统的核心价值很简单把选题、内容资产、多平台发布计划、效果数据全部串在一条流水线上让内容从灵感到发布再到数据回收全程有迹可循。这篇文章就是把我在这个全栈项目里的代码实践、踩坑记录和方案取舍完整写出来适合正在做类似内容中台想法、或者想找一个真实业务练手全栈架构的朋友参考。1. 方案设计先把业务理顺再谈技术1.1 个人IP内容矩阵到底需要什么在写第一行代码之前我花了很长时间梳理自己真正的需求。个人IP的内容矩阵表面看是“多平台发内容”但实际上有几个很明确的痛点第一选题管理。我经常在灵感爆发期攒出几十个选题然后放到某个备忘录里吃灰。等想用的时候要么找不到了要么发现同一主题已经写过两遍。系统需要给每个选题一个唯一的状态从“灵感的草稿”到“已经产出内容”再到“已经被某平台使用过”每一步都要有记录。第二内容资产的版本管理。同一主题下长文、短视频脚本、小红书图文文案它们之间有关联但又是不同格式的资产。过去我用文件夹按平台归类结果是同一个主题的内容被拆散在好几个地方。系统里应该以“选题”为聚合根下面挂不同平台的内容变体。第三发布计划与状态跟踪。内容写好了不代表完事还要考虑不同平台的最佳发布时间。我之前因为漏看日期出现过同一篇内容在公众号和小红书同日发出直接导致数据分流。系统需要有计划排期并且记录每个平台的实际发布状态。第四数据回收。发布之后的效果数据必须回填到内容资产上否则写复盘的时候又是一顿乱翻。基于这四点产品形态就很清楚了选题库、内容工作台、发布日历、数据看板。技术框架不需要太重一个能跑前后端的全栈框架加一个关系型数据库就足够。所以Next.js成了最自然的选择它的App Router能同时承载页面渲染和API接口一套代码搞定整个应用。1.2 技术选型为什么是Next.js加PostgreSQL选型这块我说一下自己试过之后的结论。最开始想过Vue Flask这样的前后端分离方案但很快就发现一个问题这个项目的前端页面大多数是数据展示和表单操作没有极端复杂的交互把前后端拆成两个项目反而增加了部署成本服务端和前端之间的类型还容易不同步。Next.js的App Router模式让页面可以直接在服务端取数然后渲染出HTML。列表页、看板页这类场景根本不需要客户端发请求数据直接在服务端拿完再传给组件页面加载速度快代码也简洁。API Routes用来处理需要写操作或者有CSRF风险的接口比如创建选题、更新发布状态。这样形成了两级数据通道读多用Server Component写操作走API这个模式在这个项目里实测下来非常顺。数据库选了PostgreSQL理由很简单这个项目天然有实体关系。选题关联内容资产内容资产关联发布计划发布计划关联平台数据这些数据如果拆到MongoDB里做文档嵌套反而没有关系型数据库来得直接。尤其是我后面还要做“某平台某时间段内的数据汇总”这类查询SQL的聚合能力比在业务代码里做内存计算高效得多。ORM选用Prisma不选TypeORM的原因是我喜欢Prisma的schema即真理风格数据库结构一眼就能看全而且迁移工具好用不用手写SQL迁移脚本。1.3 目录结构按业务域划分不是按文件类型划分目录结构这块我踩过一次坑第一版按传统习惯分了components、lib、pages这样的目录结果业务逻辑散得到处都是。后来完全重构了一版改为按业务域划分。app/ (auth)/ (dashboard)/ topics/ assets/ planner/ analytics/ api/ topics/ assets/ planner/ analytics/ lib/ schema.prisma services/ topics.ts assets.ts planner.ts analytics.ts utils/ date.ts ai.ts platform.ts括号这种路由组是Next.js的约定(auth)和(dashboard)本身不生成URL路径只是用来组织布局。api路由也按业务域分开放这样每个域的页面、接口、服务逻辑都聚在一起改一个功能时只需要进同一个目录不用在几个大目录之间来回跳。这个结构坚持下来后面加新功能时确实轻松很多。2. 数据建模内容资产的主数据设计2.1 核心表结构选题、内容资产、发布计划数据模型是这套系统的地基也是最需要想清楚的部分。直接看Prisma schema我精简一下核心的表结构model Persona { id String id default(cuid()) name String description String platforms Json // 绑定的平台如 [xiaohongshu, wechat, douyin] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Topic { id String id default(cuid()) personaId String title String description String? status String default(idea) // idea - active - produced - archived source String default(manual) // manual | ai keyword String? targetAudience String? createdAt DateTime default(now()) updatedAt DateTime updatedAt assets ContentAsset[] } model ContentAsset { id String id default(cuid()) topicId String type String // longform | shortform | image | script platform String // wechat | xiaohongshu | douyin | zhihu title String body String // 正文内容Markdown格式 mediaUrls Json? status String default(draft) // draft - reviewing - approved - published wordCount Int default(0) createdAt DateTime default(now()) updatedAt DateTime updatedAt } model PublishPlan { id String id default(cuid()) assetId String platform String plannedAt DateTime publishedAt DateTime? status String default(scheduled) // scheduled - published | failed externalUrl String? platformPostId String? errorMessage String? version Int default(0) createdAt DateTime default(now()) updatedAt DateTime updatedAt } model PlatformMetric { id String id default(cuid()) planId String platform String metrics Json collectedAt DateTime default(now()) }这个建模思路的关键在于ContentAsset挂在Topic下PublishPlan挂在ContentAsset下形成一条从灵感到发布到数据回收的完整链路。你不能凭空建一个ContentAsset必须先有Topic。同理你不能凭空建一个PublishPlan必须先有ContentAsset。这个约束保证了系统的数据是干净的。PlatformMetric单独建表而不是在PublishPlan里加几个字段是因为同一个发布计划在不同日期会被多次抓取数据今天是阅读量过几天又是新的阅读量历史轨迹要保留下来。2.2 内容状态机设计流转要可控状态管理是这个系统里容易被忽略但实际上决定体验的部分。我设计了两个状态机Topic的状态idea灵感阶段到active被采纳进排期到produced已经有内容产出再到archived归档。这里不能出现跳过比如一个topic直接到produced而没有active说明流程上有漏操作。type TopicStatus idea | active | produced | archived; const TOPIC_TRANSITIONS: RecordTopicStatus, TopicStatus[] { idea: [active, archived], active: [produced, archived], produced: [archived, active], archived: [active], }; export function canTransitionTopic(from: TopicStatus, to: TopicStatus): boolean { return TOPIC_TRANSITIONS[from]?.includes(to) ?? false; }ContentAsset的状态draft到reviewing到approved再到published以及从approved回到draft的返工路径。发布计划PublishPlan的状态scheduled到published或者failed。这套约束看起来多余但真正用起来之后我发现它能强制使用者也就是我自己保持操作的规范性不会出现内容还没定稿就标记了已发布这种混乱。2.3 时间字段和软删除的实践心得关于时间字段所有时间戳都统一用UTC存储这是一个非常关键的决定。因为内容发布涉及排期如果直接用本地时间存换一台服务器或者换了时区排期就全乱了。我在所有涉及时间的字段都用了DateTime类型并且在前端展示的时候统一用dayjs配合本地时区做格式化转换。软删除方面这个系统的数据都是长期价值资产我不会物理删除任何记录只在需要隐藏的地方用状态字段控制显示。比如选题归档了我就不展示在待办列表里但数据还在库里随时可以恢复。这个决策让我避免了“误删了又找不回来”的尴尬。另外所有表都加了createdAt和updatedAt这两个字段的语义很简单createdAt记录资产进入系统的时间updatedAt记录最近一次修改这在看板按时间段汇总的时候非常好用可以直接按createdAt分组统计每周的选题产出量。3. 后端API与业务逻辑落地3.1 统一响应格式与错误处理Next.js的API Routes本质上是函数每个route handler接收Request对象返回Response对象。一开始我写接口的时候每个接口自己处理错误结果前端拿到错误时的格式五花八门。后来统一做了一个响应包装器export function okT(data: T, message success) { return Response.json( { code: 0, message, data }, { status: 200 } ); } export function fail(message: string, status 400, code -1) { return Response.json( { code, message, data: null }, { status } ); } export function handleError(error: unknown) { console.error([api error], error); if (error instanceof ZodError) { return fail(参数校验失败: error.message, 422); } if (error instanceof Prisma.PrismaClientKnownRequestError) { return fail(数据库操作失败, 500); } return fail(服务器内部错误, 500); }统一的响应格式是{ code, message, data }前端只要判断code是否为0就能知道成功还是失败错误信息通过message字段展示给用户。这个包装看起来简单但它让调用方不需要关心HTTP状态码的各种边界情况遇到参数错误、数据库错误、未知错误都能拿到结构化的错误信息。3.2 服务端Session管理与权限控制这个系统虽然是个人用但在设计上我还是加了简单的登录机制因为后期可能开放给团队协作。会话管理直接用了JWT放在HTTP Only的Cookie里服务端通过middleware统一校验// middleware.ts import { NextResponse } from next/server; import { jwtVerify } from jose; const SECRET new TextEncoder().encode(process.env.JWT_SECRET); export async function middleware(request: NextRequest) { const token request.cookies.get(token)?.value; if (!token) { return NextResponse.redirect(new URL(/login, request.url)); } try { await jwtVerify(token, SECRET); return NextResponse.next(); } catch { return NextResponse.redirect(new URL(/login, request.url)); } } export const config { matcher: [/((?!login|_next/static|favicon.ico).*)], };jose这个库是我推荐的因为Edge Runtime和middleware默认运行在Vercel Edge上Node.js的jsonwebtoken库在Edge环境不兼容。jose完全兼容而且API简洁。这里有个小细节matcher要排除登录页和静态资源否则用户还没登录就被重定向循环了。3.3 服务层业务逻辑选题推荐怎么实现API route不该堆逻辑我把可复用的业务逻辑抽到了lib/services目录里。举一个比较有代表性的例子——选题推荐功能。我的选题库里可能躺着几百个历史选题每次想新选题时都容易撞车。我写了一个服务用关键词相似度来排除重复选题import { PrismaClient } from prisma/client; import { normalizeText, calculateSimilarity } from /lib/utils/text; const prisma new PrismaClient(); export async function suggestTopics(keyword: string, limit: number 5) { const normalizedKeyword normalizeText(keyword); const existingTopics await prisma.topic.findMany({ where: { status: { in: [idea, active] } }, select: { id: true, title: true, keyword: true }, }); const suggestions existingTopics .map((topic) { const topicKeyword normalizeText(topic.keyword ?? ); const similarity calculateSimilarity(normalizedKeyword, topicKeyword); return { ...topic, similarity }; }) .filter((t) t.similarity 0.6) .sort((a, b) b.similarity - a.similarity) .slice(0, limit); return suggestions; }calculateSimilarity我实现的是简单的Jaccard相似度基于bigram二字切分集合的交并比对中文短文本来说效果够用而且零依赖。这个逻辑完全没必要上向量数据库几百条数据量级内存算一下绰绰有余。4. AI能力集成让内容矩阵系统半自动运转4.1 LLM接口封装统一调用入口这套系统的核心价值之一就是把AI从玩具变成生产力工具。我在系统里集成了几个AI能力选题生成、标题改写、内容摘要提取、平台风格改写。但AI集成有一个容易被低估的工程问题LLM的输出是不稳定的而业务系统需要稳定。所以我做了一个统一的AI调用封装import OpenAI from openai; const openai new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); export async function generateTopics(persona: string, count: number 5) { const completion await openai.chat.completions.create({ model: gpt-4o-mini, messages: [ { role: system, content: 你是一位资深内容策划深度了解${persona}的受众。请为这位个人IP生成${count}个高质量选题。, }, { role: user, content: 请输出严格的JSON数组每个元素包含title和description字段。不要输出任何多余文字。, }, ], response_format: { type: json_object }, }); return parseJsonOutput(completion.choices[0].message.content ?? ); }关键点在于response_format设置为json_object这让模型强行输出结构化JSON而不是一段带解释的文字。这个API参数是OpenAI在后期版本加的不设置的话模型经常会在JSON外面包一层markdown代码块或解释性文字解析的时候非常痛苦。4.2 结构化输出容错AI代码里最重要的一环模型即使设置了json_object模式也还是有可能输出格式异常比如用了单引号、尾逗号、或者是空字符串。所以我在parseJsonOutput里做了三层容错export function parseJsonOutput(content: string): unknown { if (!content || content.trim() ) { throw new Error(AI返回内容为空); } const cleaned content .trim() .replace(/^(?:json)?/gm, ) .replace(/$/gm, ) .trim(); try { return JSON.parse(cleaned); } catch { // 某些情况下AI会输出带有解释前缀的JSON尝试截取第一个{到最后一个}之间的内容 const match cleaned.match(/\{[\s\S]*\}/); if (match) { try { return JSON.parse(match[0]); } catch { throw new Error(AI输出无法解析为JSON); } } throw new Error(AI输出中未找到JSON结构); } }这个函数我建议直接复制走因为几乎每个接入LLM的项目都会遇到这个问题。先清理markdown代码块标记再尝试直接解析解析失败就尝试用正则截取JSON对象区域最后才抛错。这样即使模型输出不规范也能尽最大努力把数据提取出来。4.3 定时任务每天自动生成一批新选题集成AI的另一个落地场景是定时任务。我在系统里接了一个每日定时任务每天早上自动生成一批新选题存放在idea状态等我来筛选// app/api/cron/generate-topics/route.ts import { NextRequest } from next/server; import { generateTopics } from /lib/services/ai; import { prisma } from /lib/prisma; export async function GET(request: NextRequest) { const authHeader request.headers.get(authorization); if (authHeader ! Bearer ${process.env.CRON_SECRET}) { return Response.json({ error: Unauthorized }, { status: 401 }); } const personas await prisma.persona.findMany(); for (const persona of personas) { const topics await generateTopics(persona.name, 3); await prisma.topic.createMany({ data: topics.map((t: any) ({ personaId: persona.id, title: t.title, description: t.description, status: idea, source: ai, })), }); } return Response.json({ message: topics generated }); }定时任务接口必须校验请求来源这里用了一个CRON_SECRET做Bearer鉴权。Vercel的Cron配置则是定期向这个接口发请求避免接口被任何人调用后产生大量无意义的AI请求浪费额度。5. 前端实现细节交互与体验的打磨5.1 Server Component与API的明确分工前端我做了很明确的分层。列表页、统计页这类以读操作为主的页面用Server Component在服务端直接取数然后渲染出静态HTML这种方式的性能非常好也不需要前端load状态。难点在于Server Component里不能直接用useState和onClick需要交互的地方就切给客户端组件。具体的做法是一个页面里外层是Server Component负责数据获取和骨架内层组件标注“use client”接收服务端传下来的数据再负责交互。比如选题列表页// app/dashboard/topics/page.tsx import { prisma } from /lib/prisma; import { TopicTable } from ./TopicTable; export default async function TopicsPage() { const topics await prisma.topic.findMany({ where: { status: { not: archived } }, orderBy: { updatedAt: desc }, include: { assets: { select: { id: true } } }, }); return TopicTable initialTopics{topics} /; }TopicTable内部是一个客户端组件管理筛选、排序、分页这些交互状态但数据的初始值来自服务端不再多一次请求。5.2 编辑器与拖拽排序的选型内容编辑是这个系统使用频次最高的页面我一开始考虑上了富文本编辑器但后来决定用Markdown文本域加实时预览的方案。理由很简单富文本编辑器在内容迁移时容易产生脏HTML而Markdown是纯文本以后做平台分发时转成小红书格式、知乎格式都很方便不会出现样式错乱。发布日历的拖拽排序我用的是dnd-kit相比react-dnddnd-kit的API更现代对触摸设备和键盘操作的支持也更好。拖拽的目的是手动调整计划的发布时间这个功能在产品上很简单但代码上要注意能拖拽的只有处于scheduled状态、还没发布的计划已经发布的不允许拖。5.3 看板图表别一上来就堆“酷炫”数据看板这块我用了Recharts这是React生态里我用的最顺手的图表库对时间序列图、柱状图、饼图都有比较友好的封装。看板的最核心指标是近30天各平台的发布数量、互动总数、阅读量趋势、选题到发布的平均耗时。这些指标拆到图表上就是三个柱状图加一个折线图。Recharts的用法很直白比如平台发布数量对比图use client; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, } from recharts; export function PlatformPublishChart({ data }: { data: { platform: string; count: number }[] }) { return ( ResponsiveContainer width100% height{260} BarChart data{data} CartesianGrid strokeDasharray3 3 / XAxis dataKeyplatform / YAxis / Tooltip / Bar dataKeycount fill#1677ff radius{[4, 4, 0, 0]} / /BarChart /ResponsiveContainer ); }ResponsiveContainer会自动根据父容器宽度调整图表尺寸这比固定宽度要稳定得多尤其在移动端预览时表现明显。6. 数据看板与统计聚合6.1 跨平台指标采集的实现思路数据回收是内容矩阵闭环的最后一环也是最容易偷懒的一环。我设计了一个PlatformMetric采集表每条记录包含planId、platform、metrics和collectedAt。metrics是一个JSON字段结构是动态的因为不同平台的指标不一样{ views: 1200, likes: 86, comments: 12, shares: 5, saves: 23 }采集方式目前半自动化一部分平台开放了官方API我写了一个采集脚本通过它们的API拉取数据后写入PlatformMetric表没有API的平台就手动在后台填。采集脚本通过一个管理员接口手动触发避免被Cron调用时因为接口限流导致封号风险。6.2 聚合查询与性能优化看板页需要的不是明细数据而是聚合后的统计。我第一次用Prisma直接分组查询发现在数据量超过几万条后会明显变慢。后来换了思路用SQL直接聚合Prisma允许使用$queryRaw执行原生SQL这个查询性能非常好SELECT DATE_TRUNC(day, collected_at) AS day, platform, COUNT(DISTINCT plan_id) AS publish_count, SUM(metrics-views)::int AS total_views, SUM(metrics-likes)::int AS total_likes, SUM(metrics-comments)::int AS total_comments, SUM(metrics-shares)::int AS total_shares FROM platform_metric WHERE collected_at NOW() - INTERVAL 30 days GROUP BY 1, 2 ORDER BY 1 ASC;PostgreSQL的JSON字段可以直接用-操作符取出文本值并做类型转换这个能力在处理动态指标时特别实用。SQL聚合在数据库端执行只返回已经汇总好的结果大幅降低了网络传输量和JS内存占用。6.3 看板加载速度优化的两个技巧看板页最初每次打开都要现算聚合数据量大时能感觉到明显延迟。后来做了两个优化第一个是给platform_metric表加了(tplatform, collected_at)的复合索引让范围查询走索引而不是全表扫描第二个是每天凌晨跑一个定时任务生成前一天的聚合数据存入单独一张summary表看板页默认直接读summary表只有需要看当天实时数据时才查明细表。这两个优化做完看板加载速度从原来的平均3秒降到了500毫秒以内。7. 部署、监控与迭代7.1 Vercel部署与Cron配置这个系统我部署在Vercel上全程没有自己的服务器这对个人项目来说是最省心的方案。Vercel部署Next.js是零配置体验推代码自动构建。数据库用Neon的PostgreSQL免费套餐它的serverless特性就是按需启动连接适合偶尔访问的小项目。定时任务直接在vercel.json里配置{ crons: [ { path: /api/cron/generate-topics, schedule: 0 0 * * * }, { path: /api/cron/collect-metrics, schedule: 0 9 * * * } ] }注意Vercel的Cron用UTC时间0点等于北京时间早上8点这个时间点生成选题正好是大多数人的晨间创作时段。7.2 环境变量与密钥管理的坑个人项目很容易把密钥直接写在代码里这个习惯我强烈建议改掉。所有敏感信息走环境变量本地用.env.local生产环境在Vercel面板里配置。尤其是OpenAI的API密钥和数据库连接字符串一旦泄露就非常被动AI密钥被刷的代价可能是一夜之间几百美金的账单。环境变量命名我有一套自己的规范前缀固定是OPENAI_、DATABASE_、CRON_、JWT_这样一看变量名就知道它属于哪个模块。服务器端的变量必须在变量名前加NEXT_PUBLIC前缀才能暴露给浏览器端代码这本身就是一个安全的屏障。默认情况下服务端变量对浏览器端是完全不可见的。7.3 日志、错误监控与迭代节奏这个系统虽然小但日志和错误监控不能省。API Routes里所有接口都被handleError包装任何未捕获的异常都会先走console.error再调用一个统一的错误上报函数。Vercel自带日志查看功能在控制台可以直接看到每个函数的输出配合Vercel Analytics能观察到页面的访问情况。迭代方面我有一个很务实的建议不要想着一次性把所有功能做完。这个系统我分了三个阶段上线第一阶段只有选题库和内容资产录入第二阶段加了发布计划和日历第三阶段才上的数据看板。每个阶段我都能正常地日常使用而不是一直在等“功能齐全了才用起来”的那个永远不会到来的日子。8. 踩过的坑与排查技巧实录8.1 Next.js Server Component中直接用Prisma导致的连接数问题这是我这个项目踩过最大的坑。第一版我直接在Server Component里new PrismaClient()结果本地一次页面刷新就报了数据库连接数上限错误。原因很简单Next.js在开发模式下每次热更新都会重新执行模块而PrismaClient如果被反复实例化连接池就会不断增长。解决方法是把PrismaClient定义为全局单例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma prisma; }globalThis上挂一个prisma实例开发模式下热更新不会反复创建新连接生产环境则每次实例化都能在请求之间复用。8.2 内容发布失败后的状态处理发布计划中常遇到的问题是平台登录态过期或者内容素材不完整导致发布失败。最初我只记录了一个failed状态但failed之后怎么办没有设计好。后来我在PublishPlan上加了errorMessage字段失败时写入具体原因而且只允许从failed状态重新回到scheduled不允许从failed直接改到published防止出现“平台实际没发出去但系统标记了已发布”的假数据。8.3 LLM生成内容的质量把控AI生成选题这件事最大的风险不是技术而是内容质量。系统确实能自动生成几十个选题但其中真正符合我IP调性的大概只有三分之一。所以我对AI生成内容加了一个“人工审核”的前置环节AI生成的所有选题都标记source为ai并且状态落在idea不会直接进入active排期。我必须手动点一下“采纳”按钮它才会进入正式选题池。这个设计本质上是在效率和可控之间做了一个取舍。纯粹的自动化会让系统变得省事但代价是内容失去个人风格。对于个人IP这种靠人味吃饭的场景我宁愿多花30秒筛选也不让系统替我做了所有决定。最后再分享一点我做这个项目最大的体会全栈项目真正难的不是某个技术点而是把业务逻辑想明白之后让所有技术环节都围绕业务去落地。这个内容矩阵系统写下来代码量不大但每个表、每个接口、每个页面的设计都直接服务于“从灵感到数据回收”这条业务链路。如果你也要做类似的系统我建议你把四成精力花在业务梳理和数据建模上三成花在整合AI这类智能能力上剩下三成才是编码和调试。这套比例如果反了先写代码再补业务大概率要返工。
返回列表