项目:react-travel | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
《15-接口全量参考》已经给出了全部 28 个 route.ts 文件的字段级速查表;本篇的分工是可执行性——为 10 个最高频接口给出"拿到就能调通"的完整请求/响应示例(JSON 逐字段给出)、错误分支(响应体原文)与 curl 命令,并把每个示例的取值来源交代清楚。示例值的构造口径:业务数据取自 prisma/seed-data/* 的真实种子(用户"旅行达人小王"、线路"三亚5日4晚自由行·亚特兰蒂斯水世界+蜈支洲岛"等,便于在灌过种子的环境里原样复现);动态值(token、订单号、时间戳)以「示意」标注——它们是运行时生成的,任何一次真实调用的值都不同,示例中只保证形状与长度规律与源码生成逻辑一致(如订单号 = 行 id 前缀 + 13 位毫秒时间戳 + 8 位 hex,见 lib/order.ts#genOrderNo)。
三个全局约定先读一遍,能省掉后面一半的解释。其一,响应三族(《15-接口全量参考》§1 定义):A 族 {status, ...}(认证/用户/订单/内容域,错误形态 {status, errMsg});B 族裸形状(/api/touristRoutes GET 的裸数组、购物车/结账单裸对象,分页元数据在 x-pagination 响应头);C 族幂等布尔(收藏/点赞)。判族方法看 URL 域即可,判错的典型症状是"200 却走了失败分支"。其二,时间字段统一为毫秒数字:Prisma 的 Date 经 lib/utils.ts#serialize 输出毫秒时间戳,BigInt 同样转 Number——客户端 new Date(毫秒) 还原即可。其三,鉴权头:受保护接口需 Authorization: Bearer <token>(scheme 大小写不敏感,lib/auth.ts#getTokenFromHeader 用 toLowerCase() 判定);两段式 401 的文案区分——验签失败"未登录或 token 失效"、Redis 比对失败"登录已失效"——是排障锚点,不要在客户端文案层合并。
以下 curl 均假定服务跑在 http://localhost:3000,并使用 $TOKEN 变量承接登录返回的 token。
注册走 /auth/register(旧前端路径,next.config.js#rewrites 透明转发),前端 client/views/register/RegisterForm.tsx 发送 { email, password, confirmPassword }——email 字段装的实为用户名,服务端以 body.username ?? body.email 兜底(app/api/auth/register/route.ts 第 17 行)。请求:
curl -s -X POST http://localhost:3000/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"新旅人小测","password":"abc12345","confirmPassword":"abc12345"}'{
"status": 200,
"id": 9,
"username": "新旅人小测",
"avatar": null,
"phone": null,
"sign": null,
"createTime": 1780700000000,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OSwidXNlcm5hbWUiOiLlpb3lv6DpgIk56aW5IiwiZXhwIjoxNzgxMzA0ODAwfQ.xxxx"
}字段说明:id 为 User 自增主键(示意值 9——种子灌入 8 个账号后的下一个 id);createTime 是 Date 经 serialize 的毫秒数(示意);token 是 HS256 JWT,payload 仅 {id, username}、有效期 JWT_EXPIRES_IN 默认 7 天(整串示意)。注册即登录:响应结构与登录完全一致(含 token),客户端拿到即可直接进入登录态。密码入库为 bcrypt 12 轮哈希(lib/services/user.ts#createUser 调 hashPassword),响应中不存在 password 字段(serializeUser 剥离)。错误分支(响应体原文,状态码齐全):缺字段或长度越界 400 "用户名长度需为 3-20 个字符" / "密码长度需为 6-20 个字符" / "两次密码不一致"(confirmPassword 传 null 时不校验,兼容旧客户端);重名 400 "用户已经存在"——实现是预查 + P2002 双保险(并发注册同名时 unique 约束兜底,同文案);限流 429 "请求过于频繁,请 Ns 后重试" + Retry-After 头(IP 维度 8 次/分钟)。
请求与注册同路径风格,字段名同样错位(email 装用户名):
curl -s -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"旅行达人小王","password":"123456"}'{
"status": 200,
"id": 1,
"username": "旅行达人小王",
"avatar": "/travel-img/1535713875002-d1d0cf377fde.webp",
"phone": "13800138001",
"sign": "走过30个国家,用脚步丈量世界 🌍",
"createTime": 1757000000000,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiLlvKDkuIrlpKrljYDlpb3lb3ciLCJpYXQiOjE3ODA3MDAwMDAsImV4cCI6MTc4MTMwNDgwMH0.xxxx"
}avatar/phone/sign 三字段取自 prisma/seed-data/users.ts 第 1 条种子(真实值);createTime 与 token 为示意。处理链:authRateLimit(login:<用户名>:<IP> 双维桶,8 次/分钟)→ getUserByUsername → verifyPassword 双轨校验(bcrypt 前缀 $2 分流 md5;用户不存在时比对 DUMMY_BCRYPT_HASH 对齐耗时,《18-代码走读实录》§2.2)→ signToken → cacheToken(Redis 可选)。错误分支:缺字段 400 "用户名和密码必填";凭据错 401 "用户名或密码错误"(用户不存在与密码错误同文案同时序);限流 429。集成提醒:客户端判断失败用 HTTP 状态码或 errMsg 字段,不要用 data.status !== 200 以外的启发式——登录响应是"用户字段平铺在顶层"的 A 族变形,与 user/edit 的 data 包裹形不同(《15》§3 已记录该活化石)。
搜索是全站 QPS 最高的接口,也是 B 族形状的锚点——body 是纯数组,分页元数据在响应头:
curl -s -i 'http://localhost:3000/api/touristRoutes?keyword=%E4%B8%89%E4%BA%9A&pageNumber=1&pageSize=2&sortBy=price-asc'响应头(示意)与响应体:
HTTP/1.1 200 OK
content-type: application/json
x-pagination: {"currentPage":1,"pageSize":2,"totalCount":5,"totalPages":3}[
{
"id": 1,
"title": "三亚5日4晚自由行·亚特兰蒂斯水世界+蜈支洲岛",
"description": "入住海棠湾五星酒店,含双早+接机。畅玩亚特兰蒂斯水世界,登蜈支洲岛浮潜",
"price": 3299,
"originalPrice": 4599,
"discountPresent": 28,
"rating": 4.8,
"travelDays": "5",
"tripType": "自由行",
"departureCity": "全国",
"touristRoutePictures": [
{ "id": 1, "url": "/travel-img/1559628233-100c798642d4.webp" },
{ "id": 2, "url": "/travel-img/1507525428034-b723cf961d3e.webp" }
]
}
]取值来源:id 1 的线路是 prisma/seed-data/routes.ts 第 1 条(name/info/price/originalPrice/travelDays/tripType/departureCity/rating 全为真实种子值,示例裁剪图库至 2 张——实际每线路 4 张);discountPresent 由 lib/vm.ts#houseToTouristRoute 计算:Math.round((1 - 3299/4599) * 100) = 28(非种子存储值,示意但可精确复算);travelDays 是数字转字符串(VM 约定)。参数语义:keyword 走 buildWhere 的五字段 OR 大小写不敏感 contains(name/info/address/departureCity + tripType equals,lib/services/house.ts);sortBy 白名单四值(recommended/price-asc/price-desc/newest),非法值静默回落 recommended;pageNumber ?? pageNum 双名兼容;pageSize 钳 [1,100]。错误分支:本接口无 4xx——所有参数异常都被收敛为默认值或空数组,这是 B 族"宽容查询"的设计取向;500 仅在 DB 故障时出现(脱敏文案)。注意 POST 形状({status, data} 包装、库行投影、无 x-pagination 头)与 GET 不是同一契约,新集成一律走 GET(《15》§4 域走读要点)。
curl -s http://localhost:3000/api/touristRoutes/1{
"id": 1,
"title": "三亚5日4晚自由行·亚特兰蒂斯水世界+蜈支洲岛",
"description": "入住海棠湾五星酒店,含双早+接机。畅玩亚特兰蒂斯水世界,登蜈支洲岛浮潜",
"price": 3299,
"originalPrice": 4599,
"discountPresent": 28,
"rating": 4.8,
"travelDays": "5",
"tripType": "自由行",
"departureCity": "全国",
"touristRoutePictures": [
{ "id": 1, "url": "/travel-img/1559628233-100c798642d4.webp" },
{ "id": 2, "url": "/travel-img/1507525428034-b723cf961d3e.webp" },
{ "id": 3, "url": "/travel-img/1519046904884-53103b34b206.webp" },
{ "id": 4, "url": "/travel-img/1540202404-a2f29016b523.webp" }
],
"features": "<h3>行程亮点</h3><ul><li>海棠湾五星酒店步行5分钟到沙滩</li>...</ul><h3>参考行程</h3><p><b>Day1</b> 抵达→接机→沙滩日落</p>...",
"fees": "<h3>费用包含</h3><ul><li>4晚五星酒店含双早</li>...</ul><h3>费用不含</h3><ul><li>往返大交通</li>...</ul>",
"notes": "<p>1. 携带身份证,儿童需户口本</p><p>2. 水上项目需身体健康</p>...",
"coupons": null,
"points": null
}features/fees/notes 三个富文本字段来自种子数据(示例截断展示,全文按 F()/FE()/N() 模板生成于 prisma/seed-data/routes.ts),出 API 前已过 sanitizeRichText 白名单净化(lib/vm.ts 出口统一消毒),前端用 dangerouslySetInnerHTML 渲染是安全的;若字段为空,VM 层用 info/address/价格拼兜底段落。coupons/points 恒为 null——imooc 形状预留槽位,当前无数据源。副作用:每次调用按"房源+IP+自然日"去重自增 showCount(同 IP 当日重复调用不计数,压测需知悉,《15》§4);无 showCount 字段返回——详情 VM 不含它。错误分支:400 "无效的房源 id"(非正整数);404 "房源不存在"(id 合法但查无,或 P2025 路径)。
受保护写接口三件套(requireAuth → 无业务限流 → 参数校验)后进入 addCartItem(advisory lock 事务,《18-代码走读实录》§3.1)。响应是操作后的最新全量购物车,而非"本次操作的对象":
curl -s -X POST http://localhost:3000/api/shoppingCart/items \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"touristRouteId": 1}'{
"shoppingCartItems": [
{
"id": 3,
"originalPrice": 3299,
"discountPresent": null,
"touristRoute": {
"id": 1,
"title": "三亚5日4晚自由行·亚特兰蒂斯水世界+蜈支洲岛",
"description": "入住海棠湾五星酒店,含双早+接机。畅玩亚特兰蒂斯水世界,登蜈支洲岛浮潜",
"price": 3299,
"originalPrice": 4599,
"discountPresent": 28,
"rating": 4.8,
"travelDays": "5",
"tripType": "自由行",
"departureCity": "全国",
"touristRoutePictures": [{ "id": 1, "url": "/travel-img/1559628233-100c798642d4.webp" }]
}
}
]
}字段语义需精确区分两层:外层的 id: 3 是 Orders 行 id(购物车条目的身份,删除与支付都用它),originalPrice 优先取下单时点快照 Orders.amount(本例刚加购即快照价 3299),discountPresent: null 是购物车条目的固有形态(前端有 ?: 1 兜底);内层 touristRoute 是完整 VM(服务端裁剪为展示所需,实际含 features/fees/notes——示例截断)。错误分支:400 "touristRouteId 必填";401 两段式文案;500 的两态——"房源不存在"(唯一白名单透传的业务文案)与其余一切异常的脱敏文案 "加入购物车失败";重复加购不报错(查重幂等,返回最新列表,第二件的响应与首件可能完全相同——判断是否真的加上了要用 GET 核对,勿以响应体差异推断)。
结算不落库,是纯视图转换(ordersToCheckoutOrder)。假设购物车有行 3/5/7:
curl -s -X POST http://localhost:3000/api/shoppingCart/checkout \
-H "Authorization: Bearer $TOKEN"{
"id": "3-5-7",
"orderNumber": "",
"state": "StateProcessing",
"orderItems": [
{
"id": 3,
"originalPrice": 3299,
"discountPresent": 1,
"touristRoute": { "title": "三亚5日4晚自由行·亚特兰蒂斯水世界+蜈支洲岛" }
},
{
"id": 5,
"originalPrice": 2880,
"discountPresent": 1,
"touristRoute": { "title": "云南大理+丽江6日跟团游·洱海环游+玉龙雪山" }
},
{
"id": 7,
"originalPrice": 1580,
"discountPresent": 1,
"touristRoute": { "title": "西安3日2晚周末游·兵马俑+华清宫+回民街" }
}
]
}三个哨兵值是本契约的关键:id: "3-5-7" 是各行 Orders id 用 - 连接的复合串(支付时原样回传拆回);orderNumber: "" 是支付前哨兵(行尚无订单号,rows[0]?.orderNumber ?? "");state: "StateProcessing" 与 discountPresent: 1 是给前端状态机与 CheckOutCard 消费的固定值("CheckOutCard 直接相乘,不能为 null"——lib/vm.ts 注释)。orderItems[].touristRoute 实际是完整 VM(示例仅展示 title——CheckOutCard 只消费 title/originalPrice/discountPresent)。错误分支:400 "购物车为空"(空车结算);401。集成提醒:state 与 discountPresent 的取值联动 app/orderSuccess/page.tsx 的 Completed 判定,改动任一侧需双侧同步。
支付主链路(imooc 形状):路径段是复合 id,Handler 按 [-,] 拆回 [3,5,7] 交给 payRows 五步事务(《18-代码走读实录》§3.2):
curl -s -X POST http://localhost:3000/api/orders/3-5-7/placeOrder \
-H "Authorization: Bearer $TOKEN"{
"id": "3-5-7",
"orderNumber": "31780700000000a1b2c3d4",
"state": "Completed",
"orderItems": [
{
"id": 3,
"originalPrice": 3299,
"discountPresent": 1,
"touristRoute": { "title": "三亚5日4晚自由行·亚特兰蒂斯水世界+蜈支洲岛" }
}
]
}与结算响应的差异只有两处:state 翻转为 "Completed"、orderNumber 填入生成值——genOrderNo 的形状是"首行 id 前缀 + 13 位毫秒时间戳 + 8 位 hex"(示例 3 + 1780700000000 + a1b2c3d4,整串示意),同批支付行共享一个号。幂等语义:重复支付同一复合 id 返回 200 且订单号不变(unpaid 过滤后全部已支付 → 直接回读现有行)。错误分支:400 "无效的订单 id"(拆分后空数组,如路径为 a-b);404 "订单不存在或已完成支付"——注意这条 404 合并了两种成因:id 真不存在/非本人行,以及"结账页停留期间行已被支付或购物车已清空"的正常业务分支(app/api/orders/[id]/placeOrder/route.ts 第 24 行注释),客户端不应把该 404 当系统故障上报;其余异常 500 脱敏。支付成功后行 isPayed=1,自动从购物车消失(购物车即 isPayed=0 行的查询语义)。
评论列表是唯一的"公开读+可选 token"端点——永不 401:token 有效时逐条标注 likedByMe,无效/缺失时退化为游客视角(app/api/comments/[houseId]/route.ts 只 verifyToken 不拒):
curl -s 'http://localhost:3000/api/comments/1?pageNum=1&pageSize=2' -H "Authorization: Bearer $TOKEN"{
"status": 200,
"data": [
{
"id": 101,
"msg": "亚特兰蒂斯水世界太值了,孩子玩了一天不想走!",
"createTime": 1780600000000,
"user": { "avatar": "/travel-img/1438761681033-6461ffad8d80.webp", "username": "亲子游妈妈" },
"likeCount": 3,
"likedByMe": true,
"replies": [
{
"id": 102,
"msg": "请问酒店到水世界要买票吗?",
"createTime": 1780605000000,
"user": { "avatar": "/travel-img/1507003211169-0a1dd7228f2d.webp", "username": "背包客老李" },
"likeCount": 0,
"likedByMe": false,
"replies": []
}
]
}
]
}CommentVM 形状(lib/services/comment.ts#toVM)的三个隐私/性能语义:likeCount 只暴露总数不暴露点赞用户列表;likedByMe 由"带 userId 的 likes 明细查询"判定,未登录时服务端根本不查这张表(省一次 join,likedByMe 恒 false);replies 查询层硬上限 20 条(防御性值非业务规则)。createTime 为毫秒数;id/msg 示意(结构精确)。发评论是 requireAuth + 20 次/分钟限流 + stripHtml 三道闸:
curl -s -X POST http://localhost:3000/api/comments \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"houseId": 1, "comment": "行程安排合理,下次还来"}'{ "status": 200, "data": { "id": 201, "msg": "行程安排合理,下次还来", "createTime": 1780700000000, "user": { "avatar": "/travel-img/1535713875002-d1d0cf377fde.webp", "username": "旅行达人小王" } } }错误分支(发评论):400 "houseId 和评论内容必填"(msg 经 stripHtml 后为空也算缺——纯 <img> 注入得到空串被拦);500 "评论失败(房源不存在或数据异常)"。内容安全的取值示例:提交 comment: "<script>alert(1)</script>真不错" 的实际入库值是 "真不错"(标签剥净),提交 "面包&黄油" 入库仍是 "面包&黄油"——decodeEntities 还原实体防双重转义(《08-安全与权限设计》§4)。
# 点赞
curl -s -X POST http://localhost:3000/api/commentLikes \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"commentId": 101}'
# 取消点赞(DELETE 带 body 是刻意的 imooc 兼容约定)
curl -s -X DELETE http://localhost:3000/api/commentLikes \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"commentId": 101}'{ "status": 200, "data": { "liked": true } }{ "status": 200, "data": { "liked": false } }幂等矩阵是本契约的全部精髓:重复点赞(P2002 唯一约束冲突)返回 200 {liked: true, msg: "已点赞"};未点赞时取消(P2025)返回 200 {liked: false}——四种"操作 × 重复状态"组合全部 200,客户端无需任何重复判断逻辑(前端乐观更新 + 失败回滚即可放心连点)。错误分支:400 "缺少 commentId";500 "点赞失败" / "取消点赞失败"(后者会在服务端留 [api:commentLikes:DELETE] 痕迹——注释强调"不能静默吞掉让用户误以为已取消")。收藏(/api/favorites POST/DELETE)是同构契约,把 liked 换成 favorited、commentId 换成 houseId 即可类推(《15》§8)。
全站错误响应统一为 {status, errMsg} 两字段(lib/utils.ts#errMsg/apiError),按状态码的全景速查:
{ "status": 400, "errMsg": "用户名和密码必填" }{ "status": 401, "errMsg": "未登录或 token 失效" }{ "status": 429, "errMsg": "请求过于频繁,请 37s 后重试" }{ "status": 500, "errMsg": "服务器内部错误,请稍后重试" }四条的触发条件与附加信息:400 是参数/业务校验失败(errMsg 恒为可直接展示的中文);401 两段文案见 §1(middleware 与 requireAuth 文案刻意一致);429 附 Retry-After 头(秒数整数,与 errMsg 里的数字同源,限流窗口归零即恢复);500 一律脱敏(内部异常只进服务端 [api:500] 日志),客户端不要尝试解析 500 的更多语义。客户端侧的统一处理协议(《15》§11 已述,此处给执行序):401 → 清本地登录态跳 /signin(useCartStore 的 401 联动登出是标准实现);429 → 读 Retry-After 提示等待;400/404 → 展示 errMsg;网络层异常 → e?.response?.data?.errMsg || e?.message 双兜底。最后一条调用方速记:本篇 10 个契约覆盖了前端 7 个 store 的全部请求形状锚点(搜索裸数组、详情裸对象、shoppingCartItems、复合 id 结账单、CommentVM、幂等布尔),对接新客户端时按"先判族、再对字段、最后对错误分支"三步走,并与《15-接口全量参考》的对应域表格交叉核对——两篇对同一接口的描述若出现分歧,以源码为准并回报文档维护者。