From 23f500f6236d057c972f5d6a79c4895d105cbd77 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Sat, 31 Oct 2026 10:43:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(community):=20=E5=8F=8B=E5=9C=88=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=94=AF=E6=8C=81=E4=B8=8A=E4=BC=A0=E7=9F=AD=E8=A7=86?= =?UTF-8?q?=E9=A2=91=EF=BC=8C=E5=8F=AF=E4=B8=8E=E5=9B=BE=E7=89=87=E5=90=8C?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /api/upload/video 独立上传接口:仅 MP4/MOV、单条 30MB、独立限流、登录鉴权 - posts 表新增 video_url / video_cover,发布接口支持视频与图片同时落库 - 小程序发布器支持选择/预览/删除视频(≤60 秒),动态卡片与详情页支持播放 - 后台动态管理新增视频预览弹窗 - 新增短视频链路与上传鉴权回归测试,125 套件/818 用例全绿 --- .../src/views/community/post-manage/index.vue | 41 ++++- pwuniapp/common/api.js | 41 +++-- pwuniapp/common/icons.js | 7 + pwuniapp/pages/community/detail.vue | 26 +++ pwuniapp/pages/community/index.vue | 152 +++++++++++++++++- .../core/security/content-security.service.ts | 44 +++++ server/src/db/mysql.ts | 10 +- server/src/db/repository.ts | 25 ++- server/src/index.ts | 94 +++++++++-- server/tests/community-video.spec.ts | 63 ++++++++ server/tests/upload-auth.spec.ts | 11 +- 11 files changed, 478 insertions(+), 36 deletions(-) create mode 100644 server/tests/community-video.spec.ts diff --git a/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue b/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue index 692c09e..6d7a080 100644 --- a/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue +++ b/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue @@ -105,8 +105,12 @@ - + @@ -214,6 +221,23 @@ + + + + + ([]); + // 短视频预览 + const videoPreviewVisible = ref(false); + const videoPreviewUrl = ref(''); + const videoPreviewCover = ref(''); + const videoPreviewTitle = ref('动态短视频预览'); + + const openVideoPreview = (record: any) => { + videoPreviewUrl.value = record.videoUrl || ''; + videoPreviewCover.value = record.videoCover || ''; + videoPreviewTitle.value = `动态短视频 - ${record.author || ''}`; + videoPreviewVisible.value = true; + }; + const fetchData = async () => { loading.value = true; try { diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 584f7ca..7dddddb 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -1008,13 +1008,15 @@ function authHeader() { } /** - * 真实上传本地图片至服务端持久化静态存储 - * @param {string} tempFilePath 本地图片临时路径 (如 wxfile://tmp_xxx 或 blob:xxx) - * @returns {Promise} 返回持久化公网图片访问路径 (形如 /uploads/img_xxx.png) + * 上传本地临时文件至服务端持久化静态存储 + * @param {string} tempFilePath 本地临时路径 (如 wxfile://tmp_xxx 或 blob:xxx) + * @param {string} endpoint 上传接口路径 (图片 / 视频分别使用独立接口) + * @param {string} kindLabel 媒体类型中文名,用于错误提示 + * @returns {Promise} 返回持久化公网访问路径 (形如 /uploads/xxx) */ -export function uploadImage(tempFilePath) { +function uploadMediaFile(tempFilePath, endpoint, kindLabel) { return new Promise((resolve, reject) => { - if (!tempFilePath) return reject(new Error('图片路径为空')); + if (!tempFilePath) return reject(new Error(`${kindLabel}路径为空`)); // 微信开发者工具临时路径形如 http://tmp/ 或 http://usr/ // 微信真机临时路径形如 wxfile://tmp_xxx 或 wxfile://clientdata/xxx @@ -1042,10 +1044,10 @@ export function uploadImage(tempFilePath) { } // #ifdef H5 - const uploadUrl = '/api/upload'; + const uploadUrl = endpoint; // #endif // #ifndef H5 - const uploadUrl = 'https://peiww.gynhc.com/api/upload'; + const uploadUrl = `https://peiww.gynhc.com${endpoint}`; // #endif uni.uploadFile({ @@ -1058,7 +1060,7 @@ export function uploadImage(tempFilePath) { const data = JSON.parse(uploadRes.data); if (data && data.code === 20000 && data.data && data.data.url) { let finalUrl = data.data.url; - // 非H5平台(小程序端)图片访问需补全公网域名 + // 非H5平台(小程序端)媒体访问需补全公网域名 // #ifndef H5 if (finalUrl.startsWith('/uploads/')) { finalUrl = `https://peiww.gynhc.com${finalUrl}`; @@ -1066,14 +1068,14 @@ export function uploadImage(tempFilePath) { // #endif resolve(finalUrl); } else { - reject(new Error((data && data.message) || '上传解析异常')); + reject(new Error((data && data.message) || `${kindLabel}上传解析异常`)); } } catch (e) { reject(new Error('上传数据返回格式错误')); } }, fail: (err) => { - reject(new Error(err.errMsg || '图片上传网络异常')); + reject(new Error(err.errMsg || `${kindLabel}上传网络异常`)); } }); }); @@ -1081,6 +1083,25 @@ export function uploadImage(tempFilePath) { /** * 客户端前端前置检测文本安全 (对接 security.msgSecCheck) +/** + * 真实上传本地图片至服务端持久化静态存储 + * @param {string} tempFilePath 本地图片临时路径 + * @returns {Promise} 返回持久化公网图片访问路径 (形如 /uploads/img_xxx.png) + */ +export function uploadImage(tempFilePath) { + return uploadMediaFile(tempFilePath, '/api/upload', '图片'); +} + +/** + * 真实上传本地短视频至服务端持久化静态存储 + * @param {string} tempFilePath 本地视频临时路径 + * @returns {Promise} 返回持久化公网视频访问路径 (形如 /uploads/video_xxx.mp4) + */ +export function uploadVideo(tempFilePath) { + return uploadMediaFile(tempFilePath, '/api/upload/video', '视频'); +} + + */ export async function checkTextSecurity(content) { try { diff --git a/pwuniapp/common/icons.js b/pwuniapp/common/icons.js index 5e95b70..a279910 100644 --- a/pwuniapp/common/icons.js +++ b/pwuniapp/common/icons.js @@ -71,6 +71,13 @@ export const ICONS = { `), + videoCamera: svgToUri(` + + + + + `), + plus: svgToUri(` diff --git a/pwuniapp/pages/community/detail.vue b/pwuniapp/pages/community/detail.vue index 0d04d05..a54165b 100644 --- a/pwuniapp/pages/community/detail.vue +++ b/pwuniapp/pages/community/detail.vue @@ -76,6 +76,19 @@ #{{ t }} + + + + + { } } + .detail-video-wrap { + margin-bottom: 28rpx; + border-radius: 20rpx; + overflow: hidden; + background: #000000; + + .detail-video { + width: 100%; + height: 420rpx; + display: block; + } + } + .detail-images-grid { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/pwuniapp/pages/community/index.vue b/pwuniapp/pages/community/index.vue index e2abc99..e8f0d4e 100644 --- a/pwuniapp/pages/community/index.vue +++ b/pwuniapp/pages/community/index.vue @@ -123,6 +123,20 @@ #{{ t }} + + + + + - + + + + + + {{ formatVideoDuration(newPostForm.video.duration) }} + + + + + @@ -248,6 +278,17 @@ 添加图片 {{ newPostForm.images.length }}/9 + + + + + 添加视频 + ≤60秒 + @@ -405,7 +446,7 @@ import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { ensureLoginOrRedirect } from '@/common/role.js'; import { getNavBarInfo } from '@/common/nav.js'; -import { getPosts, createPost, deletePost, likePost, viewPost, getPostComments, addPostComment, applyPostParty, getPostApplies, handlePostApply, uploadImage, getPlatformConfigs, getUserProfile, normalizeUserId } from '@/common/api.js'; +import { getPosts, createPost, deletePost, likePost, viewPost, getPostComments, addPostComment, applyPostParty, getPostApplies, handlePostApply, uploadImage, uploadVideo, getPlatformConfigs, getUserProfile, normalizeUserId } from '@/common/api.js'; import { useShare } from '@/common/share.js'; useShare({ @@ -997,6 +1038,7 @@ const newPostForm = ref({ content: '', selectedTopics: [], images: [], + video: null, location: '' }); @@ -1045,6 +1087,49 @@ const choosePostImage = () => { }); }; +const MAX_VIDEO_SIZE = 30 * 1024 * 1024; + +const formatVideoDuration = (seconds) => { + const total = Math.max(0, Math.floor(Number(seconds) || 0)); + const mm = String(Math.floor(total / 60)).padStart(2, '0'); + const ss = String(total % 60).padStart(2, '0'); + return `${mm}:${ss}`; +}; + +const choosePostVideo = () => { + if (newPostForm.value.video) { + uni.showToast({ title: '每条动态最多上传 1 个视频', icon: 'none' }); + return; + } + uni.chooseVideo({ + sourceType: ['album', 'camera'], + compressed: true, + maxDuration: 60, + camera: 'back', + success: (res) => { + if (res.size && res.size > MAX_VIDEO_SIZE) { + uni.showToast({ title: '视频超过 30MB,请压缩或选择更短的视频', icon: 'none' }); + return; + } + newPostForm.value.video = { + path: res.tempFilePath, + cover: res.thumbTempFilePath || '', + duration: Number(res.duration) || 0, + size: Number(res.size) || 0 + }; + }, + fail: (err) => { + // 用户主动取消不提示 + if (err && String(err.errMsg || '').indexOf('cancel') > -1) return; + uni.showToast({ title: '视频选择失败,请重试', icon: 'none' }); + } + }); +}; + +const removeVideo = () => { + newPostForm.value.video = null; +}; + const removeImage = (idx) => { newPostForm.value.images.splice(idx, 1); }; @@ -1110,16 +1195,30 @@ const submitPublishPost = async () => { const authorAvatar = profile.avatar || ''; let finalImages = []; + let finalVideoUrl = ''; + let finalVideoCover = ''; try { if (newPostForm.value.images.length > 0) { finalImages = await Promise.all( newPostForm.value.images.map(img => uploadImage(img)) ); } + if (newPostForm.value.video) { + // 先传封面再传视频,封面失败不阻塞视频发布 + if (newPostForm.value.video.cover) { + try { + finalVideoCover = await uploadImage(newPostForm.value.video.cover); + } catch (coverErr) { + console.warn('[Community] 视频封面上传失败,继续发布:', coverErr); + finalVideoCover = ''; + } + } + finalVideoUrl = await uploadVideo(newPostForm.value.video.path); + } } catch (uploadErr) { - console.warn('[Community] 图片上传失败:', uploadErr); + console.warn('[Community] 媒体上传失败:', uploadErr); uni.hideLoading(); - uni.showToast({ title: '图片上传失败,请重试后再发布', icon: 'none' }); + uni.showToast({ title: '图片或视频上传失败,请重试后再发布', icon: 'none' }); return; } @@ -1133,6 +1232,8 @@ const submitPublishPost = async () => { content: content, topics: [...newPostForm.value.selectedTopics], images: finalImages, + videoUrl: finalVideoUrl || undefined, + videoCover: finalVideoCover || undefined, actionText: currentMeta.action, category: newPostForm.value.category }; @@ -1155,6 +1256,8 @@ const submitPublishPost = async () => { content: content, topics: postPayload.topics, images: finalImages, + videoUrl: finalVideoUrl, + videoCover: finalVideoCover, actionText: currentMeta.action, likes: 0, liked: false, @@ -1175,6 +1278,7 @@ const submitPublishPost = async () => { closePublishModal(); newPostForm.value.content = ''; newPostForm.value.images = []; + newPostForm.value.video = null; newPostForm.value.selectedTopics = ['三里屯探店']; uni.hideLoading(); @@ -1821,6 +1925,19 @@ const submitPublishPost = async () => { } } + .post-video-wrap { + margin-bottom: 18rpx; + border-radius: 18rpx; + overflow: hidden; + background: #000000; + + .post-video { + width: 100%; + height: 380rpx; + display: block; + } + } + .post-footer { display: flex; align-items: center; @@ -2182,6 +2299,31 @@ const submitPublishPost = async () => { } } } + + /* 视频预览时长角标(主题无关) */ + .grid-video-item { + .thumb-video { + width: 100%; + height: 100%; + display: block; + } + + .video-duration-badge { + position: absolute; + left: 10rpx; + bottom: 10rpx; + padding: 2rpx 12rpx; + border-radius: 999rpx; + background: rgba(15, 23, 42, 0.72); + z-index: 2; + + .video-duration-txt { + font-size: 18rpx; + color: #FFFFFF; + font-weight: 600; + } + } + } } /* 定位信息微胶囊 */ diff --git a/server/src/core/security/content-security.service.ts b/server/src/core/security/content-security.service.ts index 3d4bd8b..e2f0578 100644 --- a/server/src/core/security/content-security.service.ts +++ b/server/src/core/security/content-security.service.ts @@ -194,6 +194,50 @@ export class ContentSecurityService { }; } + /** + * 检查短视频内容合规性 + * 校验容器格式、体积上限与非法可执行后缀,避免伪装文件写入静态目录 + */ + public checkVideo(filename: string, mimetype: string, sizeBytes: number): SecurityCheckResult { + const allowedMime = ['video/mp4', 'video/quicktime', 'video/x-m4v', 'video/3gpp']; + if (!allowedMime.includes(String(mimetype || '').toLowerCase())) { + return { + pass: false, + code: 87014, + suggest: 'block', + message: '仅支持上传 MP4 / MOV 格式的短视频' + }; + } + + // 单个视频体积上限 30MB + if (sizeBytes > 30 * 1024 * 1024) { + return { + pass: false, + code: 87014, + suggest: 'block', + message: '视频体积超过 30MB 上限,请压缩后再上传' + }; + } + + const lowerName = String(filename || '').toLowerCase(); + const badExts = ['.exe', '.sh', '.bat', '.cmd', '.php', '.jsp', '.asp', '.py', '.js', '.html', '.htm']; + if (badExts.some(ext => lowerName.includes(ext))) { + return { + pass: false, + code: 87014, + suggest: 'block', + message: '文件名包含非法可执行后缀,禁止上传' + }; + } + + return { + pass: true, + code: 0, + suggest: 'pass', + message: '视频安全校验通过' + }; + } + /** * 敏感词星号脱敏替换(供展示层兜底脱敏) */ diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 0993676..203326e 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -382,6 +382,8 @@ export async function initDatabase(): Promise { content TEXT NOT NULL, topics JSON, images JSON, + video_url VARCHAR(512) DEFAULT NULL, + video_cover VARCHAR(512) DEFAULT NULL, action_text VARCHAR(32) DEFAULT '加入车队', likes INT DEFAULT 0, comments INT DEFAULT 0, @@ -597,13 +599,19 @@ export async function initDatabase(): Promise { `); } catch (_) {} - // 扩展 posts 表结构: is_top (置顶), status (正常/隐藏) + // 扩展 posts 表结构: is_top (置顶), status (正常/隐藏), 短视频 try { await conn.query('ALTER TABLE posts ADD COLUMN is_top TINYINT DEFAULT 0'); } catch (_) {} try { await conn.query("ALTER TABLE posts ADD COLUMN status VARCHAR(32) DEFAULT '正常'"); } catch (_) {} + try { + await conn.query('ALTER TABLE posts ADD COLUMN video_url VARCHAR(512) DEFAULT NULL AFTER images'); + } catch (_) {} + try { + await conn.query('ALTER TABLE posts ADD COLUMN video_cover VARCHAR(512) DEFAULT NULL AFTER video_url'); + } catch (_) {} // 21. 用户举报工单表 (涉黄/涉赌/线下欺诈等不良陪玩举报) await conn.query(` diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 9a66910..a422c98 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -5077,6 +5077,7 @@ export class MysqlRepository { SELECT id, user_id as userId, author, avatar, tag, tag_type as tagType, location, distance, content, topics, images, action_text as actionText, + video_url as videoUrl, video_cover as videoCover, likes, comments, views, category, is_top as isTop, status, created_at as createdAt FROM posts `; @@ -5204,6 +5205,8 @@ export class MysqlRepository { content: string; topics?: string[]; images?: string[]; + videoUrl?: string; + videoCover?: string; actionText?: string; category?: string; }): Promise { @@ -5239,19 +5242,29 @@ export class MysqlRepository { const location = data.location || '朝阳区 · 三里屯太古里'; const distance = '100m内'; const content = data.content; + // 支持视频与图片同发:视频最多 1 条,图片最多 9 张,由客户端约束 + const normalizeMediaUrl = (v: any): string | null => { + const s = String(v || '').trim(); + if (!s) return null; + if (!/^(https?:\/\/|\/uploads\/)/i.test(s)) return null; + return s.slice(0, 512); + }; + const videoUrl = normalizeMediaUrl(data.videoUrl); + const videoCover = videoUrl ? normalizeMediaUrl(data.videoCover) : null; + const images = Array.isArray(data.images) ? data.images : []; const topicsJson = JSON.stringify(data.topics || []); - const imagesJson = JSON.stringify(data.images || []); + const imagesJson = JSON.stringify(images); const actionText = data.actionText || '加入车队'; const category = data.category || 'partner'; await pool.query(` INSERT INTO posts ( id, user_id, author, avatar, tag, tag_type, location, distance, - content, topics, images, action_text, likes, comments, views, category, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 1, ?, NOW()) + content, topics, images, video_url, video_cover, action_text, likes, comments, views, category, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 1, ?, NOW()) `, [ id, userId, author, avatar, tag, tagType, location, distance, - content, topicsJson, imagesJson, actionText, category + content, topicsJson, imagesJson, videoUrl, videoCover, actionText, category ]); return { @@ -5265,7 +5278,9 @@ export class MysqlRepository { distance, content, topics: data.topics || [], - images: data.images || [], + images, + videoUrl, + videoCover, actionText, likes: 0, comments: 0, diff --git a/server/src/index.ts b/server/src/index.ts index 743d92a..42b47bd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -201,6 +201,31 @@ const upload = multer({ } }); +// 短视频上传引擎(与图片分离:独立目录前缀、独立体积上限与格式白名单) +const videoStorage = multer.diskStorage({ + destination: (_req, _file, cb) => { + cb(null, uploadDir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.mp4'; + const uniqueSuffix = `${Date.now()}_${Math.round(Math.random() * 1e9)}`; + cb(null, `video_${uniqueSuffix}${ext}`); + } +}); + +const uploadVideo = multer({ + storage: videoStorage, + limits: { fileSize: 30 * 1024 * 1024 }, // 限制单个视频最大 30MB + fileFilter: (_req, file, cb) => { + const allowed = ['video/mp4', 'video/quicktime', 'video/x-m4v', 'video/3gpp']; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('仅支持上传 MP4、MOV 格式的短视频文件')); + } + } +}); + // 实例化领域服务(作为核心算法与计算引擎) export const activityService = new ActivityService(); export const orderService = new CompanionOrderService(); @@ -736,20 +761,23 @@ app.all('/api/user/certification', (_req: Request, res: Response) => { }); // 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads) -const handleFileUpload = (req: Request, res: Response) => { +const ensureUploadAuthorized = (req: Request, res: Response, scene: string): boolean => { // 上传要求登录,避免匿名上传被滥用(刷盘/托管非法内容)。 // 兼容管理后台管理员身份与小程序客户端用户身份。 // 不依赖 NODE_ENV:只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。 - if (SECURE_MODE) { - const isAdmin = isVerifiedAdminRequest(req); - if (!isAdmin) { - try { - resolveSecureUserId(req, 0, '上传文件'); - } catch (e: any) { - return res.status(e.status || 401).json({ code: e.code || 40100, message: e.message || '请先登录后再上传' }); - } - } + if (!SECURE_MODE) return true; + if (isVerifiedAdminRequest(req)) return true; + try { + resolveSecureUserId(req, 0, scene); + return true; + } catch (e: any) { + res.status(e.status || 401).json({ code: e.code || 40100, message: e.message || '请先登录后再上传' }); + return false; } +}; + +const handleFileUpload = (req: Request, res: Response) => { + if (!ensureUploadAuthorized(req, res, '上传文件')) return; upload.single('file')(req, res, (err: any) => { if (err) { return res.status(400).json({ @@ -795,8 +823,54 @@ const handleFileUpload = (req: Request, res: Response) => { }); }; +// 短视频上传接口:单条 30MB 以内,仅 MP4/MOV,独立限流避免刷盘 +const handleVideoUpload = (req: Request, res: Response) => { + if (!ensureUploadAuthorized(req, res, '上传视频')) return; + uploadVideo.single('file')(req, res, (err: any) => { + if (err) { + return res.status(400).json({ + code: 40000, + message: err.message || '视频上传失败' + }); + } + if (!req.file) { + return res.status(400).json({ + code: 40000, + message: '未接收到有效视频文件,请使用 file 字段提交' + }); + } + + const videoCheck = contentSecurityService.checkVideo( + req.file.originalname, + req.file.mimetype, + req.file.size + ); + if (!videoCheck.pass) { + try { + fs.unlinkSync(req.file.path); + } catch (e) {} + return res.status(400).json({ + code: videoCheck.code || 87014, + message: videoCheck.message + }); + } + + res.json({ + code: 20000, + data: { + url: `/uploads/${req.file.filename}`, + filename: req.file.filename, + size: req.file.size, + mimetype: req.file.mimetype + }, + message: '视频上传成功并已持久化存储' + }); + }); +}; + app.post('/api/upload', rateLimit({ name: 'upload', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), handleFileUpload); app.post('/api/user/upload', rateLimit({ name: 'upload_user', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), handleFileUpload); +app.post('/api/upload/video', rateLimit({ name: 'upload_video', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), handleVideoUpload); // 微信官方内容安全文本检测接口 (对接 security.msgSecCheck) app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => { diff --git a/server/tests/community-video.spec.ts b/server/tests/community-video.spec.ts new file mode 100644 index 0000000..5fd754a --- /dev/null +++ b/server/tests/community-video.spec.ts @@ -0,0 +1,63 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 友圈短视频发布链路回归闸门: + * - 上传必须走独立接口、独立限流与体积/格式白名单; + * - posts 表必须持久化 video_url / video_cover; + * - 视频与图片二选一,避免混排; + * - 小程序与后台都必须能播放/预览视频。 + */ +describe('友圈短视频发布链路', () => { + const root = path.resolve(__dirname, '../../'); + const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8'); + const indexCode = read('server/src/index.ts'); + const mysql = read('server/src/db/mysql.ts'); + const repo = read('server/src/db/repository.ts'); + const security = read('server/src/core/security/content-security.service.ts'); + const api = read('pwuniapp/common/api.js'); + const community = read('pwuniapp/pages/community/index.vue'); + const detail = read('pwuniapp/pages/community/detail.vue'); + const admin = read('admin/arco-design-pro-vite/src/views/community/post-manage/index.vue'); + + it('短视频上传必须使用独立接口、独立限流与 30MB 上限', () => { + expect(indexCode).toContain("app.post('/api/upload/video'"); + expect(indexCode).toContain('uploadVideo'); + expect(indexCode).toContain('30 * 1024 * 1024'); + expect(indexCode).toContain('video/mp4'); + }); + + it('短视频上传必须要求登录并做格式/体积校验', () => { + expect(security).toContain('public checkVideo'); + expect(security).toContain('视频体积超过 30MB 上限'); + expect(indexCode).toContain("ensureUploadAuthorized(req, res, '上传视频')"); + }); + + it('posts 表必须持久化 video_url / video_cover', () => { + expect(mysql).toContain('video_url VARCHAR(512)'); + expect(mysql).toContain('video_cover VARCHAR(512)'); + expect(repo).toContain('video_url as videoUrl'); + expect(repo).toContain('INSERT INTO posts'); + expect(repo).toContain('video_url, video_cover'); + }); + + it('视频与图片可同发:两条媒体链路都要落库', () => { + expect(repo).toContain("const images = Array.isArray(data.images) ? data.images : [];"); + expect(repo).toContain('videoUrl, videoCover, actionText, category'); + }); + + it('小程序必须支持选择、上传与播放短视频', () => { + expect(api).toContain('export function uploadVideo'); + expect(api).toContain("'/api/upload/video'"); + expect(community).toContain('uni.chooseVideo'); + expect(community).toContain('uploadVideo(newPostForm.value.video.path)'); + expect(community).toContain('post.videoUrl'); + expect(detail).toContain('post.videoUrl'); + }); + + it('后台动态管理必须可预览短视频', () => { + expect(admin).toContain('openVideoPreview'); + expect(admin).toContain('record.videoUrl'); + }); +}); diff --git a/server/tests/upload-auth.spec.ts b/server/tests/upload-auth.spec.ts index 46df540..74cf863 100644 --- a/server/tests/upload-auth.spec.ts +++ b/server/tests/upload-auth.spec.ts @@ -10,11 +10,16 @@ describe('第四十五批上传鉴权闸门', () => { const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); it('上传必须要求登录(安全闸门不依赖 NODE_ENV)', () => { - const start = indexCode.indexOf('const handleFileUpload ='); + const start = indexCode.indexOf('const ensureUploadAuthorized ='); expect(start).toBeGreaterThan(-1); - const block = indexCode.slice(start, start + 500); + const block = indexCode.slice(start, start + 700); expect(block).toContain('SECURE_MODE'); - expect(block).toContain("resolveSecureUserId(req, 0, '上传文件')"); + expect(block).toContain('resolveSecureUserId(req, 0, scene)'); expect(block).toContain('请先登录后再上传'); }); + + it('图片与视频上传都必须走同一登录闸门', () => { + expect(indexCode).toContain("ensureUploadAuthorized(req, res, '上传文件')"); + expect(indexCode).toContain("ensureUploadAuthorized(req, res, '上传视频')"); + }); });