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放 Schema,controllers放业务逻辑,routes只做路径映射。这样分层之后,分页、排序、分类过滤这些逻辑都集中在 controller 里,改起来不会牵一发动全身。
2. TaoToken 前置准备:把模型调用和接口调试串起来
写接口的过程中,有两件事经常需要外部工具帮忙:一是调试请求,二是如果你想让接口里接入大模型能力(比如自动生成草药描述),需要一个稳定的模型调用入口。TaoToken 在这里的角色就是后者——它提供统一的 API 入口,兼容常见的模型调用格式,你可以在 Node.js 里直接发请求。
先说清楚它是什么:TaoToken 是一个模型调用服务平台,提供 API 接口,支持对话、编码等场景。适合谁?适合需要在后端项目里集成模型能力、又不想自己维护多套 SDK 的开发者。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
如果你只是做本文的增删改查,其实不接模型也能跑通。但如果你想让「新增草药」的时候自动补一段描述,就可以在 controller 里加一个模型调用。下面给出前置准备步骤。
第一步,拿到 API Key。进入控制台页面 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,创建一个 Key。这个 Key 后面要放到环境变量里,不要硬编码进代码。
第二步,确认你要用的模型 ID。不同场景对应不同模型,对话类可以去模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 看可用列表。如果你要做长期编码或 Agent 类任务,可以了解 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
第三步,在项目根目录建一个.env文件,把 Key 和 Base URL 写进去:
TAOTOKEN_API_KEY=你的Key TAOTOKEN_BASE_URL=https://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_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里的配置说明。API Keys 管理页面在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
前置准备到这里就够了。核心还是接口本身,模型调用是可选增强。下面进入正题。
3. 可复制配置:Schema、路由分层与分页排序控制器
这一节是全文的技术核心,给出可以直接复制的配置。先建模型。
3.1 模型设计:models/herbals.js
const 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.js
const 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.js
const 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?pn=1&ps=2"返回里pageCount应该是 2,pageList长度是 2,total是 3。再取第二页:
curl "http://localhost:3000/herbals?pn=2&ps=2"pageList长度应该是 1。如果两页数据有重复,检查skip计算是不是(pn - 1) * ps。
测排序,按名称降序:
curl "http://localhost:3000/herbals?pn=1&ps=10&sort=-1"返回的pageList里名称应该从大到小排列。升序把sort改成1。
测分类过滤,只看herbalTypeId=1:
curl "http://localhost:3000/herbals?pn=1&ps=10&herbalTypeId=1"total应该是 2,pageList里只有人参和黄芪。传空分类:
curl "http://localhost:3000/herbals?pn=1&ps=10&herbalTypeId="应该返回全部 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 选raw+JSON。
验证通过的标准:分页总数对、翻页不重复、排序方向对、分类过滤准、增删改查都有正确返回。这五项都过了,接口就算跑通了。
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_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 可以重新生成。
local proxy failed。这个报错通常出现在你本地网络环境有额外代理设置的时候。先检查系统环境变量里有没有HTTP_PROXY、HTTPS_PROXY,如果有,临时清掉再试。Node.js 里 axios 默认会读这些环境变量,可以在请求配置里显式设置proxy: false:
const resp = await axios.post(url, data, { headers: { ... }, proxy: false });reading 'choices'。这个报错是resp.data.choices取不到,说明返回结构和你预期的不一样。先打印完整返回:
console.log(JSON.stringify(resp.data, null, 2));常见原因是模型 ID 写错了,或者请求体格式不对。确认model字段用的是模型对话页面里列出的 ID,messages是数组且每个元素有role和content。
OAuth 相关报错。如果你用 Claude Code 或类似工具接入,遇到 OAuth 报错,检查接入文档里的配置项。Base URL 用 https://taotoken.net/api ,Key 和 Model ID 三件套要齐全。Claude Code 的配置可以参考 https://taotoken.net/claude-code?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite 。
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转成数字。如果传的是字符串"1",Mongoose 可能不按预期排序。另外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_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 选合适的模型,然后在 controller 里按第 3 节的示例发请求。长期做编码或 Agent 类任务的话,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 会更合适。接入细节看文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
最后说一个实用技巧:把分页参数封装成一个工具函数,pn、ps、sort的默认值和边界处理都放进去,controller 里只调一次。这样以后加新的列表接口,直接复用,不用每次重写一遍分页逻辑。接口分层的好处就在这里——模型、路由、控制器各管各的,改一处不影响其他。