articles.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800
  1. const express = require('express');
  2. const router = express.Router();
  3. const {Article, Category} = require('../../models')
  4. const {Op} = require('sequelize')
  5. /**
  6. * @api {get} /admin/articles 查询文章列表
  7. * @apiName GetArticles
  8. * @apiGroup Articles
  9. * @apiVersion 1.0.0
  10. *
  11. * @apiDescription 获取文章列表,支持分页和多条件筛选
  12. *
  13. * @apiParam {Number} [currentPage=1] 当前页码,默认为1
  14. * @apiParam {Number} [pageSize=10] 每页显示数量,默认为10
  15. * @apiParam {String} [title] 标题搜索关键词(模糊匹配)
  16. * @apiParam {String|Array} [cropIds] 作物筛选ID,支持逗号分隔的多个ID或数组形式
  17. * @apiParam {Number} [categoryId] 用户分类ID,根据用户传递的category参数值进行精确匹配
  18. * @apiParam {Number} [isRecommended] 推荐筛选,0-非推荐文章,1-推荐文章
  19. * @apiParam {Number} [topPosition] 置顶筛选,0-非置顶文章,1-置顶文章
  20. * @apiParam {String|Array} [newsTypeIds] 文章类型筛选ID,支持逗号分隔的多个ID或数组形式,根据type字段进行筛选
  21. *
  22. * @apiSuccess {Boolean} status 请求状态
  23. * @apiSuccess {String} message 响应消息
  24. * @apiSuccess {Object} data 响应数据
  25. * @apiSuccess {Array} data.articles 文章列表
  26. * @apiSuccess {Number} data.articles.id 文章ID
  27. * @apiSuccess {String} data.articles.title 文章标题
  28. * @apiSuccess {String} [data.articles.subtitle] 文章副标题
  29. * @apiSuccess {String} data.articles.content 文章内容
  30. * @apiSuccess {Number} data.articles.type 文章类型
  31. * @apiSuccess {String} [data.articles.img] 文章图片
  32. * @apiSuccess {Date} [data.articles.date] 文章日期
  33. * @apiSuccess {String} [data.articles.author] 作者
  34. * @apiSuccess {Number} data.articles.category 用户分类ID
  35. * @apiSuccess {Number} data.articles.crop 作物分类ID
  36. * @apiSuccess {Number} data.articles.isRecommended 是否推荐(0-不推荐,1-推荐)
  37. * @apiSuccess {Number} data.articles.topPosition 是否置顶(0-不置顶,1-置顶)
  38. * @apiSuccess {String} [data.articles.seoKeyword] SEO关键词
  39. * @apiSuccess {String} [data.articles.seoDescription] SEO描述
  40. * @apiSuccess {Date} data.articles.createdAt 创建时间
  41. * @apiSuccess {Date} data.articles.updatedAt 更新时间
  42. * @apiSuccess {Object} [data.articles.cropInfo] 作物信息
  43. * @apiSuccess {Number} data.articles.cropInfo.id 作物ID
  44. * @apiSuccess {String} data.articles.cropInfo.name 作物名称
  45. * @apiSuccess {Number} data.articles.cropInfo.level 作物层级
  46. * @apiSuccess {Number} data.articles.cropInfo.parentId 父级作物ID
  47. * @apiSuccess {Object} data.pagination 分页信息
  48. * @apiSuccess {Number} data.pagination.total 总数量
  49. * @apiSuccess {Number} data.pagination.currentPage 当前页码
  50. * @apiSuccess {Number} data.pagination.pageSize 每页数量
  51. *
  52. * @apiSuccessExample {json} Success-Response:
  53. * HTTP/1.1 200 OK
  54. * {
  55. * "status": true,
  56. * "message": "成功",
  57. * "data": {
  58. * "articles": [
  59. * {
  60. * "id": 96,
  61. * "title": "测试文章",
  62. * "subtitle": "副标题",
  63. * "content": "<p>文章内容</p>",
  64. * "type": 1,
  65. * "img": null,
  66. * "date": null,
  67. * "author": "作者",
  68. * "category": 1,
  69. * "crop": 43,
  70. * "isRecommended": 1,
  71. * "topPosition": 0,
  72. * "seoKeyword": null,
  73. * "seoDescription": null,
  74. * "createdAt": "2025-09-14T09:21:24.000Z",
  75. * "updatedAt": "2025-09-14T09:21:24.000Z",
  76. * "cropInfo": {
  77. * "id": 43,
  78. * "name": "荔枝",
  79. * "level": 2,
  80. * "parentId": 40
  81. * }
  82. * }
  83. * ],
  84. * "pagination": {
  85. * "total": 19,
  86. * "currentPage": 1,
  87. * "pageSize": 10
  88. * }
  89. * }
  90. * }
  91. *
  92. * @apiErrorExample {json} Error-Response:
  93. * HTTP/1.1 500 Internal Server Error
  94. * {
  95. * "status": false,
  96. * "message": "失败",
  97. * "errors": ["错误信息"]
  98. * }
  99. */
  100. router.get('/', async function(req, res, next) {
  101. try {
  102. const query = req.query
  103. //当前是第几页,如果不传,那就是第一页
  104. const currentPage = Math.abs(Number(query.currentPage)) || 1
  105. //每页显示多少条数据,如果不传,那就显示10条
  106. const pageSize = Math.abs(Number(query.pageSize)) || 10
  107. //计算 offset
  108. const offset = (currentPage - 1) * pageSize
  109. const condition = {
  110. order:[
  111. ['topPosition', 'DESC'], // 置顶文章优先(1在前,0在后)
  112. ['updatedAt', 'DESC'] // 然后按更新时间倒序
  113. ],
  114. limit:pageSize,
  115. offset,
  116. attributes: [
  117. 'id', 'title', 'subtitle', 'content', 'type', 'img', 'date',
  118. 'author', 'category', 'crop', 'isRecommended', 'topPosition', 'seoKeyword',
  119. 'seoDescription', 'createdAt', 'updatedAt'
  120. ],
  121. include: [{
  122. model: Category,
  123. as: 'cropInfo',
  124. attributes: ['id', 'name', 'level', 'parentId'],
  125. required: false // LEFT JOIN,即使没有作物也能返回文章
  126. }]
  127. }
  128. // 构建查询条件
  129. const whereConditions = {};
  130. // 标题搜索
  131. if(query.title){
  132. whereConditions.title = {
  133. [Op.like]:`%${query.title}%`
  134. }
  135. }
  136. // 作物筛选 - 支持多选和包含子分类
  137. if(query.cropIds){
  138. let cropIds = [];
  139. // 处理cropIds参数(支持逗号分隔的多个ID)
  140. if(typeof query.cropIds === 'string'){
  141. cropIds = query.cropIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
  142. } else if(Array.isArray(query.cropIds)){
  143. cropIds = query.cropIds.map(id => parseInt(id)).filter(id => !isNaN(id));
  144. } else {
  145. cropIds = [parseInt(query.cropIds)].filter(id => !isNaN(id));
  146. }
  147. if(cropIds.length > 0){
  148. // 获取所有选中的分类及其子分类的ID
  149. const allCropIds = await getAllCategoryIdsWithChildren(cropIds);
  150. whereConditions.crop = {
  151. [Op.in]: allCropIds
  152. };
  153. }
  154. }
  155. // 用户分类筛选 - 根据用户传递的category参数值查询
  156. if(query.categoryId){
  157. const categoryId = parseInt(query.categoryId);
  158. if(!isNaN(categoryId)){
  159. whereConditions.category = categoryId;
  160. }
  161. }
  162. // 推荐筛选 - 根据是否推荐进行筛选
  163. if(query.isRecommended !== undefined){
  164. const isRecommended = parseInt(query.isRecommended);
  165. if(!isNaN(isRecommended) && (isRecommended === 0 || isRecommended === 1)){
  166. whereConditions.isRecommended = isRecommended;
  167. }
  168. }
  169. // 置顶筛选 - 根据是否置顶进行筛选
  170. if(query.topPosition !== undefined){
  171. const topPosition = parseInt(query.topPosition);
  172. if(!isNaN(topPosition) && (topPosition === 0 || topPosition === 1)){
  173. whereConditions.topPosition = topPosition;
  174. }
  175. }
  176. // 文章类型筛选 - 支持多选
  177. if(query.newsTypeIds){
  178. let typeIds = [];
  179. // 处理newsTypeIds参数(支持逗号分隔的多个ID)
  180. if(typeof query.newsTypeIds === 'string'){
  181. typeIds = query.newsTypeIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
  182. } else if(Array.isArray(query.newsTypeIds)){
  183. typeIds = query.newsTypeIds.map(id => parseInt(id)).filter(id => !isNaN(id));
  184. } else {
  185. typeIds = [parseInt(query.newsTypeIds)].filter(id => !isNaN(id));
  186. }
  187. if(typeIds.length > 0){
  188. whereConditions.type = {
  189. [Op.in]: typeIds
  190. };
  191. }
  192. }
  193. // 如果有查询条件,添加到condition中
  194. if(Object.keys(whereConditions).length > 0){
  195. condition.where = whereConditions;
  196. }
  197. const {count ,rows} = await Article.findAndCountAll(condition)
  198. res.json({
  199. status:true,
  200. message:'成功',
  201. data:{
  202. articles:rows,
  203. pagination:{
  204. total:count,
  205. currentPage,
  206. pageSize
  207. }
  208. }
  209. });
  210. }catch(error){
  211. res.status(500).json({
  212. status:false,
  213. message:'失败',
  214. errors:[error.message]
  215. });
  216. }
  217. });
  218. /**
  219. * @api {get} /admin/articles/:id 查询文章详情
  220. * @apiName GetArticleById
  221. * @apiGroup Articles
  222. * @apiVersion 1.0.0
  223. *
  224. * @apiDescription 根据文章ID获取文章详细信息
  225. *
  226. * @apiParam {Number} id 文章ID(路径参数)
  227. *
  228. * @apiSuccess {Boolean} status 请求状态
  229. * @apiSuccess {String} message 响应消息
  230. * @apiSuccess {Object} data 文章详细信息
  231. * @apiSuccess {Number} data.id 文章ID
  232. * @apiSuccess {String} data.title 文章标题
  233. * @apiSuccess {String} [data.subtitle] 文章副标题
  234. * @apiSuccess {String} data.content 文章内容
  235. * @apiSuccess {Number} data.type 文章类型
  236. * @apiSuccess {String} [data.img] 文章图片
  237. * @apiSuccess {Date} [data.date] 文章日期
  238. * @apiSuccess {String} [data.author] 作者
  239. * @apiSuccess {Number} data.category 用户分类ID
  240. * @apiSuccess {Number} data.crop 作物分类ID
  241. * @apiSuccess {Number} data.isRecommended 是否推荐(0-不推荐,1-推荐)
  242. * @apiSuccess {Number} data.topPosition 是否置顶(0-不置顶,1-置顶)
  243. * @apiSuccess {String} [data.seoKeyword] SEO关键词
  244. * @apiSuccess {String} [data.seoDescription] SEO描述
  245. * @apiSuccess {Date} data.createdAt 创建时间
  246. * @apiSuccess {Date} data.updatedAt 更新时间
  247. * @apiSuccess {Object} [data.cropInfo] 作物信息
  248. * @apiSuccess {Number} data.cropInfo.id 作物ID
  249. * @apiSuccess {String} data.cropInfo.name 作物名称
  250. * @apiSuccess {Number} data.cropInfo.level 作物层级
  251. * @apiSuccess {Number} data.cropInfo.parentId 父级作物ID
  252. *
  253. * @apiSuccessExample {json} Success-Response:
  254. * HTTP/1.1 200 OK
  255. * {
  256. * "status": true,
  257. * "message": "成功",
  258. * "data": {
  259. * "id": 96,
  260. * "title": "测试文章",
  261. * "subtitle": "副标题",
  262. * "content": "<p>文章内容</p>",
  263. * "type": 1,
  264. * "img": null,
  265. * "date": null,
  266. * "author": "作者",
  267. * "category": 1,
  268. * "crop": 43,
  269. * "isRecommended": 1,
  270. * "topPosition": 0,
  271. * "seoKeyword": null,
  272. * "seoDescription": null,
  273. * "createdAt": "2025-09-14T09:21:24.000Z",
  274. * "updatedAt": "2025-09-14T09:21:24.000Z",
  275. * "cropInfo": {
  276. * "id": 43,
  277. * "name": "荔枝",
  278. * "level": 2,
  279. * "parentId": 40
  280. * }
  281. * }
  282. * }
  283. *
  284. * @apiErrorExample {json} Error-Response:
  285. * HTTP/1.1 404 Not Found
  286. * {
  287. * "status": false,
  288. * "message": "文章未找到"
  289. * }
  290. */
  291. router.get('/:id', async function(req, res, next) {
  292. try {
  293. //获取文章id
  294. const {id} = req.params
  295. //查询文章
  296. const article = await Article.findByPk(id, {
  297. include: [{
  298. model: Category,
  299. as: 'cropInfo',
  300. attributes: ['id', 'name', 'level', 'parentId'],
  301. required: false
  302. }]
  303. })
  304. if(article){
  305. res.json({
  306. status:true,
  307. message:'成功',
  308. data:article
  309. });
  310. }else{
  311. res.status(404).json({
  312. status:false,
  313. message:'文章未找到',
  314. });
  315. }
  316. }catch(error){
  317. res.status(500).json({
  318. status:false,
  319. message:'失败',
  320. errors:[error.message]
  321. });
  322. }
  323. });
  324. /**
  325. * @api {post} /admin/articles 创建文章
  326. * @apiName CreateArticle
  327. * @apiGroup Articles
  328. * @apiVersion 1.0.0
  329. *
  330. * @apiDescription 创建新的文章,支持富文本内容和图片
  331. *
  332. * @apiParam {String} title 文章标题(必填,1-500字符)
  333. * @apiParam {String} content 文章内容(必填,富文本格式,最大5MB)
  334. * @apiParam {Number} [type] 文章类型
  335. * @apiParam {String} [img] 文章图片URL
  336. * @apiParam {Date} [date] 文章发布日期
  337. * @apiParam {String} [author] 作者
  338. * @apiParam {Number} [category] 用户分类ID(用户传递的参数)
  339. * @apiParam {Number} [crop] 作物分类ID
  340. * @apiParam {Number} [isRecommended=0] 是否推荐,0-不推荐,1-推荐
  341. * @apiParam {Number} [topPosition=0] 是否置顶,0-不置顶,1-置顶
  342. * @apiParam {String} [subtitle] 副标题(最大200字符)
  343. * @apiParam {String} [seoKeyword] SEO关键词
  344. * @apiParam {String} [seoDescription] SEO描述
  345. *
  346. * @apiSuccess {Boolean} status 请求状态
  347. * @apiSuccess {String} message 响应消息
  348. * @apiSuccess {Object} data 创建的文章信息
  349. * @apiSuccess {Number} data.id 文章ID
  350. * @apiSuccess {String} data.title 文章标题
  351. * @apiSuccess {String} [data.subtitle] 文章副标题
  352. * @apiSuccess {String} data.content 文章内容
  353. * @apiSuccess {Number} data.type 文章类型
  354. * @apiSuccess {String} [data.img] 文章图片
  355. * @apiSuccess {Date} [data.date] 文章日期
  356. * @apiSuccess {String} [data.author] 作者
  357. * @apiSuccess {Number} data.category 用户分类ID
  358. * @apiSuccess {Number} data.crop 作物分类ID
  359. * @apiSuccess {Number} data.isRecommended 是否推荐
  360. * @apiSuccess {Number} data.topPosition 是否置顶
  361. * @apiSuccess {String} [data.seoKeyword] SEO关键词
  362. * @apiSuccess {String} [data.seoDescription] SEO描述
  363. * @apiSuccess {Date} data.createdAt 创建时间
  364. * @apiSuccess {Date} data.updatedAt 更新时间
  365. *
  366. * @apiSuccessExample {json} Success-Response:
  367. * HTTP/1.1 201 Created
  368. * {
  369. * "status": true,
  370. * "message": "成功",
  371. * "data": {
  372. * "id": 97,
  373. * "title": "新文章标题",
  374. * "subtitle": "副标题",
  375. * "content": "<p>文章内容</p>",
  376. * "type": 1,
  377. * "img": null,
  378. * "date": null,
  379. * "author": "作者",
  380. * "category": 1,
  381. * "crop": 43,
  382. * "isRecommended": 1,
  383. * "topPosition": 0,
  384. * "seoKeyword": null,
  385. * "seoDescription": null,
  386. * "createdAt": "2025-09-14T09:21:49.333Z",
  387. * "updatedAt": "2025-09-14T09:21:49.333Z"
  388. * }
  389. * }
  390. *
  391. * @apiErrorExample {json} Error-Response:
  392. * HTTP/1.1 400 Bad Request
  393. * {
  394. * "status": false,
  395. * "message": "请求参数错误",
  396. * "errors": ["标题不能为空"]
  397. * }
  398. *
  399. * @apiErrorExample {json} Error-Response:
  400. * HTTP/1.1 400 Bad Request
  401. * {
  402. * "status": false,
  403. * "message": "请求参数错误",
  404. * "errors": ["推荐字段只能是0或1"]
  405. * }
  406. *
  407. * @apiErrorExample {json} Error-Response:
  408. * HTTP/1.1 400 Bad Request
  409. * {
  410. * "status": false,
  411. * "message": "请求参数错误",
  412. * "errors": ["副标题长度不能超过200个字符"]
  413. * }
  414. *
  415. * @apiErrorExample {json} Error-Response:
  416. * HTTP/1.1 400 Bad Request
  417. * {
  418. * "status": false,
  419. * "message": "请求参数错误",
  420. * "errors": ["置顶字段只能是0或1"]
  421. * }
  422. */
  423. router.post('/', async function(req, res, next) {
  424. try {
  425. // 添加请求日志
  426. console.log('=== 创建文章请求开始 ===');
  427. console.log('请求体大小:', JSON.stringify(req.body).length);
  428. console.log('Content字段长度:', req.body.content ? req.body.content.length : 0);
  429. console.log('Title字段长度:', req.body.title ? req.body.title.length : 0);
  430. //白名单过滤
  431. const body = filterBody(req)
  432. console.log('过滤后的数据:', {
  433. titleLength: body.title ? body.title.length : 0,
  434. contentLength: body.content ? body.content.length : 0,
  435. hasImage: !!body.img,
  436. type: body.type
  437. });
  438. const article = await Article.create(body)
  439. console.log('文章创建成功, ID:', article.id);
  440. console.log('=== 创建文章请求结束 ===');
  441. res.status(201).json({
  442. status:true,
  443. message:'成功',
  444. data:article
  445. });
  446. }catch(error){
  447. // 添加详细的错误日志
  448. console.error('=== 创建文章错误 ===');
  449. console.error('错误名称:', error.name);
  450. console.error('错误消息:', error.message);
  451. console.error('错误堆栈:', error.stack);
  452. console.error('请求体大小:', JSON.stringify(req.body).length);
  453. console.error('请求体:', JSON.stringify(req.body, null, 2));
  454. if(error.message === '标题不能为空' || error.message === '内容不能为空' ||
  455. error.message.includes('长度不能超过') || error.message.includes('不允许的脚本标签') ||
  456. error.message.includes('推荐字段只能是0或1') || error.message.includes('置顶字段只能是0或1') ||
  457. error.message.includes('副标题长度不能超过')){
  458. res.status(400).json({
  459. status:false,
  460. message:'请求参数错误',
  461. errors:[error.message]
  462. });
  463. }else if(error.name === 'SequelizeValidationError'){
  464. const errors = error.errors.map(e => e.message)
  465. res.status(400).json({
  466. status:false,
  467. message:'数据验证失败',
  468. errors
  469. });
  470. }else if(error.name === 'SequelizeDatabaseError'){
  471. console.error('数据库错误详情:', error.original);
  472. res.status(500).json({
  473. status:false,
  474. message:'数据库错误',
  475. errors:['数据库操作失败,请稍后重试']
  476. });
  477. }else if(error.name === 'SequelizeConnectionError'){
  478. res.status(500).json({
  479. status:false,
  480. message:'数据库连接错误',
  481. errors:['数据库连接失败,请稍后重试']
  482. });
  483. }else{
  484. res.status(500).json({
  485. status:false,
  486. message:'服务器内部错误',
  487. errors:['服务器处理请求时发生错误,请稍后重试']
  488. });
  489. }
  490. }
  491. });
  492. /**
  493. * @api {delete} /admin/articles/:id 删除文章
  494. * @apiName DeleteArticle
  495. * @apiGroup Articles
  496. * @apiVersion 1.0.0
  497. *
  498. * @apiDescription 根据文章ID删除文章
  499. *
  500. * @apiParam {Number} id 文章ID(路径参数)
  501. *
  502. * @apiSuccess {Boolean} status 请求状态
  503. * @apiSuccess {String} message 响应消息
  504. *
  505. * @apiSuccessExample {json} Success-Response:
  506. * HTTP/1.1 200 OK
  507. * {
  508. * "status": true,
  509. * "message": "成功"
  510. * }
  511. *
  512. * @apiErrorExample {json} Error-Response:
  513. * HTTP/1.1 404 Not Found
  514. * {
  515. * "status": false,
  516. * "message": "文章未找到"
  517. * }
  518. */
  519. router.delete('/:id', async function(req, res, next) {
  520. try {
  521. //获取文章id
  522. const {id} = req.params
  523. //查询文章
  524. const article = await Article.findByPk(id)
  525. if(article){
  526. await article.destroy()
  527. res.json({
  528. status:true,
  529. message:'成功',
  530. });
  531. }else{
  532. res.status(404).json({
  533. status:false,
  534. message:'文章未找到',
  535. });
  536. }
  537. }catch(error){
  538. res.status(500).json({
  539. status:false,
  540. message:'失败',
  541. errors:[error.message]
  542. });
  543. }
  544. });
  545. /**
  546. * @api {put} /admin/articles/:id 更新文章
  547. * @apiName UpdateArticle
  548. * @apiGroup Articles
  549. * @apiVersion 1.0.0
  550. *
  551. * @apiDescription 根据文章ID更新文章信息
  552. *
  553. * @apiParam {Number} id 文章ID(路径参数)
  554. * @apiParam {String} [title] 文章标题(1-500字符)
  555. * @apiParam {String} [content] 文章内容(富文本格式,最大5MB)
  556. * @apiParam {Number} [type] 文章类型
  557. * @apiParam {String} [img] 文章图片URL
  558. * @apiParam {Date} [date] 文章发布日期
  559. * @apiParam {String} [author] 作者
  560. * @apiParam {Number} [category] 用户分类ID
  561. * @apiParam {Number} [crop] 作物分类ID
  562. * @apiParam {Number} [isRecommended] 是否推荐,0-不推荐,1-推荐
  563. * @apiParam {Number} [topPosition] 是否置顶,0-不置顶,1-置顶
  564. * @apiParam {String} [subtitle] 副标题(最大200字符)
  565. * @apiParam {String} [seoKeyword] SEO关键词
  566. * @apiParam {String} [seoDescription] SEO描述
  567. *
  568. * @apiSuccess {Boolean} status 请求状态
  569. * @apiSuccess {String} message 响应消息
  570. * @apiSuccess {Object} data 更新后的文章信息
  571. *
  572. * @apiSuccessExample {json} Success-Response:
  573. * HTTP/1.1 200 OK
  574. * {
  575. * "status": true,
  576. * "message": "成功",
  577. * "data": {
  578. * "id": 97,
  579. * "title": "更新后的标题",
  580. * "subtitle": "更新后的副标题",
  581. * "content": "<p>更新后的内容</p>",
  582. * "type": 1,
  583. * "img": null,
  584. * "date": null,
  585. * "author": "作者",
  586. * "category": 1,
  587. * "crop": 43,
  588. * "isRecommended": 1,
  589. * "topPosition": 0,
  590. * "seoKeyword": null,
  591. * "seoDescription": null,
  592. * "createdAt": "2025-09-14T09:21:49.333Z",
  593. * "updatedAt": "2025-09-14T09:22:10.000Z"
  594. * }
  595. * }
  596. *
  597. * @apiErrorExample {json} Error-Response:
  598. * HTTP/1.1 404 Not Found
  599. * {
  600. * "status": false,
  601. * "message": "文章未找到"
  602. * }
  603. *
  604. * @apiErrorExample {json} Error-Response:
  605. * HTTP/1.1 400 Bad Request
  606. * {
  607. * "status": false,
  608. * "message": "请求参数错误",
  609. * "errors": ["推荐字段只能是0或1"]
  610. * }
  611. *
  612. * @apiErrorExample {json} Error-Response:
  613. * HTTP/1.1 400 Bad Request
  614. * {
  615. * "status": false,
  616. * "message": "请求参数错误",
  617. * "errors": ["置顶字段只能是0或1"]
  618. * }
  619. */
  620. router.put('/:id', async function(req, res, next) {
  621. try {
  622. //获取文章id
  623. const {id} = req.params
  624. //查询文章
  625. const article = await Article.findByPk(id)
  626. //白名单过滤
  627. const body = filterBody(req)
  628. if(article){
  629. await article.update(body)
  630. res.json({
  631. status:true,
  632. message:'成功',
  633. data:article
  634. });
  635. }else{
  636. res.status(404).json({
  637. status:false,
  638. message:'文章未找到',
  639. });
  640. }
  641. }catch(error){
  642. res.status(500).json({
  643. status:false,
  644. message:'失败',
  645. errors:[error.message]
  646. });
  647. }
  648. });
  649. /**
  650. * 获取分类及其所有子分类的ID列表
  651. * @param {Array} categoryIds - 分类ID数组
  652. * @returns {Promise<Array>} 包含所有分类ID的数组
  653. */
  654. async function getAllCategoryIdsWithChildren(categoryIds) {
  655. try {
  656. let allIds = [...categoryIds];
  657. // 递归获取所有子分类
  658. async function getChildrenIds(parentIds) {
  659. if (parentIds.length === 0) return [];
  660. const children = await Category.findAll({
  661. where: {
  662. parentId: {
  663. [Op.in]: parentIds
  664. }
  665. },
  666. attributes: ['id']
  667. });
  668. const childrenIds = children.map(child => child.id);
  669. if (childrenIds.length > 0) {
  670. allIds = allIds.concat(childrenIds);
  671. // 递归获取子分类的子分类
  672. const grandChildrenIds = await getChildrenIds(childrenIds);
  673. allIds = allIds.concat(grandChildrenIds);
  674. }
  675. return childrenIds;
  676. }
  677. await getChildrenIds(categoryIds);
  678. // 去重并返回
  679. return [...new Set(allIds)];
  680. } catch (error) {
  681. console.error('获取分类ID列表错误:', error);
  682. // 如果出错,返回原始ID列表
  683. return categoryIds;
  684. }
  685. }
  686. function filterBody(req){
  687. try {
  688. // 数据清理和验证
  689. const body = {
  690. title: req.body.title ? String(req.body.title).trim() : null,
  691. content: req.body.content ? String(req.body.content) : null,
  692. type: req.body.type !== undefined ? parseInt(req.body.type) : null,
  693. img: req.body.img ? String(req.body.img).trim() : null,
  694. date: req.body.date ? new Date(req.body.date) : null,
  695. author: req.body.author ? String(req.body.author).trim() : null,
  696. category: req.body.category !== undefined ? parseInt(req.body.category) : null,
  697. crop: req.body.crop !== undefined ? parseInt(req.body.crop) : null,
  698. isRecommended: req.body.isRecommended !== undefined ? parseInt(req.body.isRecommended) : 0,
  699. topPosition: req.body.topPosition !== undefined ? parseInt(req.body.topPosition) : 0,
  700. subtitle: req.body.subtitle ? String(req.body.subtitle).trim() : null,
  701. seoKeyword: req.body.seoKeyword ? String(req.body.seoKeyword).trim() : null,
  702. seoDescription: req.body.seoDescription ? String(req.body.seoDescription).trim() : null
  703. };
  704. // 验证必填字段
  705. if (!body.title) {
  706. throw new Error('标题不能为空');
  707. }
  708. if (!body.content) {
  709. throw new Error('内容不能为空');
  710. }
  711. // 验证标题长度 - 放宽限制以适应富文本编辑器
  712. if (body.title.length > 500) {
  713. throw new Error('标题长度不能超过500个字符');
  714. }
  715. // 验证内容长度 - 防止过大的内容
  716. if (body.content.length > 5000000) { // 5MB限制
  717. throw new Error('内容过长,请减少内容长度');
  718. }
  719. // 验证推荐字段 - 只能是0或1
  720. if (body.isRecommended !== 0 && body.isRecommended !== 1) {
  721. throw new Error('推荐字段只能是0或1');
  722. }
  723. // 验证置顶字段 - 只能是0或1
  724. if (body.topPosition !== 0 && body.topPosition !== 1) {
  725. throw new Error('置顶字段只能是0或1');
  726. }
  727. // 验证副标题长度
  728. if (body.subtitle && body.subtitle.length > 200) {
  729. throw new Error('副标题长度不能超过200个字符');
  730. }
  731. // 检查富文本内容是否包含危险标签或脚本
  732. const dangerousTags = /<script[^>]*>.*?<\/script>/gi;
  733. if (dangerousTags.test(body.content)) {
  734. throw new Error('内容包含不允许的脚本标签');
  735. }
  736. return body;
  737. } catch (error) {
  738. console.error('filterBody错误:', error);
  739. throw error;
  740. }
  741. }
  742. module.exports = router;