const express = require('express'); const router = express.Router(); const {Article, Category} = require('../../models') const {Op} = require('sequelize') /** * @api {get} /admin/articles 查询文章列表 * @apiName GetArticles * @apiGroup Articles * @apiVersion 1.0.0 * * @apiDescription 获取文章列表,支持分页和多条件筛选 * * @apiParam {Number} [currentPage=1] 当前页码,默认为1 * @apiParam {Number} [pageSize=10] 每页显示数量,默认为10 * @apiParam {String} [title] 标题搜索关键词(模糊匹配) * @apiParam {String|Array} [cropIds] 作物筛选ID,支持逗号分隔的多个ID或数组形式 * @apiParam {Number} [categoryId] 用户分类ID,根据用户传递的category参数值进行精确匹配 * @apiParam {Number} [isRecommended] 推荐筛选,0-非推荐文章,1-推荐文章 * @apiParam {Number} [topPosition] 置顶筛选,0-非置顶文章,1-置顶文章 * @apiParam {String|Array} [newsTypeIds] 文章类型筛选ID,支持逗号分隔的多个ID或数组形式,根据type字段进行筛选 * * @apiSuccess {Boolean} status 请求状态 * @apiSuccess {String} message 响应消息 * @apiSuccess {Object} data 响应数据 * @apiSuccess {Array} data.articles 文章列表 * @apiSuccess {Number} data.articles.id 文章ID * @apiSuccess {String} data.articles.title 文章标题 * @apiSuccess {String} [data.articles.subtitle] 文章副标题 * @apiSuccess {String} data.articles.content 文章内容 * @apiSuccess {Number} data.articles.type 文章类型 * @apiSuccess {String} [data.articles.img] 文章图片 * @apiSuccess {Date} [data.articles.date] 文章日期 * @apiSuccess {String} [data.articles.author] 作者 * @apiSuccess {Number} data.articles.category 用户分类ID * @apiSuccess {Number} data.articles.crop 作物分类ID * @apiSuccess {Number} data.articles.isRecommended 是否推荐(0-不推荐,1-推荐) * @apiSuccess {Number} data.articles.topPosition 是否置顶(0-不置顶,1-置顶) * @apiSuccess {Number} data.articles.sortOrder 文章排序字段,用于置顶文章的排序 * @apiSuccess {String} [data.articles.seoKeyword] SEO关键词 * @apiSuccess {String} [data.articles.seoDescription] SEO描述 * @apiSuccess {Date} data.articles.createdAt 创建时间 * @apiSuccess {Date} data.articles.updatedAt 更新时间 * @apiSuccess {Object} [data.articles.cropInfo] 作物信息 * @apiSuccess {Number} data.articles.cropInfo.id 作物ID * @apiSuccess {String} data.articles.cropInfo.name 作物名称 * @apiSuccess {Number} data.articles.cropInfo.level 作物层级 * @apiSuccess {Number} data.articles.cropInfo.parentId 父级作物ID * @apiSuccess {Object} data.pagination 分页信息 * @apiSuccess {Number} data.pagination.total 总数量 * @apiSuccess {Number} data.pagination.currentPage 当前页码 * @apiSuccess {Number} data.pagination.pageSize 每页数量 * * @apiSuccessExample {json} Success-Response: * HTTP/1.1 200 OK * { * "status": true, * "message": "成功", * "data": { * "articles": [ * { * "id": 96, * "title": "测试文章", * "subtitle": "副标题", * "content": "

文章内容

", * "type": 1, * "img": null, * "date": null, * "author": "作者", * "category": 1, * "crop": 43, * "isRecommended": 1, * "topPosition": 0, * "sortOrder": 1, * "seoKeyword": null, * "seoDescription": null, * "createdAt": "2025-09-14T09:21:24.000Z", * "updatedAt": "2025-09-14T09:21:24.000Z", * "cropInfo": { * "id": 43, * "name": "荔枝", * "level": 2, * "parentId": 40 * } * } * ], * "pagination": { * "total": 19, * "currentPage": 1, * "pageSize": 10 * } * } * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 500 Internal Server Error * { * "status": false, * "message": "失败", * "errors": ["错误信息"] * } */ router.get('/', async function(req, res, next) { try { const query = req.query //当前是第几页,如果不传,那就是第一页 const currentPage = Math.abs(Number(query.currentPage)) || 1 //每页显示多少条数据,如果不传,那就显示10条 const pageSize = Math.abs(Number(query.pageSize)) || 10 //计算 offset const offset = (currentPage - 1) * pageSize const condition = { order:[ ['topPosition', 'DESC'], // 置顶文章优先(1在前,0在后) ['sortOrder', 'ASC'], // 置顶文章按sortOrder升序排序 ['updatedAt', 'DESC'] // 非置顶文章按更新时间倒序 ], limit:pageSize, offset, attributes: [ 'id', 'title', 'subtitle', 'content', 'type', 'img', 'date', 'author', 'category', 'crop', 'isRecommended', 'topPosition', 'sortOrder', 'seoKeyword', 'seoDescription', 'createdAt', 'updatedAt' ], include: [{ model: Category, as: 'cropInfo', attributes: ['id', 'name', 'level', 'parentId'], required: false // LEFT JOIN,即使没有作物也能返回文章 }] } // 构建查询条件 const whereConditions = {}; // 标题搜索 if(query.title){ whereConditions.title = { [Op.like]:`%${query.title}%` } } // 作物筛选 - 支持多选和包含子分类 if(query.cropIds){ let cropIds = []; // 处理cropIds参数(支持逗号分隔的多个ID) if(typeof query.cropIds === 'string'){ cropIds = query.cropIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id)); } else if(Array.isArray(query.cropIds)){ cropIds = query.cropIds.map(id => parseInt(id)).filter(id => !isNaN(id)); } else { cropIds = [parseInt(query.cropIds)].filter(id => !isNaN(id)); } if(cropIds.length > 0){ // 获取所有选中的分类及其子分类的ID const allCropIds = await getAllCategoryIdsWithChildren(cropIds); whereConditions.crop = { [Op.in]: allCropIds }; } } // 用户分类筛选 - 根据用户传递的category参数值查询 if(query.categoryId){ const categoryId = parseInt(query.categoryId); if(!isNaN(categoryId)){ whereConditions.category = categoryId; } } // 推荐筛选 - 根据是否推荐进行筛选 if(query.isRecommended !== undefined){ const isRecommended = parseInt(query.isRecommended); if(!isNaN(isRecommended) && (isRecommended === 0 || isRecommended === 1)){ whereConditions.isRecommended = isRecommended; } } // 置顶筛选 - 根据是否置顶进行筛选 if(query.topPosition !== undefined){ const topPosition = parseInt(query.topPosition); if(!isNaN(topPosition) && (topPosition === 0 || topPosition === 1)){ whereConditions.topPosition = topPosition; } } // 文章类型筛选 - 支持多选 if(query.newsTypeIds){ let typeIds = []; // 处理newsTypeIds参数(支持逗号分隔的多个ID) if(typeof query.newsTypeIds === 'string'){ typeIds = query.newsTypeIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id)); } else if(Array.isArray(query.newsTypeIds)){ typeIds = query.newsTypeIds.map(id => parseInt(id)).filter(id => !isNaN(id)); } else { typeIds = [parseInt(query.newsTypeIds)].filter(id => !isNaN(id)); } if(typeIds.length > 0){ whereConditions.type = { [Op.in]: typeIds }; } } // 如果有查询条件,添加到condition中 if(Object.keys(whereConditions).length > 0){ condition.where = whereConditions; } const {count ,rows} = await Article.findAndCountAll(condition) res.json({ status:true, message:'成功', data:{ articles:rows, pagination:{ total:count, currentPage, pageSize } } }); }catch(error){ res.status(500).json({ status:false, message:'失败', errors:[error.message] }); } }); /** * @api {get} /admin/articles/:id 查询文章详情 * @apiName GetArticleById * @apiGroup Articles * @apiVersion 1.0.0 * * @apiDescription 根据文章ID获取文章详细信息 * * @apiParam {Number} id 文章ID(路径参数) * * @apiSuccess {Boolean} status 请求状态 * @apiSuccess {String} message 响应消息 * @apiSuccess {Object} data 文章详细信息 * @apiSuccess {Number} data.id 文章ID * @apiSuccess {String} data.title 文章标题 * @apiSuccess {String} [data.subtitle] 文章副标题 * @apiSuccess {String} data.content 文章内容 * @apiSuccess {Number} data.type 文章类型 * @apiSuccess {String} [data.img] 文章图片 * @apiSuccess {Date} [data.date] 文章日期 * @apiSuccess {String} [data.author] 作者 * @apiSuccess {Number} data.category 用户分类ID * @apiSuccess {Number} data.crop 作物分类ID * @apiSuccess {Number} data.isRecommended 是否推荐(0-不推荐,1-推荐) * @apiSuccess {Number} data.topPosition 是否置顶(0-不置顶,1-置顶) * @apiSuccess {Number} data.sortOrder 文章排序字段,用于置顶文章的排序 * @apiSuccess {String} [data.seoKeyword] SEO关键词 * @apiSuccess {String} [data.seoDescription] SEO描述 * @apiSuccess {Date} data.createdAt 创建时间 * @apiSuccess {Date} data.updatedAt 更新时间 * @apiSuccess {Object} [data.cropInfo] 作物信息 * @apiSuccess {Number} data.cropInfo.id 作物ID * @apiSuccess {String} data.cropInfo.name 作物名称 * @apiSuccess {Number} data.cropInfo.level 作物层级 * @apiSuccess {Number} data.cropInfo.parentId 父级作物ID * * @apiSuccessExample {json} Success-Response: * HTTP/1.1 200 OK * { * "status": true, * "message": "成功", * "data": { * "id": 96, * "title": "测试文章", * "subtitle": "副标题", * "content": "

文章内容

", * "type": 1, * "img": null, * "date": null, * "author": "作者", * "category": 1, * "crop": 43, * "isRecommended": 1, * "topPosition": 0, * "sortOrder": 1, * "seoKeyword": null, * "seoDescription": null, * "createdAt": "2025-09-14T09:21:24.000Z", * "updatedAt": "2025-09-14T09:21:24.000Z", * "cropInfo": { * "id": 43, * "name": "荔枝", * "level": 2, * "parentId": 40 * } * } * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 404 Not Found * { * "status": false, * "message": "文章未找到" * } */ router.get('/:id', async function(req, res, next) { try { //获取文章id const {id} = req.params //查询文章 const article = await Article.findByPk(id, { include: [{ model: Category, as: 'cropInfo', attributes: ['id', 'name', 'level', 'parentId'], required: false }] }) if(article){ res.json({ status:true, message:'成功', data:article }); }else{ res.status(404).json({ status:false, message:'文章未找到', }); } }catch(error){ res.status(500).json({ status:false, message:'失败', errors:[error.message] }); } }); /** * @api {post} /admin/articles 创建文章 * @apiName CreateArticle * @apiGroup Articles * @apiVersion 1.0.0 * * @apiDescription 创建新的文章,支持富文本内容和图片 * * @apiParam {String} title 文章标题(必填,1-500字符) * @apiParam {String} content 文章内容(必填,富文本格式,最大5MB) * @apiParam {Number} [type] 文章类型 * @apiParam {String} [img] 文章图片URL * @apiParam {Date} [date] 文章发布日期 * @apiParam {String} [author] 作者 * @apiParam {Number} [category] 用户分类ID(用户传递的参数) * @apiParam {Number} [crop] 作物分类ID * @apiParam {Number} [isRecommended=0] 是否推荐,0-不推荐,1-推荐 * @apiParam {Number} [topPosition=0] 是否置顶,0-不置顶,1-置顶 * @apiParam {Number} [sortOrder=0] 文章排序字段,用于置顶文章的排序 * @apiParam {String} [subtitle] 副标题(最大200字符) * @apiParam {String} [seoKeyword] SEO关键词 * @apiParam {String} [seoDescription] SEO描述 * * @apiSuccess {Boolean} status 请求状态 * @apiSuccess {String} message 响应消息 * @apiSuccess {Object} data 创建的文章信息 * @apiSuccess {Number} data.id 文章ID * @apiSuccess {String} data.title 文章标题 * @apiSuccess {String} [data.subtitle] 文章副标题 * @apiSuccess {String} data.content 文章内容 * @apiSuccess {Number} data.type 文章类型 * @apiSuccess {String} [data.img] 文章图片 * @apiSuccess {Date} [data.date] 文章日期 * @apiSuccess {String} [data.author] 作者 * @apiSuccess {Number} data.category 用户分类ID * @apiSuccess {Number} data.crop 作物分类ID * @apiSuccess {Number} data.isRecommended 是否推荐 * @apiSuccess {Number} data.topPosition 是否置顶 * @apiSuccess {Number} data.sortOrder 文章排序字段 * @apiSuccess {String} [data.seoKeyword] SEO关键词 * @apiSuccess {String} [data.seoDescription] SEO描述 * @apiSuccess {Date} data.createdAt 创建时间 * @apiSuccess {Date} data.updatedAt 更新时间 * * @apiSuccessExample {json} Success-Response: * HTTP/1.1 201 Created * { * "status": true, * "message": "成功", * "data": { * "id": 97, * "title": "新文章标题", * "subtitle": "副标题", * "content": "

文章内容

", * "type": 1, * "img": null, * "date": null, * "author": "作者", * "category": 1, * "crop": 43, * "isRecommended": 1, * "topPosition": 0, * "sortOrder": 1, * "seoKeyword": null, * "seoDescription": null, * "createdAt": "2025-09-14T09:21:49.333Z", * "updatedAt": "2025-09-14T09:21:49.333Z" * } * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 400 Bad Request * { * "status": false, * "message": "请求参数错误", * "errors": ["标题不能为空"] * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 400 Bad Request * { * "status": false, * "message": "请求参数错误", * "errors": ["推荐字段只能是0或1"] * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 400 Bad Request * { * "status": false, * "message": "请求参数错误", * "errors": ["副标题长度不能超过200个字符"] * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 400 Bad Request * { * "status": false, * "message": "请求参数错误", * "errors": ["置顶字段只能是0或1"] * } */ router.post('/', async function(req, res, next) { try { // 添加请求日志 console.log('=== 创建文章请求开始 ==='); console.log('请求体大小:', JSON.stringify(req.body).length); console.log('Content字段长度:', req.body.content ? req.body.content.length : 0); console.log('Title字段长度:', req.body.title ? req.body.title.length : 0); //白名单过滤 const body = filterBody(req) console.log('过滤后的数据:', { titleLength: body.title ? body.title.length : 0, contentLength: body.content ? body.content.length : 0, hasImage: !!body.img, type: body.type }); const article = await Article.create(body) console.log('文章创建成功, ID:', article.id); console.log('=== 创建文章请求结束 ==='); res.status(201).json({ status:true, message:'成功', data:article }); }catch(error){ // 添加详细的错误日志 console.error('=== 创建文章错误 ==='); console.error('错误名称:', error.name); console.error('错误消息:', error.message); console.error('错误堆栈:', error.stack); console.error('请求体大小:', JSON.stringify(req.body).length); console.error('请求体:', JSON.stringify(req.body, null, 2)); if(error.message === '标题不能为空' || error.message === '内容不能为空' || error.message.includes('长度不能超过') || error.message.includes('不允许的脚本标签') || error.message.includes('推荐字段只能是0或1') || error.message.includes('置顶字段只能是0或1') || error.message.includes('副标题长度不能超过')){ res.status(400).json({ status:false, message:'请求参数错误', errors:[error.message] }); }else if(error.name === 'SequelizeValidationError'){ const errors = error.errors.map(e => e.message) res.status(400).json({ status:false, message:'数据验证失败', errors }); }else if(error.name === 'SequelizeDatabaseError'){ console.error('数据库错误详情:', error.original); res.status(500).json({ status:false, message:'数据库错误', errors:['数据库操作失败,请稍后重试'] }); }else if(error.name === 'SequelizeConnectionError'){ res.status(500).json({ status:false, message:'数据库连接错误', errors:['数据库连接失败,请稍后重试'] }); }else{ res.status(500).json({ status:false, message:'服务器内部错误', errors:['服务器处理请求时发生错误,请稍后重试'] }); } } }); /** * @api {delete} /admin/articles/:id 删除文章 * @apiName DeleteArticle * @apiGroup Articles * @apiVersion 1.0.0 * * @apiDescription 根据文章ID删除文章 * * @apiParam {Number} id 文章ID(路径参数) * * @apiSuccess {Boolean} status 请求状态 * @apiSuccess {String} message 响应消息 * * @apiSuccessExample {json} Success-Response: * HTTP/1.1 200 OK * { * "status": true, * "message": "成功" * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 404 Not Found * { * "status": false, * "message": "文章未找到" * } */ router.delete('/:id', async function(req, res, next) { try { //获取文章id const {id} = req.params //查询文章 const article = await Article.findByPk(id) if(article){ await article.destroy() res.json({ status:true, message:'成功', }); }else{ res.status(404).json({ status:false, message:'文章未找到', }); } }catch(error){ res.status(500).json({ status:false, message:'失败', errors:[error.message] }); } }); /** * @api {put} /admin/articles/:id 更新文章 * @apiName UpdateArticle * @apiGroup Articles * @apiVersion 1.0.0 * * @apiDescription 根据文章ID更新文章信息 * * @apiParam {Number} id 文章ID(路径参数) * @apiParam {String} [title] 文章标题(1-500字符) * @apiParam {String} [content] 文章内容(富文本格式,最大5MB) * @apiParam {Number} [type] 文章类型 * @apiParam {String} [img] 文章图片URL * @apiParam {Date} [date] 文章发布日期 * @apiParam {String} [author] 作者 * @apiParam {Number} [category] 用户分类ID * @apiParam {Number} [crop] 作物分类ID * @apiParam {Number} [isRecommended] 是否推荐,0-不推荐,1-推荐 * @apiParam {Number} [topPosition] 是否置顶,0-不置顶,1-置顶 * @apiParam {Number} [sortOrder] 文章排序字段,用于置顶文章的排序 * @apiParam {String} [subtitle] 副标题(最大200字符) * @apiParam {String} [seoKeyword] SEO关键词 * @apiParam {String} [seoDescription] SEO描述 * * @apiSuccess {Boolean} status 请求状态 * @apiSuccess {String} message 响应消息 * @apiSuccess {Object} data 更新后的文章信息 * * @apiSuccessExample {json} Success-Response: * HTTP/1.1 200 OK * { * "status": true, * "message": "成功", * "data": { * "id": 97, * "title": "更新后的标题", * "subtitle": "更新后的副标题", * "content": "

更新后的内容

", * "type": 1, * "img": null, * "date": null, * "author": "作者", * "category": 1, * "crop": 43, * "isRecommended": 1, * "topPosition": 0, * "sortOrder": 1, * "seoKeyword": null, * "seoDescription": null, * "createdAt": "2025-09-14T09:21:49.333Z", * "updatedAt": "2025-09-14T09:22:10.000Z" * } * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 404 Not Found * { * "status": false, * "message": "文章未找到" * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 400 Bad Request * { * "status": false, * "message": "请求参数错误", * "errors": ["推荐字段只能是0或1"] * } * * @apiErrorExample {json} Error-Response: * HTTP/1.1 400 Bad Request * { * "status": false, * "message": "请求参数错误", * "errors": ["置顶字段只能是0或1"] * } */ router.put('/:id', async function(req, res, next) { try { //获取文章id const {id} = req.params //查询文章 const article = await Article.findByPk(id) //白名单过滤 const body = filterBody(req) if(article){ await article.update(body) res.json({ status:true, message:'成功', data:article }); }else{ res.status(404).json({ status:false, message:'文章未找到', }); } }catch(error){ res.status(500).json({ status:false, message:'失败', errors:[error.message] }); } }); /** * 获取分类及其所有子分类的ID列表 * @param {Array} categoryIds - 分类ID数组 * @returns {Promise} 包含所有分类ID的数组 */ async function getAllCategoryIdsWithChildren(categoryIds) { try { let allIds = [...categoryIds]; // 递归获取所有子分类 async function getChildrenIds(parentIds) { if (parentIds.length === 0) return []; const children = await Category.findAll({ where: { parentId: { [Op.in]: parentIds } }, attributes: ['id'] }); const childrenIds = children.map(child => child.id); if (childrenIds.length > 0) { allIds = allIds.concat(childrenIds); // 递归获取子分类的子分类 const grandChildrenIds = await getChildrenIds(childrenIds); allIds = allIds.concat(grandChildrenIds); } return childrenIds; } await getChildrenIds(categoryIds); // 去重并返回 return [...new Set(allIds)]; } catch (error) { console.error('获取分类ID列表错误:', error); // 如果出错,返回原始ID列表 return categoryIds; } } function filterBody(req){ try { // 数据清理和验证 const body = { title: req.body.title ? String(req.body.title).trim() : null, content: req.body.content ? String(req.body.content) : null, type: req.body.type !== undefined ? parseInt(req.body.type) : null, img: req.body.img ? String(req.body.img).trim() : null, date: req.body.date ? new Date(req.body.date) : null, author: req.body.author ? String(req.body.author).trim() : null, category: req.body.category !== undefined ? parseInt(req.body.category) : null, crop: req.body.crop !== undefined ? parseInt(req.body.crop) : null, isRecommended: req.body.isRecommended !== undefined ? parseInt(req.body.isRecommended) : 0, topPosition: req.body.topPosition !== undefined ? parseInt(req.body.topPosition) : 0, sortOrder: req.body.sortOrder !== undefined ? parseInt(req.body.sortOrder) : 0, subtitle: req.body.subtitle ? String(req.body.subtitle).trim() : null, seoKeyword: req.body.seoKeyword ? String(req.body.seoKeyword).trim() : null, seoDescription: req.body.seoDescription ? String(req.body.seoDescription).trim() : null }; // 验证必填字段 if (!body.title) { throw new Error('标题不能为空'); } if (!body.content) { throw new Error('内容不能为空'); } // 验证标题长度 - 放宽限制以适应富文本编辑器 if (body.title.length > 500) { throw new Error('标题长度不能超过500个字符'); } // 验证内容长度 - 防止过大的内容 if (body.content.length > 5000000) { // 5MB限制 throw new Error('内容过长,请减少内容长度'); } // 验证推荐字段 - 只能是0或1 if (body.isRecommended !== 0 && body.isRecommended !== 1) { throw new Error('推荐字段只能是0或1'); } // 验证置顶字段 - 只能是0或1 if (body.topPosition !== 0 && body.topPosition !== 1) { throw new Error('置顶字段只能是0或1'); } // 验证副标题长度 if (body.subtitle && body.subtitle.length > 200) { throw new Error('副标题长度不能超过200个字符'); } // 检查富文本内容是否包含危险标签或脚本 const dangerousTags = /]*>.*?<\/script>/gi; if (dangerousTags.test(body.content)) { throw new Error('内容包含不允许的脚本标签'); } return body; } catch (error) { console.error('filterBody错误:', error); throw error; } } module.exports = router;