254 lines
7.0 KiB
JavaScript
254 lines
7.0 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 上传路由 · /api/zhiku/upload
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 智库节点 Phase 4 · ZY-SVR-006
|
|||
|
|
* 守护: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*
|
|||
|
|
* 端点:
|
|||
|
|
* POST /api/zhiku/upload — 上传文件
|
|||
|
|
* POST /api/zhiku/upload/text — 上传纯文本(JSON方式)
|
|||
|
|
* GET /api/zhiku/upload/formats — 获取支持的格式
|
|||
|
|
* GET /api/zhiku/upload/list — 上传历史
|
|||
|
|
* GET /api/zhiku/upload/stats — 上传统计
|
|||
|
|
*
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const express = require('express');
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
const router = express.Router();
|
|||
|
|
const engine = require('../services/upload-engine');
|
|||
|
|
|
|||
|
|
// ─── 文件大小限制 ───
|
|||
|
|
const MAX_SIZE = 50 * 1024 * 1024; // 50MB
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 简易文件解析中间件(不依赖 multer)
|
|||
|
|
* 解析 multipart/form-data 中的单个文件
|
|||
|
|
*/
|
|||
|
|
function parseFileUpload(req, res, next) {
|
|||
|
|
const contentType = req.headers['content-type'] || '';
|
|||
|
|
|
|||
|
|
// JSON 方式上传跳过
|
|||
|
|
if (contentType.includes('application/json')) {
|
|||
|
|
return next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!contentType.includes('multipart/form-data')) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'INVALID_CONTENT_TYPE',
|
|||
|
|
message: '请使用 multipart/form-data 或 application/json 上传'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const boundary = contentType.split('boundary=')[1];
|
|||
|
|
if (!boundary) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'MISSING_BOUNDARY',
|
|||
|
|
message: '无法解析 multipart boundary'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const chunks = [];
|
|||
|
|
let totalSize = 0;
|
|||
|
|
|
|||
|
|
req.on('data', (chunk) => {
|
|||
|
|
totalSize += chunk.length;
|
|||
|
|
if (totalSize > MAX_SIZE) {
|
|||
|
|
req.destroy();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
chunks.push(chunk);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('end', () => {
|
|||
|
|
if (totalSize > MAX_SIZE) {
|
|||
|
|
return res.status(413).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'FILE_TOO_LARGE',
|
|||
|
|
message: `文件过大,最大支持 ${MAX_SIZE / 1024 / 1024}MB`
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const buffer = Buffer.concat(chunks);
|
|||
|
|
const parts = parseMultipart(buffer, boundary);
|
|||
|
|
|
|||
|
|
req.uploadedFile = parts.file || null;
|
|||
|
|
req.uploadFields = parts.fields || {};
|
|||
|
|
next();
|
|||
|
|
} catch (err) {
|
|||
|
|
res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'PARSE_ERROR',
|
|||
|
|
message: '文件解析失败: ' + err.message
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('error', () => {
|
|||
|
|
res.status(500).json({ error: true, code: 'UPLOAD_ERROR', message: '上传过程中断' });
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 解析 multipart/form-data
|
|||
|
|
*/
|
|||
|
|
function parseMultipart(buffer, boundary) {
|
|||
|
|
const result = { file: null, fields: {} };
|
|||
|
|
const boundaryBuf = Buffer.from('--' + boundary);
|
|||
|
|
const parts = [];
|
|||
|
|
|
|||
|
|
let start = 0;
|
|||
|
|
while (true) {
|
|||
|
|
const idx = buffer.indexOf(boundaryBuf, start);
|
|||
|
|
if (idx === -1) break;
|
|||
|
|
if (start > 0) {
|
|||
|
|
parts.push(buffer.slice(start, idx - 2)); // -2 for \r\n
|
|||
|
|
}
|
|||
|
|
start = idx + boundaryBuf.length + 2; // skip boundary + \r\n
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const part of parts) {
|
|||
|
|
const headerEnd = part.indexOf('\r\n\r\n');
|
|||
|
|
if (headerEnd === -1) continue;
|
|||
|
|
|
|||
|
|
const headerStr = part.slice(0, headerEnd).toString('utf8');
|
|||
|
|
const body = part.slice(headerEnd + 4);
|
|||
|
|
|
|||
|
|
const nameMatch = headerStr.match(/name="([^"]+)"/);
|
|||
|
|
if (!nameMatch) continue;
|
|||
|
|
|
|||
|
|
const filenameMatch = headerStr.match(/filename="([^"]+)"/);
|
|||
|
|
const contentTypeMatch = headerStr.match(/Content-Type:\s*(.+)/i);
|
|||
|
|
|
|||
|
|
if (filenameMatch) {
|
|||
|
|
// 文件部分
|
|||
|
|
result.file = {
|
|||
|
|
originalname: filenameMatch[1],
|
|||
|
|
mimetype: contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream',
|
|||
|
|
buffer: body,
|
|||
|
|
size: body.length
|
|||
|
|
};
|
|||
|
|
} else {
|
|||
|
|
// 普通字段
|
|||
|
|
result.fields[nameMatch[1]] = body.toString('utf8').trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ─── POST / — 上传文件(multipart) ─── */
|
|||
|
|
router.post('/', parseFileUpload, (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const file = req.uploadedFile;
|
|||
|
|
const fields = req.uploadFields || {};
|
|||
|
|
|
|||
|
|
if (!file) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'NO_FILE',
|
|||
|
|
message: '未收到文件,请使用 multipart/form-data 上传'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = engine.processUpload(file, {
|
|||
|
|
title: fields.title || '',
|
|||
|
|
author: fields.author || '',
|
|||
|
|
uploader: fields.uploader || 'unknown'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.status(201).json({
|
|||
|
|
error: false,
|
|||
|
|
data: result,
|
|||
|
|
message: `上传成功 · ${result.original_format} → TXT · ${result.word_count} 字`
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'UPLOAD_FAILED',
|
|||
|
|
message: err.message
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ─── POST /text — 纯文本上传(JSON方式) ─── */
|
|||
|
|
router.post('/text', express.json({ limit: '20mb' }), (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { title, author, content, uploader, format } = req.body;
|
|||
|
|
|
|||
|
|
if (!content || typeof content !== 'string' || content.trim().length < 10) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'EMPTY_CONTENT',
|
|||
|
|
message: '文本内容为空或过短(最少10字)'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const filename = (title || '未命名') + (format || '.txt');
|
|||
|
|
const file = {
|
|||
|
|
originalname: filename,
|
|||
|
|
mimetype: 'text/plain',
|
|||
|
|
buffer: Buffer.from(content, 'utf8'),
|
|||
|
|
size: Buffer.byteLength(content, 'utf8')
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const result = engine.processUpload(file, {
|
|||
|
|
title: title || '未命名',
|
|||
|
|
author: author || '未知',
|
|||
|
|
uploader: uploader || 'unknown'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.status(201).json({
|
|||
|
|
error: false,
|
|||
|
|
data: result,
|
|||
|
|
message: `文本上传成功 · ${result.word_count} 字`
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
code: 'UPLOAD_FAILED',
|
|||
|
|
message: err.message
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ─── GET /formats — 支持的格式 ─── */
|
|||
|
|
router.get('/formats', (_req, res) => {
|
|||
|
|
res.json({
|
|||
|
|
error: false,
|
|||
|
|
data: engine.getSupportedFormats(),
|
|||
|
|
max_size_mb: MAX_SIZE / 1024 / 1024
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ─── GET /list — 上传历史 ─── */
|
|||
|
|
router.get('/list', (req, res) => {
|
|||
|
|
const { uploader, limit } = req.query;
|
|||
|
|
const list = engine.listUploads({
|
|||
|
|
uploader,
|
|||
|
|
limit: limit ? parseInt(limit, 10) : undefined
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
error: false,
|
|||
|
|
data: list,
|
|||
|
|
total: list.length
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ─── GET /stats — 上传统计 ─── */
|
|||
|
|
router.get('/stats', (_req, res) => {
|
|||
|
|
res.json({ error: false, data: engine.getStats() });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
module.exports = router;
|