feat(community): 友圈动态支持上传短视频,可与图片同发
- 新增 /api/upload/video 独立上传接口:仅 MP4/MOV、单条 30MB、独立限流、登录鉴权 - posts 表新增 video_url / video_cover,发布接口支持视频与图片同时落库 - 小程序发布器支持选择/预览/删除视频(≤60 秒),动态卡片与详情页支持播放 - 后台动态管理新增视频预览弹窗 - 新增短视频链路与上传鉴权回归测试,125 套件/818 用例全绿
这个提交包含在:
@@ -105,8 +105,12 @@
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="配图" :width="160">
|
||||
<a-table-column title="图片 / 视频" :width="170">
|
||||
<template #cell="{ record }">
|
||||
<div v-if="record.videoUrl" class="post-video-cell">
|
||||
<a-tag color="arcoblue" size="small">短视频</a-tag>
|
||||
<a-button type="text" size="mini" @click="openVideoPreview(record)">播放</a-button>
|
||||
</div>
|
||||
<a-image-preview-group v-if="record.images && record.images.length > 0">
|
||||
<a-space wrap :size="4">
|
||||
<a-image
|
||||
@@ -120,7 +124,10 @@
|
||||
/>
|
||||
</a-space>
|
||||
</a-image-preview-group>
|
||||
<span v-else style="color: var(--color-text-4); font-size: 12px">纯文本无配图</span>
|
||||
<span
|
||||
v-if="!record.videoUrl && (!record.images || record.images.length === 0)"
|
||||
style="color: var(--color-text-4); font-size: 12px"
|
||||
>纯文本无配图</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
@@ -214,6 +221,23 @@
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 短视频预览 -->
|
||||
<a-modal
|
||||
v-model:visible="videoPreviewVisible"
|
||||
:title="videoPreviewTitle"
|
||||
:footer="false"
|
||||
:width="560"
|
||||
unmount-on-close
|
||||
>
|
||||
<video
|
||||
v-if="videoPreviewUrl"
|
||||
:src="videoPreviewUrl"
|
||||
:poster="videoPreviewCover || undefined"
|
||||
controls
|
||||
style="width: 100%; max-height: 420px; background: #000; border-radius: 6px"
|
||||
></video>
|
||||
</a-modal>
|
||||
|
||||
<!-- 评论互动抽屉 (Drawer) -->
|
||||
<a-drawer
|
||||
v-model:visible="commentsDrawerVisible"
|
||||
@@ -337,6 +361,19 @@
|
||||
const loadingApplies = ref(false);
|
||||
const appliesList = ref<any[]>([]);
|
||||
|
||||
// 短视频预览
|
||||
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 {
|
||||
|
||||
@@ -1008,13 +1008,15 @@ function authHeader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实上传本地图片至服务端持久化静态存储
|
||||
* @param {string} tempFilePath 本地图片临时路径 (如 wxfile://tmp_xxx 或 blob:xxx)
|
||||
* @returns {Promise<string>} 返回持久化公网图片访问路径 (形如 /uploads/img_xxx.png)
|
||||
* 上传本地临时文件至服务端持久化静态存储
|
||||
* @param {string} tempFilePath 本地临时路径 (如 wxfile://tmp_xxx 或 blob:xxx)
|
||||
* @param {string} endpoint 上传接口路径 (图片 / 视频分别使用独立接口)
|
||||
* @param {string} kindLabel 媒体类型中文名,用于错误提示
|
||||
* @returns {Promise<string>} 返回持久化公网访问路径 (形如 /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<string>} 返回持久化公网图片访问路径 (形如 /uploads/img_xxx.png)
|
||||
*/
|
||||
export function uploadImage(tempFilePath) {
|
||||
return uploadMediaFile(tempFilePath, '/api/upload', '图片');
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实上传本地短视频至服务端持久化静态存储
|
||||
* @param {string} tempFilePath 本地视频临时路径
|
||||
* @returns {Promise<string>} 返回持久化公网视频访问路径 (形如 /uploads/video_xxx.mp4)
|
||||
*/
|
||||
export function uploadVideo(tempFilePath) {
|
||||
return uploadMediaFile(tempFilePath, '/api/upload/video', '视频');
|
||||
}
|
||||
|
||||
|
||||
*/
|
||||
export async function checkTextSecurity(content) {
|
||||
try {
|
||||
|
||||
@@ -71,6 +71,13 @@ export const ICONS = {
|
||||
</svg>
|
||||
`),
|
||||
|
||||
videoCamera: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="6" width="13" height="12" rx="2"></rect>
|
||||
<path d="M15 10.5l6-3.5v10l-6-3.5"></path>
|
||||
</svg>
|
||||
`),
|
||||
|
||||
plus: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
|
||||
@@ -76,6 +76,19 @@
|
||||
<text v-for="t in post.topics" :key="t" class="detail-topic-pill">#{{ t }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 短视频播放器 -->
|
||||
<view class="detail-video-wrap" v-if="post.videoUrl">
|
||||
<video
|
||||
class="detail-video"
|
||||
:src="post.videoUrl"
|
||||
:poster="post.videoCover || ''"
|
||||
object-fit="contain"
|
||||
controls
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 高清图片展示 -->
|
||||
<view class="detail-images-grid" v-if="post.images && post.images.length">
|
||||
<image
|
||||
@@ -896,6 +909,19 @@ onShow(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
@@ -123,6 +123,20 @@
|
||||
<text v-for="t in post.topics" :key="t" class="topic-item">#{{ t }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 短视频(可与图片同发) -->
|
||||
<view class="post-video-wrap" v-if="post.videoUrl">
|
||||
<video
|
||||
class="post-video"
|
||||
:src="post.videoUrl"
|
||||
:poster="post.videoCover || ''"
|
||||
object-fit="cover"
|
||||
controls
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
:show-fullscreen-btn="true"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 图片九宫格 / 展位 -->
|
||||
<view class="post-images" v-if="post.images && post.images.length">
|
||||
<image
|
||||
@@ -224,9 +238,25 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图片九宫格选择区 (小红书/朋友圈现代画廊式) -->
|
||||
<!-- 图片 / 短视频选择区 (小红书/朋友圈现代画廊式,支持视频与图片同发) -->
|
||||
<view class="gallery-section">
|
||||
<view class="gallery-grid">
|
||||
<!-- 已选视频预览 -->
|
||||
<view class="grid-img-item grid-video-item" v-if="newPostForm.video">
|
||||
<video
|
||||
class="thumb-video"
|
||||
:src="newPostForm.video.path"
|
||||
:poster="newPostForm.video.cover || ''"
|
||||
object-fit="cover"
|
||||
:controls="false"
|
||||
/>
|
||||
<view class="video-duration-badge">
|
||||
<text class="video-duration-txt">{{ formatVideoDuration(newPostForm.video.duration) }}</text>
|
||||
</view>
|
||||
<view class="remove-btn btn-active" @click.stop="removeVideo">
|
||||
<image class="remove-icon" :src="ICONS.close" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="grid-img-item"
|
||||
v-for="(img, idx) in newPostForm.images"
|
||||
@@ -239,7 +269,7 @@
|
||||
</view>
|
||||
<view
|
||||
class="add-img-tile btn-active"
|
||||
v-if="newPostForm.images.length < 9"
|
||||
v-if="!newPostForm.video && newPostForm.images.length < 9"
|
||||
@click="choosePostImage"
|
||||
>
|
||||
<view class="tile-icon-circle">
|
||||
@@ -248,6 +278,17 @@
|
||||
<text class="tile-label">添加图片</text>
|
||||
<text class="tile-count">{{ newPostForm.images.length }}/9</text>
|
||||
</view>
|
||||
<view
|
||||
class="add-img-tile add-video-tile btn-active"
|
||||
v-if="!newPostForm.video"
|
||||
@click="choosePostVideo"
|
||||
>
|
||||
<view class="tile-icon-circle">
|
||||
<image class="cam-icon" :src="ICONS.videoCamera" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="tile-label">添加视频</text>
|
||||
<text class="tile-count">≤60秒</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 定位信息微胶囊 */
|
||||
|
||||
@@ -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: '视频安全校验通过'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感词星号脱敏替换(供展示层兜底脱敏)
|
||||
*/
|
||||
|
||||
@@ -382,6 +382,8 @@ export async function initDatabase(): Promise<void> {
|
||||
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<void> {
|
||||
`);
|
||||
} 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(`
|
||||
|
||||
@@ -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<any> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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, '上传视频')");
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户