存储那么贵,何不白嫖飞书云文件空间
服务器硬盘又满了
下班前,监控系统给我发了条告警:NAS 存储空间不足 10%。
打开后台一看,500G 的硬盘塞得满满当当,全是这两年积累的项目文档、设计稿、测试数据。清理了一波,也就腾出 20G,治标不治本。
买新硬盘?现在存储价格涨到天际,一块 2T 的企业级硬盘要 1000+。上云存储?阿里云 OSS 标准存储 500G 一年要 480 元,加上流量费、请求费,一年下来得好几百。
正发愁呢,同事发来一条飞书消息,附带一个云文档链接。我点开一看,突然意识到一个问题:
飞书企业版,每个用户有 100G 云空间,完全免费!
我们公司 50 个人,那就是 5T 的免费存储空间!
一个大胆的想法冒了出来:能不能把飞书云盘当成免费网盘用?
飞书云存储的"白嫖"姿势
先搞清楚飞书云盘的规则
在动手之前,先把飞书云盘的"家底"摸清楚:
| 项目 | 免费额度 | 说明 |
|---|---|---|
| 人均存储空间 | 100G | 企业版标准配置 |
| 单文件大小 | 500MB | 超过需要分片上传 |
| 文件类型限制 | 无明确限制 | 文档、图片、视频都行 |
| API 调用频率 | 10次/秒 | 超了会返回 429 |
⚠️ 注意:以上是企业版(标准版)的配置,具体以你企业的实际套餐为准。
这个系统能帮你省多少钱
光说不练假把式,来算笔账:
成本对比表:
| 方案 | 首年成本 | 次年成本 | 优点 | 缺点 |
|---|---|---|---|---|
| 自建 NAS | 800+ 元 | 200 元 | 完全掌控、速度快 | 硬件故障风险、需要维护 |
| 阿里云 OSS | 730 元 | 730 元 | 稳定可靠、CDN加速 | 持续付费、流量费贵 |
| 腾讯云 COS | 680 元 | 680 元 | 同上 | 同上 |
| 飞书云盘 | 0 元 | 0 元 | 免费、有协作功能 | 单文件500MB限制 |
一年省下 700+,这钱拿来喝奶茶不香吗?
核心功能一览
当然,直接用飞书 App 上传文件不是我们的目标。我们要做的是一个独立的文件管理系统,把飞书云盘当底层存储:
先上成果:白嫖成功了!
口说无凭,先看看做出来的效果:
📸 系统登陆界面截图
📸 系统主界面截图
系统已经跑起来了,下面我就一步步讲怎么实现的。
上传:把文件"搬"到飞书云盘
普通上传 vs 分片上传
飞书对单文件有 500MB 的限制,但我们实际使用中经常遇到几百兆的设计稿、视频素材。怎么破?分片上传。
前端分片核心代码
切片逻辑很简单,用 JavaScript 的 Blob.slice() 方法:
// ChunkUploader.vue - 核心切片逻辑
const CHUNK_SIZE = 5 * 1024 * 1024; // 每片 5MB
async function uploadFile(file) {
// 1. 计算文件MD5(用于秒传和校验)
const fileHash = await calculateMD5(file);
// 2. 切片
const chunks = [];
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push({
index: i,
blob: file.slice(start, end),
size: end - start
});
}
// 3. 初始化分片上传
const { uploadId } = await api.initChunkUpload({
fileName: file.name,
fileSize: file.size,
fileHash,
totalChunks
});
// 4. 逐片上传(支持并发)
for (const chunk of chunks) {
await api.uploadChunk(uploadId, chunk.index, chunk.blob);
updateProgress(chunk.index / totalChunks * 100);
}
// 5. 完成上传
await api.completeChunkUpload(uploadId);
}
后端合并逻辑
后端收到分片后,调用飞书 API 完成最终合并:
// ChunkUploadService.cs - 分片合并
public async Task<CompleteResult> CompleteUploadAsync(string uploadId)
{
// 1. 获取所有分片信息
var chunks = await _dbContext.ChunkUploadRecords
.Where(c => c.UploadId == uploadId)
.OrderBy(c => c.ChunkIndex)
.ToListAsync();
// 2. 校验分片完整性
if (chunks.Count != chunks.First().TotalChunks)
{
throw new InvalidOperationException("分片不完整");
}
// 3. 调用飞书API合并分片
var response = await _driveFiles.UploadCompleteAsync(new UploadCompleteRequest
{
UploadId = uploadId,
BlockNum = chunks.Count,
BlockSize = CHUNK_SIZE
});
// 4. 创建文件记录
var fileRecord = new FileRecord
{
FileToken = response.Data.FileToken,
FileName = chunks.First().FileName,
FileSize = chunks.Sum(c => c.ChunkSize),
UploadTime = DateTime.UtcNow
};
_dbContext.FileRecords.Add(fileRecord);
// 5. 清理临时分片记录
_dbContext.ChunkUploadRecords.RemoveRange(chunks);
await _dbContext.SaveChangesAsync();
return new CompleteResult { Success = true, FileToken = fileRecord.FileToken };
}
一个隐藏坑:分片过期时间
开发过程中踩了个坑:飞书的分片上传有 7 天有效期。
📌 场景:用户上传一个 300MB 文件,传到一半断网了,过了 8 天再来续传,结果报错"分片已过期"。
解决方案:前端记录上传进度到 localStorage,每次打开页面检查是否有未完成的上传任务:
// 检查断点续传
function checkPendingUploads() {
const pending = localStorage.getItem('pending_uploads');
if (pending) {
const uploads = JSON.parse(pending);
// 过滤掉超过6天的(留1天余量)
const valid = uploads.filter(u =>
Date.now() - u.startTime < 6 * 24 * 60 * 60 * 1000
);
return valid;
}
return [];
}
同步:让飞书云盘变成你的"第二硬盘"
为什么需要同步
用户可能会问:我直接在飞书 App 里上传文件不行吗?
当然可以,但有几个问题:
- 飞书 App 上传的文件,我们系统里看不到 —— 因为我们维护了一份本地数据库记录
- 团队成员在飞书里改了文档,本地感知不到 —— 没有实时通知机制
- 需要统一管理界面 —— 把飞书云盘和其他来源的文件放在一起
所以需要一个同步机制,把飞书云盘的数据"拉"到我们的系统里。
同步策略:增量 + 递归
核心同步逻辑:
// FeishuSyncService.cs - 递归同步文件夹
private async Task<List<FolderRecord>> SyncFolderRecursiveAsync(
string? folderToken,
int userId,
SyncResult result,
CancellationToken cancellationToken)
{
var folders = new List<FolderRecord>();
var allFiles = new List<FileInfo>();
string? pageToken = null;
// 分页获取文件夹内容
do
{
var response = await _driveFolder.GetFilesPageListAsync(
folderToken,
page_token: pageToken,
cancellationToken: cancellationToken);
if (response?.Data?.Files == null || response.Data.Files.Length == 0)
break;
foreach (var file in response.Data.Files)
{
if (file.Type == "folder")
{
// 递归同步子文件夹
var folder = await SyncFolderRecordAsync(file, folderToken, userId, result);
if (folder != null)
{
folders.Add(folder);
result.SyncedFolders++;
// 🔁 递归调用
await SyncFolderRecursiveAsync(file.Token, userId, result, cancellationToken);
}
}
else
{
allFiles.Add(file);
}
}
pageToken = response.Data.NextPageToken;
}
while (!string.IsNullOrEmpty(pageToken));
// 批量同步文件
if (allFiles.Count > 0)
{
await SyncFileRecordsWithMetadataAsync(allFiles, folderToken, userId, result, cancellationToken);
result.SyncedFiles += allFiles.Count;
}
return folders;
}
同步性能优化
同步大量文件时,性能是个问题。我们做了几项优化:
| 优化项 | 问题 | 解决方案 | 效果 |
|---|---|---|---|
| 批量查询 | 逐个查询元数据太慢 | 一次查询 50 个文件的元数据 | 速度提升 50 倍 |
| 并发控制 | 多线程并发触发限流 | 限制同时 3 个同步任务 | 避免报错 429 |
| 本地缓存 | 每次同步都重新查询 | SQLite 缓存文件记录 | 减少 API 调用 |
批量元数据查询代码:
// 批量查询文件元数据(一次最多50个)
private async Task SyncFileRecordsWithMetadataAsync(
List<FileInfo> files, string? folderToken, int userId, SyncResult result)
{
const int batchSize = 50; // 飞书API限制
for (int i = 0; i < files.Count; i += batchSize)
{
var batch = files.Skip(i).Take(batchSize).ToList();
// 构建批量查询请求
var requestDocs = batch
.Where(f => !string.IsNullOrEmpty(f.Token))
.Select(f => new RequestDoc
{
DocToken = f.Token!,
DocType = MapFileType(f.Type)
})
.ToArray();
// 调用飞书批量元数据接口
var metasResponse = await _driveFiles.BatchQueryMetasAsync(
new MetasBatchQueryRequest { RequestDocs = requestDocs });
// 构建元数据字典
var metaDict = new Dictionary<string, FileMetaInfo>();
if (metasResponse?.Data?.Metas != null)
{
foreach (var meta in metasResponse.Data.Metas)
{
if (!string.IsNullOrEmpty(meta.DocToken))
metaDict[meta.DocToken] = meta;
}
}
// 逐个同步文件记录
foreach (var file in batch)
{
metaDict.TryGetValue(file.Token!, out var metaInfo);
await SyncFileRecordAsync(file, folderToken, userId, result, metaInfo);
}
}
}
📸 文件同步运行效果
同步面板的运行截图,展示了同步进度、文件数量统计等信息_
分享:让文件流转起来
内部分享 vs 外部分享
文件分享有两种场景:
| 场景 | 对象 | 方式 | 特点 |
|---|---|---|---|
| 内部分享 | 组织架构内成员 | 飞书权限继承 | 无需额外配置,权限自动同步 |
| 外部分享 | 组织外用户 | 分享链接 + 密码 | 支持过期时间、访问次数限制 |
分享链接的实现
短链接生成算法:
// ShareService.cs - 生成短链接
public string GenerateShareCode()
{
// 使用 Base62 编码,生成 6 位短码
const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
var random = new Random();
var code = new char[6];
for (int i = 0; i < 6; i++)
{
code[i] = chars[random.Next(chars.Length)];
}
return new string(code);
}
// 校验分享链接
public async Task<ShareValidationResult> ValidateShareAsync(string code, string? password)
{
var share = await _dbContext.ShareRecords
.FirstOrDefaultAsync(s => s.ShareCode == code);
if (share == null)
return ShareValidationResult.Fail("分享链接不存在");
if (share.ExpireTime < DateTime.UtcNow)
return ShareValidationResult.Fail("分享链接已过期");
if (!string.IsNullOrEmpty(share.Password) && share.Password != password)
return ShareValidationResult.Fail("密码错误");
if (share.MaxAccessCount > 0 && share.AccessCount >= share.MaxAccessCount)
return ShareValidationResult.Fail("访问次数已达上限");
// 更新访问次数
share.AccessCount++;
await _dbContext.SaveChangesAsync();
return ShareValidationResult.Success(share);
}
访问统计
每次访问分享链接,我们记录以下信息:
// 记录访问日志
public class ShareAccessLog
{
public int Id { get; set; }
public int ShareId { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? Referer { get; set; }
public DateTime AccessTime { get; set; }
public string? Action { get; set; } // view, download
}
版本管理:文件改错了也不怕
版本号设计
上传同名文件时,我们不会直接覆盖,而是创建新版本:
版本表设计:
CREATE TABLE FileVersions (
Id INTEGER PRIMARY KEY,
FileToken TEXT NOT NULL,
Version INTEGER NOT NULL,
StoragePath TEXT NOT NULL, -- 本地存储路径
FileSize INTEGER NOT NULL,
UploadTime TEXT NOT NULL,
Remark TEXT,
UNIQUE(FileToken, Version)
);
版本存储策略
这里有个问题:版本文件存在哪里?
| 存储位置 | 优点 | 缺点 |
|---|---|---|
| 飞书云盘 | 统一管理 | 占用飞书空间额度 |
| 本地存储 | 不占飞书空间 | 需要额外备份 |
我们选择本地存储,原因很简单:飞书的免费空间有限,存历史版本太浪费了。
// VersionService.cs - 创建新版本
public async Task<FileVersion> CreateVersionAsync(string fileToken, IFormFile file)
{
// 1. 获取当前最大版本号
var maxVersion = await _dbContext.FileVersions
.Where(v => v.FileToken == fileToken)
.MaxAsync(v => (int?)v.Version) ?? 0;
// 2. 保存新版本文件到本地
var storagePath = $"versions/{fileToken}_v{maxVersion + 1}";
using var stream = File.Create(storagePath);
await file.CopyToAsync(stream);
// 3. 创建版本记录
var version = new FileVersion
{
FileToken = fileToken,
Version = maxVersion + 1,
StoragePath = storagePath,
FileSize = file.Length,
UploadTime = DateTime.UtcNow
};
_dbContext.FileVersions.Add(version);
await _dbContext.SaveChangesAsync();
return version;
}
// 版本回滚
public async Task RestoreVersionAsync(string fileToken, int version)
{
var targetVersion = await _dbContext.FileVersions
.FirstOrDefaultAsync(v => v.FileToken == fileToken && v.Version == version)
?? throw new NotFoundException("版本不存在");
// 将目标版本文件上传到飞书,覆盖当前版本
var fileBytes = await File.ReadAllBytesAsync(targetVersion.StoragePath);
await _driveFiles.UploadAsync(fileToken, fileBytes);
}
版本清理策略
历史版本也不能无限保留,我们设置了清理规则:
- 保留最近 10 个版本
- 清理超过 30 天的历史版本
// 定时清理任务(每天凌晨执行)
public async Task CleanupOldVersionsAsync()
{
var cutoffDate = DateTime.UtcNow.AddDays(-30);
var oldVersions = await _dbContext.FileVersions
.Where(v => v.UploadTime < cutoffDate)
.ToListAsync();
foreach (var version in oldVersions)
{
// 删除本地文件
if (File.Exists(version.StoragePath))
File.Delete(version.StoragePath);
}
_dbContext.FileVersions.RemoveRange(oldVersions);
await _dbContext.SaveChangesAsync();
}
权限体系:自己的文件自己管
用户隔离设计
多用户环境下,数据隔离是基本要求。我们采用简单的用户级隔离:
查询自动过滤用户:
// 在 DbContext 中重写 SaveChanges,自动注入 UserId
public override int SaveChanges()
{
var entries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
foreach (var entry in entries)
{
if (entry.Entity is IHasUser entity && entry.State == EntityState.Added)
{
entity.UserId = _currentUserService.UserId;
}
}
return base.SaveChanges();
}
JWT 认证流程
Token 生成与验证:
// AuthService.cs
public string GenerateToken(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["JwtSettings:SecretKey"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, user.Role)
};
var token = new JwtSecurityToken(
issuer: _config["JwtSettings:Issuer"],
audience: _config["JwtSettings:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
操作日志审计
所有关键操作都记录日志:
// OperationLogService.cs
public enum OperationType
{
Login,
Upload,
Download,
Delete,
Share,
Sync
}
public async Task LogAsync(int userId, OperationType type, string target, string? detail = null)
{
var log = new OperationLog
{
UserId = userId,
OperationType = type.ToString(),
Target = target,
Detail = detail,
OperationTime = DateTime.UtcNow,
IpAddress = _httpContext.Connection.RemoteIpAddress?.ToString()
};
_dbContext.OperationLogs.Add(log);
await _dbContext.SaveChangesAsync();
}
部署:从零开始搭一套
创建飞书应用
第一步,去飞书开放平台创建应用:
- 打开 飞书开放平台
- 点击"开发者后台" → "创建企业自建应用"
- 填写应用名称、描述
- 进入应用详情,记录 App ID 和 App Secret
配置权限(重要!):
| 权限名称 | 权限点 | 用途 |
|---|---|---|
| 云空间 | drive:drive |
访问云空间 |
| 文件 | drive:file |
文件操作 |
| 文件夹 | drive:folder |
文件夹操作 |
| 文档 | docs:doc |
文档操作 |
⚠️ 坑点:权限配置后需要发布应用才能生效,发布后还要管理员审批。
后端部署(.NET 8)
# 1. 克隆项目
git clone https://gitee.com/mudtools/MudFeishu.git
cd Demos/FeishuFileServer/backend
# 2. 创建本地配置文件
# 注意:appsettings.local.json 不要提交到 git
cat > appsettings.local.json << 'EOF'
{
"FeishuApps": {
"Default": {
"AppId": "cli_xxxxxxxxxxxxx",
"AppSecret": "xxxxxxxxxxxxxxxxxxxxxxxx",
"IsDefault": true
}
},
"JwtSettings": {
"SecretKey": "your-secret-key-at-least-32-characters-long-for-security"
}
}
EOF
# 3. 安装依赖并运行
dotnet restore
dotnet run
# 后端服务将在 http://localhost:5000 启动
# Swagger 文档:http://localhost:5000/swagger
前端部署(Vue 3)
# 1. 进入前端目录
cd ../frontend
# 2. 配置后端地址
cat > .env.development << 'EOF'
VITE_API_BASE_URL=http://localhost:5000
EOF
# 3. 安装依赖
npm install
# 或使用 pnpm(更快)
# pnpm install
# 4. 启动开发服务器
npm run dev
# 前端服务将在 http://localhost:3000 启动
生产环境部署建议
Docker 部署(推荐):
# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 5000
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "FeishuFileServer.dll"]
# docker-compose.yml
version: '3.8'
services:
backend:
build: ./backend
ports:
- "5000:5000"
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
environment:
- ASPNETCORE_ENVIRONMENT=Production
frontend:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf
Nginx 反向代理配置:
server {
listen 80;
server_name your-domain.com;
# 前端静态资源
location / {
root /var/www/frontend/dist;
try_files $uri $uri/ /index.html;
}
# 后端 API
location /api {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 文件上传大小限制
client_max_body_size 500M;
}
踩坑记录:那些官方文档没告诉你的
开发过程中踩了不少坑,这里记录下来,希望能帮你少走弯路。
API 限流坑
现象:同步到一半突然报错 429 Too Many Requests
原因:飞书 API 有调用频率限制,每个应用每秒最多 10 次请求。
解决:
// 使用信号量控制并发
private static readonly SemaphoreSlim _apiLimiter = new(10, 10);
public async Task<T> CallApiAsync<T>(Func<Task<T>> apiCall)
{
await _apiLimiter.WaitAsync();
try
{
return await apiCall();
}
finally
{
_apiLimiter.Release();
}
}
文件类型坑
现象:上传 .docx 文件后,在飞书 App 里打开报错
原因:飞书对不同文件类型有不同的处理方式,需要明确指定 doc_type
解决:
// 根据文件扩展名映射 doc_type
private string MapFileType(string extension)
{
return extension.ToLower() switch
{
".docx" => "docx",
".xlsx" => "xlsx",
".pptx" => "pptx",
".pdf" => "pdf",
_ => "file" // 通用文件类型
};
}
权限坑
现象:上传成功,但获取文件列表返回空
原因:应用权限配置不完整,缺少 drive:drive:readonly 权限
解决:去开放平台检查权限配置,确保以下权限都已开启:
drive:drivedrive:drive:readonlydrive:filedrive:file:readonly
9.4 分片上传坑
现象:大文件上传失败,报"分片已过期"
原因:飞书分片上传有效期 7 天,中断后超时
解决:前端记录上传进度,支持断点续传(详见第 3.4 节)
后续玩法:还能怎么"薅"
这个系统还有很大的扩展空间:
| 功能 | 实现思路 | 收益 |
|---|---|---|
| 文件在线预览 | 调用飞书预览 API | 不用自己实现 Office 渲染 |
| 文档协同编辑 | 接入飞书文档 API | 免费在线协作功能 |
| 定时同步任务 | 每天凌晨自动同步 | 数据始终最新 |
| 多租户支持 | 支持多企业配置 | 给其他公司用 |
在线预览示例:
// 获取飞书预览链接
async function getPreviewUrl(fileToken) {
const response = await api.get(`/files/${fileToken}/preview-url`);
// 在 iframe 中打开预览
window.open(response.data.previewUrl, '_blank');
}




浙公网安备 33010602011771号