
1. 从零搭建 JS 全栈 CMS前后端目录结构与 API 调用链路怎么设计做 JS 全栈 CMS最容易卡住的不是写页面也不是写接口而是前后端各自跑起来之后API 调用链路对不上前端请求发出去后端收不到后端返回了前端解析报错本地调试通了换个环境又 401。我试过把这类问题拆成三层来看——项目结构层、环境变量层、模型调用层一层一层对齐链路就通了。这篇要交付的东西很具体一个能跑通内容增删改查的 JS 全栈 CMS 骨架前端管理面板用 React Vite后端内容 API 用 Express Mongoose数据库用 MongoDB。同时把 AI 能力接进来用 TaoToken 的统一 Key 打通模型调用链路让 CMS 里的摘要生成、标题润色、内容分类这些功能不用再单独维护多套密钥。适合谁看已经会写基础 JS、想完整走一遍全栈 CMS 从零搭建流程的开发者手里有多个模型供应商、被 Key 管理搞烦的人以及想把 AI 能力嵌进内容管理后台、但不想在密钥配置上花太多时间的人。核心检索词先明确JS 全栈开发、CMS 系统、前后端搭建、统一 Key 打通 API 调用链路。下面按可跟做的顺序展开每一步都给完整命令和配置。先说整体链路。前端管理面板负责内容编辑和列表展示通过 axios 调用后端 REST API后端 Express 负责鉴权、内容 CRUD、以及转发 AI 请求AI 请求统一走 TaoToken 的 API 地址用同一个 Key 调用不同模型。这样前端只需要认后端一个地址后端只需要认 TaoToken 一个 Key链路从三段变成两段排障范围直接缩小。目录结构建议这样分js-cms/ ├── server/ │ ├── src/ │ │ ├── models/ # Mongoose 数据模型 │ │ ├── routes/ # 内容、用户、AI 路由 │ │ ├── middleware/ # JWT 鉴权、错误处理 │ │ ├── services/ # AI 调用封装 │ │ └── app.js │ ├── .env │ └── package.json ├── admin/ │ ├── src/ │ │ ├── api/ # axios 实例与接口封装 │ │ ├── pages/ # 内容列表、编辑、登录 │ │ ├── components/ │ │ └── main.jsx │ ├── .env │ └── package.json └── README.md前后端分离但同仓库好处是环境变量模板可以放在一起对照改接口时两边一起改不容易漏。后端端口默认 4000前端开发端口 5173前端通过 Vite 代理把/api转发到后端避免开发期跨域。环境变量模板先定下来后面所有配置都围绕它# server/.env PORT4000 MONGODB_URImongodb://127.0.0.1:27017/js_cms JWT_SECRETreplace_with_a_long_random_string TAOTOKEN_API_KEYsk-你的TaoToken密钥 TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELclaude-sonnet-4-5# admin/.env VITE_API_BASE/api注意TAOTOKEN_BASE_URL只写到/api不要带多余路径也不要加 UTM 参数服务端调用保持干净。Key 只放后端前端永远不接触密钥这是链路安全的基本盘。初始化命令按顺序执行mkdir js-cms cd js-cms mkdir server admin # 后端 cd server npm init -y npm i express mongoose jsonwebtoken bcryptjs cors dotenv axios npm i -D nodemon # 前端 cd ../admin npm create vitelatest . -- --template react npm i axios react-router-dom后端package.json里加两行脚本方便开发{ scripts: { dev: nodemon src/app.js, start: node src/app.js } }到这一步项目骨架和环境变量就位。下一节把 TaoToken 的接入前置讲清楚包括 Key 怎么拿、Base URL 怎么填、模型 ID 从哪查避免后面配置时来回翻文档。2. TaoToken 前置准备统一 Key 与 Base URL 配置打通多模型调用TaoToken 在这里扮演的角色是「统一入口」你不需要为每个模型单独申请密钥、单独记 Base URL而是用同一个 Key、同一个 API 地址通过切换 Model ID 来调用不同模型。对 CMS 这种需要多种 AI 能力的场景很实用——生成摘要用一个模型长文润色用另一个分类打标再用一个但配置只有一份。前置准备分三步拿 Key、确认 Base URL、查模型 ID。第一步拿 Key。打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册登录后进入控制台在 API Keys 页面创建一个新 Key。创建时建议按用途命名比如js-cms-dev方便后面区分开发和生产。Key 只在创建时完整显示一次复制后立刻写进server/.env的TAOTOKEN_API_KEY不要提交到 Git。控制台地址https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI Keys 页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content第二步确认 Base URL。服务端调用的地址是https://taotoken.net/api这个地址不加任何查询参数。很多接入失败是因为把带 UTM 的官网地址误当成 API 地址填进去了两者要分清官网地址用于浏览和注册API 地址用于代码调用。第三步查模型 ID。在文档页可以看到当前支持的模型列表和对应的 Model ID 写法。Model ID 是大小写敏感的填错会直接报模型不存在。文档地址https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content如果你用的是 Claude Code 这类编码工具TaoToken 也提供了对应的接入方式Base URL 同样是https://taotoken.net/apiKey 用同一个Model ID 按文档填。相关入口https://taotoken.net/claudecodeanthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content这里要强调一个链路设计原则CMS 后端只暴露一个 AI 服务模块所有模型调用都经过它。这样前端不需要知道用的是哪个模型也不需要知道 Key 是什么。服务模块内部读环境变量拼请求返回结果。换模型只改一个环境变量不动业务代码。在server/src/services/aiService.js里封装调用// server/src/services/aiService.js const axios require(axios); const client axios.create({ baseURL: process.env.TAOTOKEN_BASE_URL, timeout: 60000, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.TAOTOKEN_API_KEY}, }, }); async function chat(messages, model process.env.TAOTOKEN_MODEL) { const { data } await client.post(/v1/messages, { model, max_tokens: 1024, messages, }); return data; } module.exports { chat };注意路径是/v1/messages拼接在 Base URL 后面。不同模型的请求体格式可能略有差异如果用的是 OpenAI 兼容格式路径和字段要按文档调整。封装成函数的好处是业务层只调chat()不关心底层是哪个模型。Key 管理上有个实用技巧开发和生产用不同的 Key在控制台分别创建环境变量里区分。这样即使开发 Key 泄露也不会影响生产额度。另外Key 不要写进任何前端代码、不要写进 Docker 镜像、不要贴到聊天记录里。前置准备做完下一节进入可复制配置把后端 Express 的完整配置、前端 axios 实例、以及 AI 路由的 JSON 配置片段都给出来直接抄就能跑。3. 可复制配置Express 后端、Vite 代理与 AI 路由完整片段这一节给的是能直接复制进项目的配置。先看后端入口server/src/app.js// server/src/app.js require(dotenv).config(); const express require(express); const mongoose require(mongoose); const cors require(cors); const contentRoutes require(./routes/content); const authRoutes require(./routes/auth); const aiRoutes require(./routes/ai); const app express(); app.use(cors()); app.use(express.json({ limit: 2mb })); app.use(/api/auth, authRoutes); app.use(/api/content, contentRoutes); app.use(/api/ai, aiRoutes); app.get(/api/health, (req, res) { res.json({ ok: true, ts: Date.now() }); }); const PORT process.env.PORT || 4000; mongoose .connect(process.env.MONGODB_URI) .then(() { app.listen(PORT, () console.log(server on ${PORT})); }) .catch((err) { console.error(mongo connect failed, err.message); process.exit(1); });内容模型server/src/models/Content.js// server/src/models/Content.js const mongoose require(mongoose); const contentSchema new mongoose.Schema( { title: { type: String, required: true }, slug: { type: String, required: true, unique: true }, body: { type: String, default: }, summary: { type: String, default: }, tags: [{ type: String }], status: { type: String, enum: [draft, published], default: draft }, author: { type: mongoose.Schema.Types.ObjectId, ref: User }, }, { timestamps: true } ); module.exports mongoose.model(Content, contentSchema);内容路由server/src/routes/content.js覆盖增删改查// server/src/routes/content.js const router require(express).Router(); const Content require(../models/Content); const auth require(../middleware/auth); router.get(/, auth, async (req, res) { const list await Content.find().sort({ createdAt: -1 }); res.json(list); }); router.post(/, auth, async (req, res) { const doc await Content.create(req.body); res.status(201).json(doc); }); router.put(/:id, auth, async (req, res) { const doc await Content.findByIdAndUpdate(req.params.id, req.body, { new: true, }); res.json(doc); }); router.delete(/:id, auth, async (req, res) { await Content.findByIdAndDelete(req.params.id); res.json({ ok: true }); }); module.exports router;AI 路由server/src/routes/ai.js把摘要生成接进来// server/src/routes/ai.js const router require(express).Router(); const auth require(../middleware/auth); const { chat } require(../services/aiService); router.post(/summarize, auth, async (req, res) { const { text } req.body; if (!text) return res.status(400).json({ error: text required }); try { const data await chat([ { role: user, content: 请为以下内容生成不超过120字的摘要\n${text} }, ]); const summary data?.content?.[0]?.text || ; res.json({ summary }); } catch (err) { const status err.response?.status || 500; res.status(status).json({ error: ai request failed, detail: err.response?.data || err.message, }); } }); module.exports router;前端 axios 实例admin/src/api/client.js// admin/src/api/client.js import axios from axios; const client axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 60000, }); client.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) config.headers.Authorization Bearer ${token}; return config; }); export default client;Vite 代理配置admin/vite.config.js// admin/vite.config.js import { defineConfig } from vite; import react from vitejs/plugin-react; export default defineConfig({ plugins: [react()], server: { port: 5173, proxy: { /api: { target: http://localhost:4000, changeOrigin: true, }, }, }, });如果你用 Cline 或类似工具做 MCP 接入配置里同样要写全三件套Base URL 填https://taotoken.net/apiKey 填 TaoToken 的 KeyModel ID 按文档填。三者缺一请求就会失败。Cline MCP 的配置片段大致如下{ mcpServers: { taotoken: { command: npx, args: [-y, taotoken/mcp-server], env: { TAOTOKEN_BASE_URL: https://taotoken.net/api, TAOTOKEN_API_KEY: sk-你的密钥, TAOTOKEN_MODEL: claude-sonnet-4-5 } } } }Codex 的auth.json也是同样的三件套逻辑Base URL、Key、Model ID 一个都不能少。配置路径按工具文档来字段名可能不同但内容一致。配置写完启动顺序是先起 MongoDB再起后端最后起前端。下一节验证请求看链路是否真的通了。4. 验证请求从健康检查到内容增删改查与 AI 摘要闭环验证要分层做一层通了再下一层不要一上来就测 AI否则报错分不清是链路问题还是模型问题。第一层健康检查。后端起来后执行curl http://localhost:4000/api/health返回{ok:true,ts:...}说明 Express 和 MongoDB 连接正常。如果这里就失败先查 MongoDB 是否启动、MONGODB_URI是否正确。第二层注册和登录拿 tokencurl -X POST http://localhost:4000/api/auth/register \ -H Content-Type: application/json \ -d {email:devtest.com,password:12345678} curl -X POST http://localhost:4000/api/auth/login \ -H Content-Type: application/json \ -d {email:devtest.com,password:12345678}登录返回里会有token复制出来后面请求都带上。第三层内容增删改查。创建一条内容curl -X POST http://localhost:4000/api/content \ -H Content-Type: application/json \ -H Authorization: Bearer 你的token \ -d {title:第一篇,slug:first-post,body:这是正文内容,status:draft}返回 201 和文档对象说明写入成功。再查列表curl http://localhost:4000/api/content \ -H Authorization: Bearer 你的token能看到刚才那条说明读也通了。更新和删除同理把 id 换进去即可。到这一步CMS 的内容闭环已经跑通。第四层AI 摘要。这是验证 TaoToken 链路的关键一步curl -X POST http://localhost:4000/api/ai/summarize \ -H Content-Type: application/json \ -H Authorization: Bearer 你的token \ -d {text:JavaScript 全栈开发中前后端分离架构需要统一 API 调用链路避免多套密钥管理带来的维护成本。}成功时返回{ summary: JavaScript 全栈开发采用前后端分离时统一 API 调用链路可降低多密钥维护成本。 }如果返回里summary有内容说明从 Express 到 TaoToken 再到模型的整条链路是通的。前端页面上在编辑页加一个「生成摘要」按钮调用/api/ai/summarize把返回填进 summary 字段保存即可。这样内容创建、AI 辅助、保存发布的闭环就完整了。前端调用示例// admin/src/pages/Editor.jsx 片段 import client from ../api/client; async function handleSummarize() { const { data } await client.post(/ai/summarize, { text: body }); setSummary(data.summary); }验证时建议按顺序截图或记录返回出问题时能快速定位是哪一层断的。下一节把常见报错整理出来对照排查。5. 常见报错排查401、local proxy failed、reading choices、OAuth 对照表排错的核心是看报错发生在哪一层。下面按真实遇到的报错逐个说。401 Unauthorized。两种可能一是 CMS 自己的 JWT 没带或过期检查请求头Authorization: Bearer是否存在、token 是否过期二是 TaoToken 的 Key 无效检查server/.env里TAOTOKEN_API_KEY是否复制完整、是否有多余空格。区分方法看报错来自/api/content还是/api/ai前者是 JWT 问题后者是 Key 问题。local proxy failed。这个通常出现在本地开发代理配置上。检查admin/vite.config.js里 proxy 的 target 是否指向http://localhost:4000后端是否真的在 4000 端口监听。如果后端换了端口proxy 也要同步改。另外前端请求路径要以/api开头否则代理规则不匹配。reading choices 相关报错。这类报错一般出现在解析模型返回时代码里按 OpenAI 格式取data.choices[0]但实际返回是 Anthropic 格式的data.content[0].text。解决办法是统一在aiService.js里做格式适配业务层只拿summary字段。如果你切换了模型返回结构可能变适配层要跟着改。OAuth 相关报错。如果你用 Claude Code 或 Codex 这类工具接入报 OAuth 错误通常是认证方式没选对。用 TaoToken 的 Key 接入时认证走的是 Bearer Token不是 OAuth 流程。检查配置里是否误开了 OAuth 模式Base URL 是否填成了官网地址而不是https://taotoken.net/api。模型不存在。报错信息里会带 model 字段检查TAOTOKEN_MODEL是否和文档里的 Model ID 完全一致大小写、连字符都要对上。超时。AI 请求默认 60 秒超时长文本可能不够。在aiService.js里把 timeout 调大或者在前端做分段请求。注意不要设成无限等待否则请求堆积会拖垮后端。CORS 报错。开发期前端 5173、后端 4000如果没走 Vite 代理而是直接请求后端会触发跨域。确认前端 axios 的 baseURL 是/api而不是http://localhost:4000/api让请求走代理。排查顺序建议先看健康检查再看 JWT再看 Key最后看模型返回格式。每层都有独立的验证命令不要跳步。把上面的报错对照表存下来下次遇到直接查。6. 链路跑通之后把统一 Key 用在长期编码与 Agent 场景内容增删改查和 AI 摘要跑通之后这套链路的价值不止于 CMS 本身。统一 Key 的设计让后续扩展变得简单想加标题润色就在aiService.js里加一个函数路由里加一个端点想换模型只改环境变量想接 Agent 做自动化内容处理复用同一个 Key 和 Base URL 即可。如果你打算长期做编码和 Agent 相关的工作可以了解下 Coding Plan它适合需要持续调用模型、做代码生成和自动化任务的场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content日常调试模型返回、对比不同模型效果可以用模型对话页面快速验证不用每次都写代码https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content接入过程中遇到具体问题查接入文档比到处搜更快文档里有完整的参数说明和示例https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content需要新建或管理 Key 时回到 API Keys 页面操作https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content最后给一个实用建议把aiService.js里的模型调用做成可配置的按任务类型映射不同 Model ID比如摘要用轻量模型、长文润色用强模型。这样在控制台调整额度分配时更灵活也不会因为某个模型限流影响整个 CMS。链路跑通只是开始把配置管理好后面加功能才不痛苦。