feat(community): 友圈动态支持上传短视频,可与图片同发

- 新增 /api/upload/video 独立上传接口:仅 MP4/MOV、单条 30MB、独立限流、登录鉴权

- posts 表新增 video_url / video_cover,发布接口支持视频与图片同时落库

- 小程序发布器支持选择/预览/删除视频(≤60 秒),动态卡片与详情页支持播放

- 后台动态管理新增视频预览弹窗

- 新增短视频链路与上传鉴权回归测试,125 套件/818 用例全绿
这个提交包含在:
xcbyy
2026-10-31 10:43:43 +08:00
父节点 1681d5d086
当前提交 23f500f623
共有 11 个文件被更改,包括 478 次插入 和 36 次删除

查看文件

@@ -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, '上传视频')");
});
});