feat(m-memory): 环节0+1 文件存储API+元数据管理+分类搜索+配额

This commit is contained in:
liuxunxun7-max 2026-03-18 09:30:31 +08:00
parent 77178b2037
commit e5152e9dc3
7 changed files with 1430 additions and 0 deletions

55
backend/memory/README.md Normal file
View File

@ -0,0 +1,55 @@
# M-MEMORY 人格体自用云盘存储
## 模块信息
- 端口: 3020
- 目录: `/backend/memory/`
- 分类: persona-growth / user-memory / system-log / knowledge-base
## API 接口清单
### 文件上传
`POST /api/memory/upload`
- FormData: file, category, description(可选), tags(可选,逗号分隔)
- 限制: 单文件 ≤50MB, 类型: json/txt/md/csv/log/png/jpg
### 文件列表
`GET /api/memory/files`
### 单文件信息
`GET /api/memory/files/:id`
### 下载文件
`GET /api/memory/files/:id/download`
### 删除文件
`DELETE /api/memory/files/:id`
### 分类统计
`GET /api/memory/categories`
### 按分类查询
`GET /api/memory/categories/:category`
### 关键词搜索
`GET /api/memory/search?q=keyword`
### 更新元数据
`PUT /api/memory/files/:id/meta`
- Body: { "description": "新描述", "tags": ["tag1","tag2"] }
### 配额查询
`GET /api/memory/quota`
## 配置说明
`config.json` 支持:
- port: 服务端口
- maxFileSizeMB: 单文件大小限制(MB)
- allowedTypes: 允许的文件类型
- quotaGB: 总存储配额(GB)
- autoCleanDays: system-log 自动清理天数
## 启动方法
```bash
cd backend/memory
npm install
node server.js

View File

@ -0,0 +1,7 @@
{
"port": 3020,
"maxFileSizeMB": 50,
"allowedTypes": ["json", "txt", "md", "csv", "log", "png", "jpg"],
"quotaGB": 1,
"autoCleanDays": 30
}

View File

@ -0,0 +1 @@
[]

1006
backend/memory/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
{
"name": "memory",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"express": "^5.2.1",
"mime-types": "^3.0.2",
"multer": "^2.1.1",
"uuid": "^13.0.0"
}
}

341
backend/memory/server.js Normal file
View File

@ -0,0 +1,341 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const mime = require('mime-types');
const app = express();
const PORT = 3020;
// ========== 配置加载 ==========
const configPath = path.join(__dirname, 'config.json');
let config = {};
function loadConfig() {
try {
const data = fs.readFileSync(configPath, 'utf8');
config = JSON.parse(data);
} catch (err) {
// 默认配置
config = {
port: 3020,
maxFileSizeMB: 50,
allowedTypes: ['json', 'txt', 'md', 'csv', 'log', 'png', 'jpg'],
quotaGB: 1,
autoCleanDays: 30
};
// 写入默认配置
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
}
}
loadConfig();
const MAX_SIZE = config.maxFileSizeMB * 1024 * 1024;
const QUOTA_BYTES = config.quotaGB * 1024 * 1024 * 1024;
const ALLOWED_EXTENSIONS = config.allowedTypes;
const AUTO_CLEAN_DAYS = config.autoCleanDays;
// ========== 目录初始化 ==========
const UPLOAD_DIR = path.join(__dirname, 'uploads');
const CATEGORIES = ['persona-growth', 'user-memory', 'system-log', 'knowledge-base'];
// 创建 uploads 及其子目录
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR);
}
CATEGORIES.forEach(cat => {
const catPath = path.join(UPLOAD_DIR, cat);
if (!fs.existsSync(catPath)) {
fs.mkdirSync(catPath);
}
});
const INDEX_PATH = path.join(__dirname, 'memory-index.json');
// 初始化索引文件
let fileIndex = [];
if (fs.existsSync(INDEX_PATH)) {
try {
fileIndex = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8'));
} catch (err) {
fileIndex = [];
}
} else {
fs.writeFileSync(INDEX_PATH, JSON.stringify([], null, 2));
}
// ========== 辅助函数 ==========
function saveIndex() {
fs.writeFileSync(INDEX_PATH, JSON.stringify(fileIndex, null, 2));
}
function getFileSize(filePath) {
try {
const stat = fs.statSync(filePath);
return stat.size;
} catch {
return 0;
}
}
// 计算已用空间
function getUsedSpace() {
let total = 0;
fileIndex.forEach(item => {
total += item.size || 0;
});
return total;
}
// 检查配额
function checkQuota(newFileSize) {
const used = getUsedSpace();
return (used + newFileSize) <= QUOTA_BYTES;
}
// 自动清理旧日志system-log 超过30天
function autoClean() {
const now = Date.now();
const maxAge = AUTO_CLEAN_DAYS * 24 * 60 * 60 * 1000;
fileIndex = fileIndex.filter(item => {
if (item.category === 'system-log') {
const uploadedAt = new Date(item.uploadedAt).getTime();
if (now - uploadedAt > maxAge) {
// 删除物理文件
const filePath = path.join(UPLOAD_DIR, item.category, item.storedName);
try {
fs.unlinkSync(filePath);
} catch (err) {
// 文件可能已经不存在
}
return false; // 从索引移除
}
}
return true;
});
saveIndex();
}
// 每天执行一次清理
setInterval(autoClean, 24 * 60 * 60 * 1000);
// 启动时也执行一次
autoClean();
// ========== multer 配置 ==========
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const category = req.body.category;
console.log('收到的 category:', category); // 加这一行看看
if (!CATEGORIES.includes(category)) {
return cb(new Error('分类必须是 persona-growth / user-memory / system-log / knowledge-base'));
}
const dest = path.join(UPLOAD_DIR, category);
cb(null, dest);
},
filename: function (req, file, cb) {
const ext = path.extname(file.originalname);
const storedName = uuidv4() + ext;
cb(null, storedName);
}
});
const fileFilter = (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
if (ALLOWED_EXTENSIONS.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`不允许的文件类型,允许的类型: ${ALLOWED_EXTENSIONS.join(', ')}`));
}
};
const upload = multer({
storage: storage,
limits: { fileSize: MAX_SIZE },
fileFilter: fileFilter
});
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// ========== API 路由 ==========
// 上传文件
app.post('/api/memory/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: '没有上传文件' });
}
const { category, description = '', tags = '' } = req.body;
// 配额检查
if (!checkQuota(req.file.size)) {
// 删除已上传的文件
fs.unlinkSync(req.file.path);
return res.status(413).json({ error: '存储配额已满,无法上传' });
}
const fileRecord = {
id: uuidv4(),
filename: req.file.originalname,
storedName: req.file.filename,
category: category,
size: req.file.size,
mimeType: req.file.mimetype,
description: description,
tags: tags ? tags.split(',').map(t => t.trim()) : [],
uploadedAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
fileIndex.push(fileRecord);
saveIndex();
res.status(201).json(fileRecord);
});
// 文件列表
app.get('/api/memory/files', (req, res) => {
res.json(fileIndex);
});
// 单文件信息
app.get('/api/memory/files/:id', (req, res) => {
const file = fileIndex.find(f => f.id === req.params.id);
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
res.json(file);
});
// 下载文件
app.get('/api/memory/files/:id/download', (req, res) => {
const file = fileIndex.find(f => f.id === req.params.id);
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const filePath = path.join(UPLOAD_DIR, file.category, file.storedName);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: '物理文件不存在' });
}
res.download(filePath, file.filename);
});
// 删除文件
app.delete('/api/memory/files/:id', (req, res) => {
const index = fileIndex.findIndex(f => f.id === req.params.id);
if (index === -1) {
return res.status(404).json({ error: '文件不存在' });
}
const file = fileIndex[index];
const filePath = path.join(UPLOAD_DIR, file.category, file.storedName);
// 删除物理文件
try {
fs.unlinkSync(filePath);
} catch (err) {
// 文件可能已不存在,继续删除索引
}
// 删除索引
fileIndex.splice(index, 1);
saveIndex();
res.json({ message: '删除成功' });
});
// 分类统计
app.get('/api/memory/categories', (req, res) => {
const stats = {};
CATEGORIES.forEach(cat => {
const files = fileIndex.filter(f => f.category === cat);
const count = files.length;
const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
stats[cat] = { count, totalSize };
});
res.json(stats);
});
// 按分类查文件
app.get('/api/memory/categories/:category', (req, res) => {
const category = req.params.category;
if (!CATEGORIES.includes(category)) {
return res.status(400).json({ error: '分类不存在' });
}
const files = fileIndex.filter(f => f.category === category);
res.json(files);
});
// 关键词搜索
app.get('/api/memory/search', (req, res) => {
const q = req.query.q;
if (!q) {
return res.status(400).json({ error: '请提供搜索关键词' });
}
const keyword = q.toLowerCase();
const results = fileIndex.filter(f => {
return (
f.filename.toLowerCase().includes(keyword) ||
f.description.toLowerCase().includes(keyword) ||
f.tags.some(tag => tag.toLowerCase().includes(keyword))
);
});
res.json(results);
});
// 更新元数据
app.put('/api/memory/files/:id/meta', (req, res) => {
const { description, tags } = req.body;
const file = fileIndex.find(f => f.id === req.params.id);
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
if (description !== undefined) file.description = description;
if (tags !== undefined) {
file.tags = Array.isArray(tags) ? tags : tags.split(',').map(t => t.trim());
}
file.updatedAt = new Date().toISOString();
saveIndex();
res.json(file);
});
// 配额查询
app.get('/api/memory/quota', (req, res) => {
const used = getUsedSpace();
res.json({
quotaGB: config.quotaGB,
quotaBytes: QUOTA_BYTES,
usedBytes: used,
usedGB: (used / (1024*1024*1024)).toFixed(3),
remainingBytes: QUOTA_BYTES - used,
remainingGB: ((QUOTA_BYTES - used) / (1024*1024*1024)).toFixed(3),
totalFiles: fileIndex.length
});
});
// 错误处理中间件
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: `文件大小超过限制 (最大 ${config.maxFileSizeMB}MB)` });
}
return res.status(400).json({ error: err.message });
}
if (err) {
return res.status(400).json({ error: err.message });
}
next();
});
app.listen(PORT, () => {
console.log(`M-MEMORY 服务运行在端口 ${PORT}`);
});

1
test.txt Normal file
View File

@ -0,0 +1 @@
hello