实现文章表格导出、文件上传接口,初步完成视频查询新增删除相关接口

This commit is contained in:
2025-10-20 18:04:36 +08:00
parent a1202fc4cf
commit f0797666a6
8 changed files with 1208 additions and 104 deletions

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ coverage/
tmp/
temp/
.cache/
upload/*
# Editors & OS
.vscode/

288
package-lock.json generated
View File

@@ -11,7 +11,9 @@
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
"mysql2": "^3.15.2"
"multer": "^2.0.2",
"mysql2": "^3.15.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.1.10"
@@ -30,6 +32,15 @@
"node": ">= 0.6"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -44,6 +55,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -117,6 +134,23 @@
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -155,6 +189,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -180,6 +227,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -187,6 +243,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@@ -226,6 +297,18 @@
"node": ">=6.6.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -428,6 +511,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -777,12 +869,94 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mysql2": {
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.2.tgz",
@@ -879,6 +1053,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1019,6 +1202,20 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1229,6 +1426,18 @@
"node": ">= 0.6"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1238,6 +1447,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1297,6 +1523,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1313,6 +1545,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -1322,11 +1560,59 @@
"node": ">= 0.8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View File

@@ -14,7 +14,9 @@
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
"mysql2": "^3.15.2"
"multer": "^2.0.2",
"mysql2": "^3.15.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.1.10"

43
sql/videos.sql Normal file
View File

@@ -0,0 +1,43 @@
-- 视频表
CREATE TABLE `videos` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '视频ID',
`title` varchar(255) NOT NULL COMMENT '视频标题',
`title_color` varchar(20) DEFAULT '#000000' COMMENT '标题颜色',
`url` varchar(500) DEFAULT NULL COMMENT 'URL链接',
`publish_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间',
`content` longtext COMMENT '详细内容',
`article_number` varchar(50) DEFAULT NULL COMMENT '图文编号',
`cover_image` varchar(500) DEFAULT NULL COMMENT '封面图',
`video_url` varchar(500) DEFAULT NULL COMMENT '视频地址',
`classification` varchar(100) DEFAULT NULL COMMENT '分类',
`duration` int(11) DEFAULT 0 COMMENT '时长(秒)',
`file_size` bigint(20) DEFAULT 0 COMMENT '文件大小(字节)',
`sort_order` int(11) DEFAULT 0 COMMENT '排序数字',
`view_count` int(11) DEFAULT 0 COMMENT '浏览次数',
`is_published` tinyint(1) DEFAULT 0 COMMENT '是否发布0=未发布1=已发布)',
`is_top` tinyint(1) DEFAULT 0 COMMENT '是否置顶0=否1=是)',
`is_recommended` tinyint(1) DEFAULT 0 COMMENT '是否推荐0=否1=是)',
`is_hot` tinyint(1) DEFAULT 0 COMMENT '是否热点0=否1=是)',
`is_slideshow` tinyint(1) DEFAULT 0 COMMENT '是否幻灯0=否1=是)',
`seo_title` varchar(255) DEFAULT NULL COMMENT 'SEO标题',
`seo_keywords` varchar(500) DEFAULT NULL COMMENT 'SEO关键词',
`seo_description` text DEFAULT NULL COMMENT 'SEO描述',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted_at` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_classification` (`classification`),
KEY `idx_is_published` (`is_published`),
KEY `idx_is_top` (`is_top`),
KEY `idx_is_recommended` (`is_recommended`),
KEY `idx_is_hot` (`is_hot`),
KEY `idx_is_slideshow` (`is_slideshow`),
KEY `idx_publish_time` (`publish_time`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频表';
-- 插入示例数据
INSERT INTO `videos` (`title`, `title_color`, `url`, `content`, `article_number`, `cover_image`, `video_url`, `classification`, `duration`, `file_size`, `sort_order`, `is_published`, `is_top`, `is_recommended`, `is_hot`, `is_slideshow`, `seo_title`, `seo_keywords`, `seo_description`) VALUES
('示例视频1', '#FF0000', '/video/sample1', '这是一个示例视频的详细内容描述', 'V001', '/upload/cover1.jpg', '/upload/video1.mp4', '教育', 300, 52428800, 1, 1, 0, 1, 0, 0, '示例视频1 - SEO标题', '视频,教育,示例', '这是示例视频1的SEO描述'),
('示例视频2', '#0066CC', '/video/sample2', '这是第二个示例视频的详细内容', 'V002', '/upload/cover2.jpg', '/upload/video2.mp4', '娱乐', 180, 31457280, 2, 1, 1, 0, 1, 1, '示例视频2 - SEO标题', '视频,娱乐,热门', '这是示例视频2的SEO描述');

View File

@@ -2,6 +2,10 @@ const express = require('express');
const dotenv = require('dotenv');
const { testConnection } = require('./db');
const articlesRouter = require('./routes/articles');
const videosRouter = require('./routes/videos');
const path = require('path');
const fs = require('fs');
const uploadRouter = require('./routes/upload');
// 获取环境变量
dotenv.config();
@@ -48,7 +52,15 @@ app.get('/api/db/ping', async (req, res) => {
// 文章相关接口
app.use('/api', articlesRouter);
// 视频相关接口
app.use('/api', videosRouter);
// 上传相关接口
app.use('/api', uploadRouter);
app.listen(port, () => {
console.log(`API server listening on http://localhost:${port}`);
});
});
const uploadDir = path.resolve(__dirname, '..', 'upload');
fs.mkdirSync(uploadDir, { recursive: true });
app.use('/upload', express.static(uploadDir));

View File

@@ -64,6 +64,99 @@ function toDateOrNull(val) {
return Number.isNaN(d.getTime()) ? null : d;
}
// ===== Shared helpers for query & save =====
function buildArticleFilter(query) {
const where = [];
const params = [];
if (query.title != null) {
const t = toNullableString(query.title);
if (t) { where.push('title LIKE ?'); params.push(`%${t}%`); }
}
if (query.classification != null) {
const cls = toNullableString(query.classification);
if (cls) { where.push('classification = ?'); params.push(cls); }
}
const statusKeys = ['isDraft', 'isPublished', 'isTop', 'isRecommended', 'isHot', 'isSlideshow', 'isDeleted'];
for (const key of statusKeys) {
if (query[key] != null) {
const v = toBooleanTinyInt(query[key], null);
if (v !== null) { where.push(`${key} = ?`); params.push(v); }
}
}
const start = toDateOrNull(query.startTime);
const end = toDateOrNull(query.endTime);
if (start && end) { where.push('publishTime BETWEEN ? AND ?'); params.push(start, end); }
else if (start) { where.push('publishTime >= ?'); params.push(start); }
else if (end) { where.push('publishTime <= ?'); params.push(end); }
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const orderSql = 'ORDER BY isTop DESC, sortNumber DESC, publishTime DESC';
return { whereSql, params, orderSql };
}
async function fetchArticles(query, opts = {}) {
const { whereSql, params, orderSql } = buildArticleFilter(query || {});
const pageSize = opts.pageSize;
const page = opts.page;
const hasPaging = Number.isInteger(pageSize) && Number.isInteger(page) && pageSize > 0 && page > 0;
const limitSql = hasPaging ? `LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}` : '';
const selectSql = `SELECT id, ${columns.join(',')} FROM articles ${whereSql} ${orderSql} ${limitSql}`;
const [rows] = await pool.execute(selectSql, params);
let total = rows.length;
if (opts.skipCount !== true) {
const countSql = `SELECT COUNT(*) as total FROM articles ${whereSql}`;
const [countRows] = await pool.execute(countSql, params);
total = countRows[0]?.total ?? 0;
}
return { rows, total };
}
function normalizeArticlePayload(body) {
const src = body || {};
return {
title: toNullableString(src.title),
titleColor: toNullableString(src.titleColor),
source: toNullableString(src.source),
author: toNullableString(src.author),
url: toNullableString(src.url),
classification: toNullableString(src.classification),
updateTime: new Date(),
publishTime: toDateOrNull(src.publishTime),
content: src.content,
summary: toNullableString(src.summary),
coverImage: toNullableString(src.coverImage),
sortNumber: toInt(src.sortNumber, 0),
viewCount: toInt(src.viewCount, 0),
isDraft: toBooleanTinyInt(src.isDraft, 0),
isPublished: toBooleanTinyInt(src.isPublished, 0),
isTop: toBooleanTinyInt(src.isTop, 0),
isRecommended: toBooleanTinyInt(src.isRecommended, 0),
isHot: toBooleanTinyInt(src.isHot, 0),
isSlideshow: toBooleanTinyInt(src.isSlideshow, 0),
seoTitle: toNullableString(src.seoTitle),
seoKeywords: toNullableString(src.seoKeywords),
seoDescription: toNullableString(src.seoDescription),
};
}
async function upsertArticle(id, payload) {
const placeholders = columns.map(() => '?').join(',');
const values = columns.map((c) => payload[c] ?? null);
const numId = parseInt(id, 10);
if (!Number.isNaN(numId) && numId > 0) {
const sql = `UPDATE articles SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`;
const [result] = await pool.execute(sql, [...values, numId]);
return result;
} else {
const sql = `INSERT INTO articles (${columns.join(',')}) VALUES (${placeholders})`;
const [result] = await pool.execute(sql, values);
return result;
}
}
// 上架多篇文章
router.post('/articles/publish', async (req, res) => {
try {
@@ -74,7 +167,7 @@ router.post('/articles/publish', async (req, res) => {
return res.status(400).json({ success: false, message: 'ids不合法' });
}
const placeholders = numIds.map(() => '?').join(',');
const sql = `UPDATE articles SET isPublished = 1, isDraft = 0 WHERE id IN (${placeholders})`;
const sql = `UPDATE articles SET isPublished = 1, isDraft = 0, publishTime = '${new Date().toLocaleString()}', updateTime = '${new Date().toLocaleString()}' WHERE id IN (${placeholders})`
const [result] = await pool.execute(sql, numIds);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '文章不存在' });
@@ -95,7 +188,7 @@ router.post('/articles/unpublish', async (req, res) => {
return res.status(400).json({ success: false, message: 'ids不合法' });
}
const placeholders = numIds.map(() => '?').join(',');
const sql = `UPDATE articles SET isPublished = 0 WHERE id IN (${placeholders})`;
const sql = `UPDATE articles SET isPublished = 0, publishTime = NULL, updateTime = '${new Date().toLocaleString()}' WHERE id IN (${placeholders})`;
const [result] = await pool.execute(sql, numIds);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '文章不存在' });
@@ -106,7 +199,140 @@ router.post('/articles/unpublish', async (req, res) => {
}
});
// Create article
// 导出文章数据
router.get('/articles/export', async (req, res) => {
try {
const format = String(req.query.format || 'csv').toLowerCase();
const { rows } = await fetchArticles(req.query, { skipCount: true });
const headers = ['id', ...columns];
const pad2 = (n) => String(n).padStart(2, '0');
const fmtDate = (v) => {
if (!v) return '';
const d = v instanceof Date ? v : new Date(v);
if (Number.isNaN(d.getTime())) return '';
return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
};
const fmtCell = (v) => {
if (v == null) return '';
if (v instanceof Date || (typeof v === 'string' && /\d{4}-\d{2}-\d{2}/.test(v))) return fmtDate(v);
return String(v);
};
const ts = new Date();
const nameBase = `articles-${ts.getFullYear()}${pad2(ts.getMonth()+1)}${pad2(ts.getDate())}-${pad2(ts.getHours())}${pad2(ts.getMinutes())}${pad2(ts.getSeconds())}`;
if (format === 'json') {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=${nameBase}.json`);
return res.send(JSON.stringify(rows));
}
if (format === 'xlsx') {
const xlsx = require('xlsx');
const ordered = rows.map(r => {
const o = {};
for (const h of headers) o[h] = r[h];
return o;
});
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(ordered, { header: headers });
xlsx.utils.book_append_sheet(wb, ws, 'Articles');
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${nameBase}.xlsx`);
return res.send(buffer);
}
// 默认 CSV
const escapeCsv = (s) => {
const str = fmtCell(s);
const needQuote = /[",\n]/.test(str);
const inner = String(str).replace(/"/g, '""');
return needQuote ? `"${inner}"` : inner;
};
const csvLines = [];
csvLines.push(headers.join(','));
for (const r of rows) {
csvLines.push(headers.map(h => escapeCsv(r[h])).join(','));
}
const bom = '\ufeff';
const csv = bom + csvLines.join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=${nameBase}.csv`);
return res.send(csv);
} catch (err) {
return res.status(500).json({ success: false, message: '导出文章失败', error: err.message });
}
});
// 导入文章数据
const multer = require('multer');
const importUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
router.post('/articles/import', importUpload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: '缺少上传文件(file)' });
}
const original = req.file.originalname || 'data';
const ext = (require('path').extname(original).toLowerCase());
let rows = [];
if (ext === '.json') {
try {
const parsed = JSON.parse(req.file.buffer.toString('utf8'));
rows = Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
return res.status(400).json({ success: false, message: 'JSON格式不合法', error: e.message });
}
} else {
const xlsx = require('xlsx');
const wb = xlsx.read(req.file.buffer, { type: 'buffer' });
const first = wb.SheetNames[0];
const ws = wb.Sheets[first];
rows = xlsx.utils.sheet_to_json(ws, { defval: null });
}
if (!rows || rows.length === 0) {
return res.status(400).json({ success: false, message: '没有可导入的数据'});
}
let inserted = 0;
let updated = 0;
const errors = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i] || {};
const title = toNullableString(row.title);
const content = row.content;
if (!title || !content) {
errors.push({ index: i, message: '缺少必要字段title 或 content' });
continue;
}
const payload = normalizeArticlePayload(row);
const id = parseInt(row.id, 10);
try {
const result = await upsertArticle(!Number.isNaN(id) && id > 0 ? id : 0, payload);
if (!Number.isNaN(id) && id > 0) {
if (result.affectedRows > 0) updated += 1; else errors.push({ index: i, message: '更新失败或文章不存在' });
} else {
if (result.affectedRows > 0) inserted += 1; else errors.push({ index: i, message: '新增失败' });
}
} catch (e) {
errors.push({ index: i, message: e.message });
}
}
return res.json({ success: true, total: rows.length, inserted, updated, errors });
} catch (err) {
return res.status(500).json({ success: false, message: '导入文章失败', error: err.message });
}
});
// Create/Update article
router.post('/articles/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
@@ -119,47 +345,15 @@ router.post('/articles/:id', async (req, res) => {
return res.status(400).json({ success: false, message: '缺少必要字段title 或 content' });
}
// Build sanitized payload with defaults and conversions
const payload = {
title: toNullableString(body.title),
titleColor: toNullableString(body.titleColor),
source: toNullableString(body.source),
author: toNullableString(body.author),
url: toNullableString(body.url),
classification: toNullableString(body.classification),
updateTime: new Date(), // auto-generate update time
publishTime: toDateOrNull(body.publishTime),
content: body.content, // keep as-is (required above)
summary: toNullableString(body.summary),
coverImage: toNullableString(body.coverImage),
sortNumber: toInt(body.sortNumber, 0),
viewCount: toInt(body.viewCount, 0),
isDraft: toBooleanTinyInt(body.isDraft, 0),
isPublished: toBooleanTinyInt(body.isPublished, 0),
isTop: toBooleanTinyInt(body.isTop, 0),
isRecommended: toBooleanTinyInt(body.isRecommended, 0),
isHot: toBooleanTinyInt(body.isHot, 0),
isSlideshow: toBooleanTinyInt(body.isSlideshow, 0),
seoTitle: toNullableString(body.seoTitle),
seoKeywords: toNullableString(body.seoKeywords),
seoDescription: toNullableString(body.seoDescription),
};
const payload = normalizeArticlePayload(body);
const result = await upsertArticle(id, payload);
const placeholders = columns.map(() => '?').join(',');
const values = columns.map((c) => payload[c] ?? null);
let sql = '';
if (id === 0) {
sql = `INSERT INTO articles (${columns.join(',')}) VALUES (${placeholders})`;
} else {
sql = `UPDATE articles SET ${columns.map((c) => `${c} = ?`).join(',')} WHERE id = ${id}`;
}
const [result] = await pool.execute(sql, values);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '文章不存在' });
}
return res.json({ success: true, id: result.insertId });
// 对于新增返回 insertId更新返回传入的 id
const newId = (id === 0) ? result.insertId : id;
return res.json({ success: true, id: newId });
} catch (err) {
return res.status(500).json({ success: false, message: '新增文章失败', error: err.message });
}
@@ -187,67 +381,9 @@ router.get('/articles', async (req, res) => {
try {
const pageSize = Math.min(parseInt(req.query.pageSize || '20', 10), 100);
const page = Math.max(parseInt(req.query.page || '1', 10), 1);
const offset = (page - 1) * pageSize;
// 构造 WHERE 条件
const where = [];
const params = [];
// 标题模糊查询
if (req.query.title != null) {
const t = toNullableString(req.query.title);
if (t) {
where.push('title LIKE ?');
params.push(`%${t}%`);
}
}
// 分类精准查询
if (req.query.classification != null) {
const cls = toNullableString(req.query.classification);
if (cls) {
where.push('classification = ?');
params.push(cls);
}
}
// 状态布尔筛选
const statusKeys = ['isDraft', 'isPublished', 'isTop', 'isRecommended', 'isHot', 'isSlideshow', 'isDeleted'];
for (const key of statusKeys) {
if (req.query[key] != null) {
const v = toBooleanTinyInt(req.query[key], null);
if (v !== null) {
where.push(`${key} = ?`);
params.push(v);
}
}
}
// 发布日期范围查询
const start = toDateOrNull(req.query.startTime);
const end = toDateOrNull(req.query.endTime);
if (start && end) {
where.push('publishTime BETWEEN ? AND ?');
params.push(start, end);
} else if (start) {
where.push('publishTime >= ?');
params.push(start);
} else if (end) {
where.push('publishTime <= ?');
params.push(end);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const sql = `SELECT id, ${columns.join(',')} FROM articles ${whereSql} ORDER BY isTop DESC, sortNumber DESC, publishTime DESC LIMIT ${pageSize} OFFSET ${offset}`;
const [rows] = await pool.execute(sql, params);
// 计算总页数
const countSql = `SELECT COUNT(*) as total FROM articles ${whereSql}`;
const [countRows] = await pool.execute(countSql, params);
const total = countRows[0].total;
const { rows, total } = await fetchArticles(req.query, { page, pageSize });
const totalPages = Math.ceil(total / pageSize);
return res.json({ success: true, page, pageSize, total, totalPages, data: rows });
} catch (err) {
return res.status(500).json({ success: false, message: '获取文章列表失败', error: err.message });
@@ -261,7 +397,7 @@ router.delete('/articles/:id', async (req, res) => {
if (Number.isNaN(id)) {
return res.status(400).json({ success: false, message: 'id不合法' });
}
const sql = `UPDATE articles SET isDeleted = 1 WHERE id = ?`;
const sql = `UPDATE articles SET isDeleted = 1, updateTime = ${new Date()} WHERE id = ?`;
const [result] = await pool.execute(sql, [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '文章不存在' });
@@ -279,7 +415,7 @@ router.post('/articles/recover/:id', async (req, res) => {
if (Number.isNaN(id)) {
return res.status(400).json({ success: false, message: 'id不合法' });
}
const sql = `UPDATE articles SET isDeleted = 0 WHERE id = ?`;
const sql = `UPDATE articles SET isDeleted = 0, updateTime = ${new Date()} WHERE id = ?`;
const [result] = await pool.execute(sql, [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '文章不存在' });

249
src/routes/upload.js Normal file
View File

@@ -0,0 +1,249 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const router = express.Router();
// 上传目录根
const uploadRoot = path.resolve(__dirname, '..', '..', 'upload');
fs.mkdirSync(uploadRoot, { recursive: true });
function toBoolean(val, def = false) {
if (val === true) return true;
if (val === false) return false;
if (typeof val === 'string') {
const s = val.trim().toLowerCase();
if (s === 'true' || s === '1') return true;
if (s === 'false' || s === '0') return false;
}
if (val === 1) return true;
if (val === 0) return false;
return def;
}
function safeSubdir(val) {
if (!val) return '';
let sub = String(val).replace(/\\/g, '/'); // 统一分隔符
// 去除前后斜杠
sub = sub.replace(/^\/+|\/+$/g, '');
// 仅允许字母数字、下划线、短横线、斜杠
if (!/^[-_/a-zA-Z0-9]+$/.test(sub)) return '';
return sub;
}
// 检查是否为视频文件
function isVideoFile(mimetype, filename) {
const videoMimes = [
'video/mp4', 'video/avi', 'video/mov', 'video/wmv', 'video/flv',
'video/webm', 'video/mkv', 'video/3gp', 'video/m4v', 'video/quicktime'
];
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv', '.3gp', '.m4v'];
if (videoMimes.includes(mimetype)) return true;
const ext = path.extname(filename).toLowerCase();
return videoExts.includes(ext);
}
// 获取视频元数据(时长和分辨率)
function getVideoMetadata(filePath) {
return new Promise((resolve) => {
// 尝试使用系统的ffprobe
const ffprobe = spawn('ffprobe', [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
filePath
]);
let output = '';
let errorOutput = '';
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.stderr.on('data', (data) => {
errorOutput += data.toString();
});
ffprobe.on('close', (code) => {
if (code === 0 && output) {
try {
const metadata = JSON.parse(output);
const videoStream = metadata.streams?.find(s => s.codec_type === 'video');
const result = {
duration: 0,
width: 0,
height: 0,
resolution: '0x0'
};
// 获取时长(秒)
if (metadata.format?.duration) {
result.duration = Math.round(parseFloat(metadata.format.duration));
}
// 获取分辨率
if (videoStream) {
result.width = videoStream.width || 0;
result.height = videoStream.height || 0;
result.resolution = `${result.width}x${result.height}`;
}
resolve(result);
} catch (err) {
console.warn('解析视频元数据失败:', err.message);
resolve({ duration: 0, width: 0, height: 0, resolution: '0x0' });
}
} else {
console.warn('ffprobe执行失败:', errorOutput || 'Unknown error');
resolve({ duration: 0, width: 0, height: 0, resolution: '0x0' });
}
});
ffprobe.on('error', (err) => {
console.warn('ffprobe不可用:', err.message);
resolve({ duration: 0, width: 0, height: 0, resolution: '0x0' });
});
});
}
// 存储策略
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const subdir = safeSubdir(req.body?.dir || req.query?.dir);
const targetDir = subdir ? path.join(uploadRoot, subdir) : uploadRoot;
// 保证不跳出根目录
const resolved = path.resolve(targetDir);
if (!resolved.startsWith(uploadRoot)) {
return cb(null, uploadRoot);
}
fs.mkdirSync(resolved, { recursive: true });
cb(null, resolved);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const base = path.basename(file.originalname, ext);
const prefix = (req.body?.prefix || req.query?.prefix || '').toString().trim();
const keepName = toBoolean(req.body?.keepName ?? req.query?.keepName, false);
const unique = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
let safeBase = base.replace(/[^a-zA-Z0-9-_\.]/g, '_');
let name;
if (keepName) {
name = (prefix ? prefix + '-' : '') + safeBase + '-' + unique + ext;
} else {
name = (prefix ? prefix + '-' : '') + unique + ext;
}
cb(null, name);
},
});
// 限制大小为 100MB
const upload = multer({
storage,
limits: { fileSize: 100 * 1024 * 1024 },
});
// 单文件上传
router.post('/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: '未接收到文件,字段名应为 file' });
}
const host = req.get('host');
const protocol = req.protocol;
const relDir = path.relative(uploadRoot, req.file.destination).replace(/\\/g, '/');
const publicDir = relDir ? `/upload/${relDir}` : '/upload';
const publicPath = `${publicDir}/${req.file.filename}`;
const url = `${protocol}://${host}${publicPath}`;
// 基础文件信息
const fileInfo = {
success: true,
url,
path: publicPath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
mimeType: req.file.mimetype,
data: { url, path: publicPath },
};
// 如果是视频文件,获取视频元数据
if (isVideoFile(req.file.mimetype, req.file.originalname)) {
try {
const videoMetadata = await getVideoMetadata(req.file.path);
fileInfo.duration = videoMetadata.duration;
fileInfo.width = videoMetadata.width;
fileInfo.height = videoMetadata.height;
fileInfo.resolution = videoMetadata.resolution;
fileInfo.isVideo = true;
// 同时添加到data对象中方便前端使用
fileInfo.data.duration = videoMetadata.duration;
fileInfo.data.width = videoMetadata.width;
fileInfo.data.height = videoMetadata.height;
fileInfo.data.resolution = videoMetadata.resolution;
fileInfo.data.isVideo = true;
} catch (err) {
console.warn('获取视频元数据失败:', err.message);
// 即使获取元数据失败,也标记为视频文件
fileInfo.isVideo = true;
fileInfo.duration = 0;
fileInfo.width = 0;
fileInfo.height = 0;
fileInfo.resolution = '0x0';
fileInfo.data.isVideo = true;
fileInfo.data.duration = 0;
fileInfo.data.width = 0;
fileInfo.data.height = 0;
fileInfo.data.resolution = '0x0';
}
} else {
fileInfo.isVideo = false;
fileInfo.data.isVideo = false;
}
return res.json(fileInfo);
} catch (err) {
return res.status(500).json({ success: false, message: '上传失败', error: err.message });
}
});
// 多文件上传
router.post('/uploads', upload.array('files', 10), async (req, res) => {
try {
const files = req.files || [];
if (!files.length) {
return res.status(400).json({ success: false, message: '未接收到文件,字段名应为 files' });
}
const host = req.get('host');
const protocol = req.protocol;
const list = files.map((f) => {
const relDir = path.relative(uploadRoot, f.destination).replace(/\\/g, '/');
const publicDir = relDir ? `/upload/${relDir}` : '/upload';
const publicPath = `${publicDir}/${f.filename}`;
const url = `${protocol}://${host}${publicPath}`;
return {
url,
path: publicPath,
filename: f.filename,
originalName: f.originalname,
size: f.size,
mimeType: f.mimetype,
};
});
return res.json({ success: true, files: list });
} catch (err) {
return res.status(500).json({ success: false, message: '批量上传失败', error: err.message });
}
});
module.exports = router;

375
src/routes/videos.js Normal file
View File

@@ -0,0 +1,375 @@
const express = require('express');
const { pool } = require('../db');
const router = express.Router();
const columns = [
'title',
'titleColor',
'url',
'publishTime',
'content',
'articleNumber',
'coverImage',
'videoUrl',
'classification',
'duration',
'fileSize',
'sortOrder',
'viewCount',
'isPublished',
'isTop',
'isRecommended',
'isHot',
'isSlideshow',
'seoTitle',
'seoKeywords',
'seoDescription',
];
// Helpers for robust type conversion and defaults
function toNullableString(val) {
if (val == null) return null;
if (typeof val !== 'string') return String(val);
const trimmed = val.trim();
return trimmed === '' ? null : trimmed;
}
function toInt(val, def = 0) {
if (val == null || val === '') return def;
const n = parseInt(val, 10);
return Number.isNaN(n) ? def : n;
}
function toBigInt(val, def = 0) {
if (val == null || val === '') return def;
const n = parseInt(val, 10);
return Number.isNaN(n) ? def : n;
}
function toBooleanTinyInt(val, def = 0) {
if (val === true) return 1;
if (val === false) return 0;
if (typeof val === 'string') {
const s = val.trim().toLowerCase();
if (s === 'true') return 1;
if (s === 'false') return 0;
if (s === '1') return 1;
if (s === '0') return 0;
}
if (typeof val === 'number') {
return val > 0 ? 1 : 0;
}
return def;
}
function toDateOrNull(val) {
if (!val) return null;
const d = new Date(val);
return Number.isNaN(d.getTime()) ? null : d;
}
// Build WHERE clause for video filtering
function buildVideoFilter(query) {
const conditions = [];
const params = [];
if (query.title) {
conditions.push('title LIKE ?');
params.push(`%${query.title}%`);
}
if (query.classification) {
conditions.push('classification = ?');
params.push(query.classification);
}
if (query.articleNumber) {
conditions.push('article_number LIKE ?');
params.push(`%${query.articleNumber}%`);
}
if (query.isPublished !== undefined && query.isPublished !== '') {
conditions.push('is_published = ?');
params.push(toBooleanTinyInt(query.isPublished));
}
if (query.isTop !== undefined && query.isTop !== '') {
conditions.push('is_top = ?');
params.push(toBooleanTinyInt(query.isTop));
}
if (query.isRecommended !== undefined && query.isRecommended !== '') {
conditions.push('is_recommended = ?');
params.push(toBooleanTinyInt(query.isRecommended));
}
if (query.isHot !== undefined && query.isHot !== '') {
conditions.push('is_hot = ?');
params.push(toBooleanTinyInt(query.isHot));
}
if (query.isSlideshow !== undefined && query.isSlideshow !== '') {
conditions.push('is_slideshow = ?');
params.push(toBooleanTinyInt(query.isSlideshow));
}
if (query.startTime) {
conditions.push('publish_time >= ?');
params.push(query.startTime);
}
if (query.endTime) {
conditions.push('publish_time <= ?');
params.push(query.endTime);
}
// 软删除过滤
conditions.push('deleted_at IS NULL');
return { conditions, params };
}
// Fetch videos with filtering and pagination
async function fetchVideos(query, opts = {}) {
const { conditions, params } = buildVideoFilter(query);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
let orderBy = 'ORDER BY sort_order DESC, publish_time DESC';
if (query.sortBy) {
const sortField = query.sortBy === 'publishTime' ? 'publish_time' : 'sort_order';
const sortOrder = query.sortOrder === 'asc' ? 'ASC' : 'DESC';
orderBy = `ORDER BY ${sortField} ${sortOrder}`;
}
const sql = `SELECT * FROM videos ${whereClause} ${orderBy}`;
const [rows] = await pool.execute(sql, params);
return rows;
}
// Normalize video payload
function normalizeVideoPayload(body) {
return {
title: toNullableString(body.title),
title_color: toNullableString(body.titleColor) || '#000000',
url: toNullableString(body.url),
publish_time: toDateOrNull(body.publishTime) || new Date(),
content: toNullableString(body.content),
article_number: toNullableString(body.articleNumber),
cover_image: toNullableString(body.coverImage),
video_url: toNullableString(body.videoUrl),
classification: toNullableString(body.classification),
duration: toInt(body.duration, 0),
file_size: toBigInt(body.fileSize, 0),
sort_order: toInt(body.sortOrder, 0),
view_count: toInt(body.viewCount, 0),
is_published: toBooleanTinyInt(body.isPublished, 0),
is_top: toBooleanTinyInt(body.isTop, 0),
is_recommended: toBooleanTinyInt(body.isRecommended, 0),
is_hot: toBooleanTinyInt(body.isHot, 0),
is_slideshow: toBooleanTinyInt(body.isSlideshow, 0),
seo_title: toNullableString(body.seoTitle),
seo_keywords: toNullableString(body.seoKeywords),
seo_description: toNullableString(body.seoDescription),
};
}
// Upsert video (insert or update)
async function upsertVideo(id, payload) {
if (id && id !== 'new') {
const sql = `UPDATE videos SET ${Object.keys(payload).map(k => `${k} = ?`).join(', ')}, updated_at = NOW() WHERE id = ?`;
const params = [...Object.values(payload), id];
await pool.execute(sql, params);
return id;
} else {
const sql = `INSERT INTO videos (${Object.keys(payload).join(', ')}) VALUES (${Object.keys(payload).map(() => '?').join(', ')})`;
const [result] = await pool.execute(sql, Object.values(payload));
return result.insertId;
}
}
// 发布视频
router.post('/videos/publish', async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: '请提供要发布的视频ID数组' });
}
const placeholders = ids.map(() => '?').join(',');
const sql = `UPDATE videos SET is_published = 1, updated_at = NOW() WHERE id IN (${placeholders}) AND deleted_at IS NULL`;
const [result] = await pool.execute(sql, ids);
res.json({
success: true,
message: `成功发布 ${result.affectedRows} 个视频`,
affectedRows: result.affectedRows
});
} catch (error) {
console.error('发布视频失败:', error);
res.status(500).json({ success: false, message: '发布视频失败' });
}
});
// 取消发布视频
router.post('/videos/unpublish', async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: '请提供要取消发布的视频ID数组' });
}
const placeholders = ids.map(() => '?').join(',');
const sql = `UPDATE videos SET is_published = 0, updated_at = NOW() WHERE id IN (${placeholders}) AND deleted_at IS NULL`;
const [result] = await pool.execute(sql, ids);
res.json({
success: true,
message: `成功取消发布 ${result.affectedRows} 个视频`,
affectedRows: result.affectedRows
});
} catch (error) {
console.error('取消发布视频失败:', error);
res.status(500).json({ success: false, message: '取消发布视频失败' });
}
});
// 创建或更新视频
router.post('/videos/:id', async (req, res) => {
try {
const { id } = req.params;
const payload = normalizeVideoPayload(req.body);
const videoId = await upsertVideo(id, payload);
const [rows] = await pool.execute('SELECT * FROM videos WHERE id = ?', [videoId]);
const video = rows[0];
res.json({
success: true,
message: id === 'new' ? '视频创建成功' : '视频更新成功',
data: video
});
} catch (error) {
console.error('保存视频失败:', error);
res.status(500).json({ success: false, message: '保存视频失败' });
}
});
// 获取单个视频
router.get('/videos/:id', async (req, res) => {
try {
const { id } = req.params;
const [rows] = await pool.execute('SELECT * FROM videos WHERE id = ? AND deleted_at IS NULL', [id]);
if (rows.length === 0) {
return res.status(404).json({ success: false, message: '视频不存在' });
}
res.json({ success: true, data: rows[0] });
} catch (error) {
console.error('获取视频失败:', error);
res.status(500).json({ success: false, message: '获取视频失败' });
}
});
// 获取视频列表
router.get('/videos', async (req, res) => {
try {
const page = toInt(req.query.page, 1);
const pageSize = toInt(req.query.pageSize, 10);
const offset = (page - 1) * pageSize;
const { conditions, params } = buildVideoFilter(req.query);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
let orderBy = 'ORDER BY sort_order DESC, publish_time DESC';
if (req.query.sortBy) {
const sortField = req.query.sortBy === 'publishTime' ? 'publish_time' : 'sort_order';
const sortOrder = req.query.sortOrder === 'asc' ? 'ASC' : 'DESC';
orderBy = `ORDER BY ${sortField} ${sortOrder}`;
}
// 获取总数
const countSql = `SELECT COUNT(*) as total FROM videos ${whereClause}`;
const [countResult] = await pool.execute(countSql, params);
const total = countResult[0].total;
// 获取分页数据
const sql = `SELECT * FROM videos ${whereClause} ${orderBy} LIMIT ? OFFSET ?`;
const [rows] = await pool.execute(sql, [...params, pageSize, offset]);
res.json({
success: true,
data: rows,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
});
} catch (error) {
console.error('获取视频列表失败:', error);
res.status(500).json({ success: false, message: '获取视频列表失败' });
}
});
// 逻辑删除视频
router.delete('/videos/:id', async (req, res) => {
try {
const { id } = req.params;
const [result] = await pool.execute(
'UPDATE videos SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '视频不存在或已被删除' });
}
res.json({ success: true, message: '视频删除成功' });
} catch (error) {
console.error('删除视频失败:', error);
res.status(500).json({ success: false, message: '删除视频失败' });
}
});
// 恢复视频
router.post('/videos/recover/:id', async (req, res) => {
try {
const { id } = req.params;
const [result] = await pool.execute(
'UPDATE videos SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL',
[id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '视频不存在或未被删除' });
}
res.json({ success: true, message: '视频恢复成功' });
} catch (error) {
console.error('恢复视频失败:', error);
res.status(500).json({ success: false, message: '恢复视频失败' });
}
});
// 物理删除视频
router.delete('/videos/force/:id', async (req, res) => {
try {
const { id } = req.params;
const [result] = await pool.execute('DELETE FROM videos WHERE id = ?', [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '视频不存在' });
}
res.json({ success: true, message: '视频永久删除成功' });
} catch (error) {
console.error('永久删除视频失败:', error);
res.status(500).json({ success: false, message: '永久删除视频失败' });
}
});
module.exports = router;