articles.js 27 KB

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