
1. 从零搭一个草药接口为什么分页排序和分类查询最容易翻车如果你正在用 Node.js Express Mongoose 写第一个带数据库的接口大概率会遇到这样一个场景前端要一个列表页既要按分类筛选又要分页还要能按名称排序。听起来就是三个参数的事但真正写起来坑一个接一个——总数算错了、翻到第二页数据重复了、分类传空字符串结果查不到数据、排序字段传错类型直接报错。这篇就围绕一个「草药列表」的实战项目把增删改查、分类查询、分页和排序完整走一遍。核心检索词是 Node.js Express Mongoose 增删改查适合刚入门后端、想搞清楚路由分层和模型设计的同学。我会给出可以直接复制的 Schema、路由、控制器代码然后用 curl 逐项验证分页参数、排序字段和分类过滤的结果。先说清楚这个项目能做什么一个草药管理接口支持新增草药、删除草药、修改草药、根据 ID 查详情、按分类查列表列表支持分页和按名称排序。技术栈就是 Express 做路由、Mongoose 做模型和数据库操作、MongoDB 存数据。适合谁适合已经会写app.get(/)但还没系统整理过接口分层的人。我试过把分页逻辑写在路由里结果路由文件越来越长后来拆成 routes controllers models 三层维护起来舒服很多。下面按这个结构来。先看目录结构这是后面所有代码的落点project/ ├── app.js ├── models/ │ └── herbals.js ├── controllers/ │ └── herbals.js └── routes/ └── herbals.jsapp.js负责启动服务和挂载路由models放 Schemacontrollers放业务逻辑routes只做路径映射。这样分层之后分页、排序、分类过滤这些逻辑都集中在 controller 里改起来不会牵一发动全身。2. TaoToken 前置准备把模型调用和接口调试串起来写接口的过程中有两件事经常需要外部工具帮忙一是调试请求二是如果你想让接口里接入大模型能力比如自动生成草药描述需要一个稳定的模型调用入口。TaoToken 在这里的角色就是后者——它提供统一的 API 入口兼容常见的模型调用格式你可以在 Node.js 里直接发请求。先说清楚它是什么TaoToken 是一个模型调用服务平台提供 API 接口支持对话、编码等场景。适合谁适合需要在后端项目里集成模型能力、又不想自己维护多套 SDK 的开发者。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。如果你只是做本文的增删改查其实不接模型也能跑通。但如果你想让「新增草药」的时候自动补一段描述就可以在 controller 里加一个模型调用。下面给出前置准备步骤。第一步拿到 API Key。进入控制台页面 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建一个 Key。这个 Key 后面要放到环境变量里不要硬编码进代码。第二步确认你要用的模型 ID。不同场景对应不同模型对话类可以去模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 看可用列表。如果你要做长期编码或 Agent 类任务可以了解 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。第三步在项目根目录建一个.env文件把 Key 和 Base URL 写进去TAOTOKEN_API_KEY你的Key TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在app.js里用dotenv加载require(dotenv).config();这样后面 controller 里要用的时候直接process.env.TAOTOKEN_API_KEY就能取到。注意 Base URL 用 https://taotoken.net/api 不要加多余的路径。如果你用的是 Claude Code 这类工具做辅助开发可以参考接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里的配置说明。API Keys 管理页面在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。前置准备到这里就够了。核心还是接口本身模型调用是可选增强。下面进入正题。3. 可复制配置Schema、路由分层与分页排序控制器这一节是全文的技术核心给出可以直接复制的配置。先建模型。3.1 模型设计models/herbals.jsconst mongoose require(mongoose); const Schema mongoose.Schema; const herbalSchema new Schema({ name: { type: String, required: true }, herbalTypeId: { type: String, default: }, description: { type: String, default: }, cover: { type: String, default: }, details: { type: String, default: } }, { timestamps: true }); module.exports mongoose.model(Herbal, herbalSchema);这里比原始版本多了required和default避免字段缺失导致查询时出现 undefined。timestamps会自动加createdAt和updatedAt排序时多一个选择。3.2 数据库连接app.jsconst express require(express); const mongoose require(mongoose); require(dotenv).config(); const herbalRouter require(./routes/herbals); const app express(); app.use(express.json()); mongoose.connect(mongodb://127.0.0.1:27017/herbal); mongoose.connection.on(connected, () { console.log(数据库连接成功); }); mongoose.connection.on(error, (err) { console.log(连接失败, err.message); }); mongoose.connection.on(disconnected, () { console.log(连接断开); }); app.use(/herbals, herbalRouter); app.listen(3000, () { console.log(服务启动在 3000 端口); });注意express.json()必须加否则 POST 和 PUT 的req.body是空的这是新手最常见的坑之一。3.3 路由层routes/herbals.jsconst express require(express); const router express.Router(); const controller require(../controllers/herbals); router.get(/, controller.getList); router.get(/detail, controller.getDetail); router.post(/, controller.create); router.put(/, controller.update); router.delete(/, controller.remove); module.exports router;路由层只做映射不写业务逻辑。这样后面加中间件、加权限校验都很方便。3.4 控制器controllers/herbals.js这是分页、排序、分类查询的核心。const Herbals require(../models/herbals); // 列表分类查询 分页 排序 exports.getList async (req, res) { try { let pn parseInt(req.query.pn) || 1; let ps parseInt(req.query.ps) || 10; let herbalTypeId req.query.herbalTypeId; let sort parseInt(req.query.sort) || 1; if (pn 1) pn 1; if (ps 1) ps 10; const skip (pn - 1) * ps; let params {}; if (herbalTypeId herbalTypeId ! 0 herbalTypeId ! ) { params.herbalTypeId herbalTypeId; } const count await Herbals.countDocuments(params); const pageCount Math.ceil(count / ps); const pageList await Herbals.find(params) .sort({ name: sort }) .skip(skip) .limit(ps); res.json({ code: 0, message: , data: { pageCount, pageNum: pn, total: count, pageList } }); } catch (err) { res.json({ code: 1, message: err.message }); } }; // 详情 exports.getDetail async (req, res) { try { const id req.query.id; const doc await Herbals.findById(id); if (!doc) { return res.json({ code: 1, message: 未找到该草药 }); } res.json({ code: 0, message: , result: doc }); } catch (err) { res.json({ code: 1, message: err.message }); } }; // 新增 exports.create async (req, res) { try { const newHerbal { name: req.body.name, herbalTypeId: req.body.herbalTypeId, description: req.body.description, cover: req.body.cover, details: req.body.details }; await Herbals.create(newHerbal); res.json({ code: 0, msg: 添加成功, result: {} }); } catch (err) { res.json({ code: 1, msg: err.message }); } }; // 修改 exports.update async (req, res) { try { const condition { _id: req.body.id }; const query { $set: { name: req.body.name, herbalTypeId: req.body.herbalTypeId, description: req.body.description, cover: req.body.cover, details: req.body.details } }; await Herbals.updateOne(condition, query); res.json({ code: 0, msg: 修改成功, result: {} }); } catch (err) { res.json({ code: 1, msg: err.message }); } }; // 删除 exports.remove async (req, res) { try { await Herbals.deleteOne({ _id: req.body.id }); res.json({ code: 0, msg: 删除成功, result: {} }); } catch (err) { res.json({ code: 1, msg: err.message }); } };几个关键点说明。第一分页用countDocuments单独查总数不要像原始版本那样先find再取doc.length数据量大时后者会把整表拉进内存。第二sort参数用parseInt转成数字Mongoose 的sort({ name: 1 })里 1 是升序、-1 是降序传字符串会出问题。第三分类参数做了空值判断herbalTypeId为0、空字符串或 undefined 时都不加过滤条件返回全部。如果你需要把模型调用接进来比如新增时自动生成描述可以在create里加一段const axios require(axios); async function generateDescription(name) { const resp await axios.post( ${process.env.TAOTOKEN_BASE_URL}/v1/chat/completions, { model: 你的模型ID, messages: [ { role: user, content: 用一句话描述草药${name} } ] }, { headers: { Authorization: Bearer ${process.env.TAOTOKEN_API_KEY}, Content-Type: application/json } } ); return resp.data.choices[0].message.content; }这段是可选的不影响增删改查主流程。模型 ID 去模型对话页面确认。4. 验证请求用 curl 逐项测分页、排序和分类过滤代码写完必须验证。下面用 curl 逐项测。假设服务跑在localhost:3000。先插几条测试数据curl -X POST http://localhost:3000/herbals \ -H Content-Type: application/json \ -d {name:人参,herbalTypeId:1,description:补气,cover:,details:} curl -X POST http://localhost:3000/herbals \ -H Content-Type: application/json \ -d {name:黄芪,herbalTypeId:1,description:补气固表,cover:,details:} curl -X POST http://localhost:3000/herbals \ -H Content-Type: application/json \ -d {name:当归,herbalTypeId:2,description:补血,cover:,details:}测分页取第一页每页两条curl http://localhost:3000/herbals?pn1ps2返回里pageCount应该是 2pageList长度是 2total是 3。再取第二页curl http://localhost:3000/herbals?pn2ps2pageList长度应该是 1。如果两页数据有重复检查skip计算是不是(pn - 1) * ps。测排序按名称降序curl http://localhost:3000/herbals?pn1ps10sort-1返回的pageList里名称应该从大到小排列。升序把sort改成1。测分类过滤只看herbalTypeId1curl http://localhost:3000/herbals?pn1ps10herbalTypeId1total应该是 2pageList里只有人参和黄芪。传空分类curl http://localhost:3000/herbals?pn1ps10herbalTypeId应该返回全部 3 条。测详情、修改、删除# 详情把 ID 换成实际返回的 _id curl http://localhost:3000/herbals/detail?id你的ID # 修改 curl -X PUT http://localhost:3000/herbals \ -H Content-Type: application/json \ -d {id:你的ID,name:人参改,herbalTypeId:1} # 删除 curl -X DELETE http://localhost:3000/herbals \ -H Content-Type: application/json \ -d {id:你的ID}每步都看返回的code字段0是成功1是失败失败时message会带原因。用 Postman 的话把上面的 URL 和 body 对应填进去就行注意 POST/PUT 选rawJSON。验证通过的标准分页总数对、翻页不重复、排序方向对、分类过滤准、增删改查都有正确返回。这五项都过了接口就算跑通了。5. 本篇常见错排查401、local proxy failed、reading choices 这些报错怎么解接口跑起来之后报错是常态。这一节把几个高频错误对照着说。401 Unauthorized。如果你在 controller 里接了模型调用这个错误基本是 Key 的问题。检查.env里的TAOTOKEN_API_KEY有没有值请求头是不是Authorization: Bearer 你的Key。注意 Bearer 后面有一个空格。如果 Key 是从控制台复制的确认没有多余换行。API Keys 管理页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 可以重新生成。local proxy failed。这个报错通常出现在你本地网络环境有额外代理设置的时候。先检查系统环境变量里有没有HTTP_PROXY、HTTPS_PROXY如果有临时清掉再试。Node.js 里 axios 默认会读这些环境变量可以在请求配置里显式设置proxy: falseconst resp await axios.post(url, data, { headers: { ... }, proxy: false });reading choices。这个报错是resp.data.choices取不到说明返回结构和你预期的不一样。先打印完整返回console.log(JSON.stringify(resp.data, null, 2));常见原因是模型 ID 写错了或者请求体格式不对。确认model字段用的是模型对话页面里列出的 IDmessages是数组且每个元素有role和content。OAuth 相关报错。如果你用 Claude Code 或类似工具接入遇到 OAuth 报错检查接入文档里的配置项。Base URL 用 https://taotoken.net/api Key 和 Model ID 三件套要齐全。Claude Code 的配置可以参考 https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。Mongoose 报 CastError。比如Cast to ObjectId failed for value xxx说明你传的 ID 不是合法的 ObjectId。检查req.query.id或req.body.id是不是空字符串或格式不对。可以在查询前加判断const mongoose require(mongoose); if (!mongoose.Types.ObjectId.isValid(id)) { return res.json({ code: 1, message: ID 格式不正确 }); }分页返回 pageCount 为 0。检查countDocuments的查询条件是不是和find一致。如果分类参数处理逻辑在两处写得不一样就会出现总数和列表对不上的情况。把params抽成一个函数两处共用。排序不生效。检查sort参数有没有被parseInt转成数字。如果传的是字符串1Mongoose 可能不按预期排序。另外sort({ name: sort })里的sort必须是 1 或 -1其他值会被忽略。POST 请求 req.body 为空。九成是没加app.use(express.json())或者 Postman 里没选 JSON 格式。检查中间件顺序express.json()要在路由挂载之前。这些错误覆盖了大部分新手会遇到的场景。遇到报错先看message再去对照上面的条目基本能定位。6. 语义一致 CTA把接口调试和模型调用接起来接口跑通之后下一步通常是两件事一是把调试流程固定下来二是按需接入模型能力。调试流程方面建议把常用的 curl 命令存成一个test.sh每次改完代码跑一遍比手动点 Postman 快。分页、排序、分类这三个参数组合起来测确保边界情况都覆盖到。模型调用方面如果你要在项目里集成对话或编码能力可以从模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 选合适的模型然后在 controller 里按第 3 节的示例发请求。长期做编码或 Agent 类任务的话Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 会更合适。接入细节看文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite Key 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。最后说一个实用技巧把分页参数封装成一个工具函数pn、ps、sort的默认值和边界处理都放进去controller 里只调一次。这样以后加新的列表接口直接复用不用每次重写一遍分页逻辑。接口分层的好处就在这里——模型、路由、控制器各管各的改一处不影响其他。