feat: 文传易匿名临时文件传输站初始版本
- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式 - 前端 Vue3 + Vite + TDesign,上传/取件/管理三页 - 取件码/二维码/海报合成(含保存二维码为图片) - deploy/ 部署文档与一键 FTP 发布脚本 - appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
using System.Text;
|
||||
using Aliyun.OSS;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WenChuanyi.Api.Dtos;
|
||||
using WenChuanyi.Api.Models;
|
||||
using WenChuanyi.Api.Services;
|
||||
|
||||
namespace WenChuanyi.Api.Controllers;
|
||||
|
||||
/// <summary>文件上传 / 查询 / 标签列表 / 预览 / 下载</summary>
|
||||
[ApiController]
|
||||
[Route("api/files")]
|
||||
public class FileController : ControllerBase
|
||||
{
|
||||
private readonly IFreeSql _fsql;
|
||||
private readonly CodeGeneratorService _codeGen;
|
||||
private readonly OssStorageService _oss;
|
||||
private readonly string _baseUrl;
|
||||
private readonly ILogger<FileController> _logger;
|
||||
|
||||
public FileController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss,
|
||||
IConfiguration cfg, ILogger<FileController> logger)
|
||||
{
|
||||
_fsql = fsql;
|
||||
_codeGen = codeGen;
|
||||
_oss = oss;
|
||||
_baseUrl = cfg["App:BaseUrl"] ?? "";
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ---------- 上传 ----------
|
||||
|
||||
/// <summary>multipart 上传:file + expireHours(24/168/0) + password + tag(password 与 tag 可同时设置)</summary>
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(220_200_960)] // 210MB(Kestrel 同配置)
|
||||
public async Task<IActionResult> Upload([FromForm] int expireHours, [FromForm] string? password,
|
||||
[FromForm] string? tag, IFormFile? file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { message = "请选择要上传的文件" });
|
||||
}
|
||||
if (file.Length > FileRules.MaxFileBytes)
|
||||
{
|
||||
return BadRequest(new { message = "文件超过 200MB 限制,无法上传" });
|
||||
}
|
||||
|
||||
var pwdErr = FileRules.ValidatePassword(password);
|
||||
if (pwdErr != null) return BadRequest(new { message = pwdErr });
|
||||
|
||||
tag = string.IsNullOrWhiteSpace(tag) ? null : tag.Trim();
|
||||
var tagErr = FileRules.ValidateTag(tag);
|
||||
if (tagErr != null) return BadRequest(new { message = tagErr });
|
||||
|
||||
var hasPassword = !string.IsNullOrEmpty(password);
|
||||
var hasTag = !string.IsNullOrEmpty(tag);
|
||||
|
||||
// 有效期:仅 24/168/0 三档;含标签强制永久
|
||||
var effectiveExpire = hasTag ? 0 : (expireHours is 24 or 168 or 0 ? expireHours : 24);
|
||||
var isPermanent = hasTag || effectiveExpire == 0;
|
||||
DateTime? expiresAt = isPermanent ? null : DateTime.Now.AddHours(effectiveExpire);
|
||||
|
||||
var fileType = FileRules.ResolveFileType(hasPassword, hasTag);
|
||||
var originalName = FileRules.SanitizeFileName(file.FileName);
|
||||
var objectKey = _oss.BuildObjectKey(originalName);
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var contentType = string.IsNullOrEmpty(file.ContentType) ? "application/octet-stream" : file.ContentType;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _oss.UploadAsync(objectKey, stream, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OSS 上传失败:{Name}", file.FileName);
|
||||
return StatusCode(500, new { message = "文件上传失败,请稍后重试" });
|
||||
}
|
||||
|
||||
var item = new FileItem
|
||||
{
|
||||
PickCode = await _codeGen.GeneratePickCodeAsync(FileRules.PickCodeLength(fileType)),
|
||||
AdminCode = await _codeGen.GenerateAdminCodeAsync(),
|
||||
FileType = fileType,
|
||||
Password = hasPassword ? password : null,
|
||||
Tag = hasTag ? tag : null,
|
||||
OriginalName = originalName,
|
||||
ObjectKey = objectKey,
|
||||
Size = file.Length,
|
||||
MimeType = contentType,
|
||||
DownloadCount = 0,
|
||||
UploadIp = ip,
|
||||
IsPermanent = isPermanent,
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = DateTime.Now
|
||||
};
|
||||
await _fsql.Insert(item).ExecuteAffrowsAsync();
|
||||
|
||||
return Ok(new UploadResultDto
|
||||
{
|
||||
Id = item.Id,
|
||||
PickCode = item.PickCode,
|
||||
AdminCode = item.AdminCode,
|
||||
FileType = fileType,
|
||||
OriginalName = item.OriginalName,
|
||||
Size = item.Size,
|
||||
IsPermanent = isPermanent,
|
||||
ExpiresAt = expiresAt,
|
||||
PickUrl = BuildPickUrl(item.PickCode)
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 查询 ----------
|
||||
|
||||
/// <summary>按取件码查询(私密需带 password;过期返回 410)</summary>
|
||||
[HttpGet("{code}")]
|
||||
public async Task<IActionResult> Query(string code, [FromQuery] string? password)
|
||||
{
|
||||
if (!IsPickCode(code))
|
||||
{
|
||||
return NotFound(new { message = "取件码/标签无效" });
|
||||
}
|
||||
var (item, error, status) = await FindAndValidateAsync(code, password);
|
||||
if (item == null)
|
||||
{
|
||||
return StatusCode(status, new { message = error });
|
||||
}
|
||||
return Ok(ToDto(item));
|
||||
}
|
||||
|
||||
/// <summary>按标签查询文件列表(CreatedAt 倒序,limit 100)</summary>
|
||||
[HttpGet("by-tag/{tag}")]
|
||||
public async Task<IActionResult> ByTag(string tag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tag) || !tag.All(char.IsLetterOrDigit))
|
||||
{
|
||||
return BadRequest(new { message = "标签无效" });
|
||||
}
|
||||
var list = await _fsql.Select<FileItem>()
|
||||
.Where(f => f.Tag == tag)
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Limit(100)
|
||||
.ToListAsync();
|
||||
return Ok(list.Select(ToDto));
|
||||
}
|
||||
|
||||
// ---------- 下载 ----------
|
||||
|
||||
/// <summary>流式下载:计数 +1(仅统计),还原原始文件名,支持 Range 断点续传(私密需密码)</summary>
|
||||
[HttpGet("{code}/download")]
|
||||
public async Task<IActionResult> Download(string code, [FromQuery] string? password)
|
||||
{
|
||||
var (item, error, status) = await FindAndValidateAsync(code, password);
|
||||
if (item == null)
|
||||
{
|
||||
return StatusCode(status, new { message = error });
|
||||
}
|
||||
await _fsql.Update<FileItem>()
|
||||
.Set(f => f.DownloadCount + 1)
|
||||
.Where(f => f.Id == item.Id)
|
||||
.ExecuteAffrowsAsync();
|
||||
await ServeStreamAsync(item, inline: false);
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
// ---------- 预览 ----------
|
||||
|
||||
/// <summary>在线预览:PDF/图片(≤30MB)/ 音视频白名单(不限大小)inline 流;不计次数</summary>
|
||||
[HttpGet("{code}/preview")]
|
||||
public async Task<IActionResult> Preview(string code, [FromQuery] string? password)
|
||||
{
|
||||
var (item, error, status) = await FindAndValidateAsync(code, password);
|
||||
if (item == null)
|
||||
{
|
||||
return StatusCode(status, new { message = error });
|
||||
}
|
||||
var ext = Path.GetExtension(item.OriginalName).ToLowerInvariant();
|
||||
var mime = item.MimeType ?? "";
|
||||
if (FileRules.IsText(ext))
|
||||
{
|
||||
return await ServeTextContentAsync(item);
|
||||
}
|
||||
if (FileRules.IsPdfOrImage(mime))
|
||||
{
|
||||
if (item.Size > FileRules.MaxPdfImagePreview)
|
||||
{
|
||||
return BadRequest(new { message = "文件过大,请下载后查看" });
|
||||
}
|
||||
await ServeStreamAsync(item, inline: true);
|
||||
return new EmptyResult();
|
||||
}
|
||||
if (FileRules.IsVideoWhitelist(ext) || FileRules.IsAudioWhitelist(ext))
|
||||
{
|
||||
await ServeStreamAsync(item, inline: true);
|
||||
return new EmptyResult();
|
||||
}
|
||||
return BadRequest(new { message = "该格式不支持在线预览,请下载查看" });
|
||||
}
|
||||
|
||||
/// <summary>文本类内容(≤2MB),供在线阅读渲染(私密需密码)</summary>
|
||||
[HttpGet("{code}/content")]
|
||||
public async Task<IActionResult> GetContent(string code, [FromQuery] string? password)
|
||||
{
|
||||
var (item, error, status) = await FindAndValidateAsync(code, password);
|
||||
if (item == null)
|
||||
{
|
||||
return StatusCode(status, new { message = error });
|
||||
}
|
||||
if (!FileRules.IsText(Path.GetExtension(item.OriginalName).ToLowerInvariant()))
|
||||
{
|
||||
return BadRequest(new { message = "该类型不支持文本预览" });
|
||||
}
|
||||
return await ServeTextContentAsync(item);
|
||||
}
|
||||
|
||||
// ---------- 私有辅助 ----------
|
||||
|
||||
private static bool IsPickCode(string code)
|
||||
=> code.Length is 6 or 8 && code.All(char.IsDigit);
|
||||
|
||||
/// <summary>校验取件码 + 过期(懒检查)+ 私密密码;错误时返回 (null, message, status)</summary>
|
||||
private async Task<(FileItem? Item, string? Error, int Status)> FindAndValidateAsync(string code, string? password)
|
||||
{
|
||||
if (!IsPickCode(code))
|
||||
{
|
||||
return (null, "取件码/标签无效", StatusCodes.Status404NotFound);
|
||||
}
|
||||
var item = await _fsql.Select<FileItem>().Where(f => f.PickCode == code).FirstAsync();
|
||||
if (item == null)
|
||||
{
|
||||
return (null, "取件码/标签无效", StatusCodes.Status404NotFound);
|
||||
}
|
||||
if (!item.IsPermanent && item.ExpiresAt != null && item.ExpiresAt < DateTime.Now)
|
||||
{
|
||||
return (null, "文件已过期", StatusCodes.Status410Gone);
|
||||
}
|
||||
if (item.FileType == "private")
|
||||
{
|
||||
if (string.IsNullOrEmpty(password) || password != item.Password)
|
||||
{
|
||||
return (null, "密码错误", StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
}
|
||||
return (item, null, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private FileInfoDto ToDto(FileItem f) => new()
|
||||
{
|
||||
Id = f.Id,
|
||||
PickCode = f.PickCode,
|
||||
FileType = f.FileType,
|
||||
HasPassword = f.FileType == "private",
|
||||
OriginalName = f.OriginalName,
|
||||
Size = f.Size,
|
||||
MimeType = f.MimeType,
|
||||
DownloadCount = f.DownloadCount,
|
||||
IsPermanent = f.IsPermanent,
|
||||
ExpiresAt = f.ExpiresAt,
|
||||
CreatedAt = f.CreatedAt,
|
||||
ExpireText = FileRules.ExpireText(f)
|
||||
};
|
||||
|
||||
private string BuildPickUrl(string pickCode)
|
||||
=> $"{_baseUrl}/#/pickup?code={pickCode}";
|
||||
|
||||
/// <summary>OSS 流式输出到响应(inline/attachment + Range 206 透传)</summary>
|
||||
private async Task ServeStreamAsync(FileItem item, bool inline)
|
||||
{
|
||||
var rangeHeader = Request.Headers.Range.ToString();
|
||||
long? start = null, end = null;
|
||||
if (!string.IsNullOrEmpty(rangeHeader) && TryParseRange(rangeHeader, item.Size, out var rs, out var re))
|
||||
{
|
||||
start = rs;
|
||||
end = re;
|
||||
}
|
||||
OssObject obj;
|
||||
try
|
||||
{
|
||||
obj = _oss.GetObject(item.ObjectKey, start, end);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OSS 读取失败:{Key}", item.ObjectKey);
|
||||
Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await Response.WriteAsync("文件读取失败,请稍后重试");
|
||||
return;
|
||||
}
|
||||
|
||||
Response.StatusCode = StatusCodes.Status200OK;
|
||||
Response.ContentType = item.MimeType ?? "application/octet-stream";
|
||||
Response.Headers["Accept-Ranges"] = "bytes";
|
||||
Response.Headers["Content-Disposition"] = inline
|
||||
? "inline"
|
||||
: $"attachment; filename*=UTF-8''{Uri.EscapeDataString(item.OriginalName)}";
|
||||
|
||||
if (start.HasValue)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||
var actualEnd = end < 0 || end == null ? item.Size - 1 : end.Value;
|
||||
Response.Headers["Content-Range"] = $"bytes {start}-{actualEnd}/{item.Size}";
|
||||
}
|
||||
Response.Headers["Content-Length"] = obj.ContentLength.ToString();
|
||||
await obj.Content.CopyToAsync(Response.Body);
|
||||
}
|
||||
|
||||
/// <summary>文本内容读取(≤2MB,UTF-8 优先,GBK 兜底)</summary>
|
||||
private async Task<IActionResult> ServeTextContentAsync(FileItem item)
|
||||
{
|
||||
if (item.Size > FileRules.MaxTextPreview)
|
||||
{
|
||||
return BadRequest(new { message = "文件过大,请下载后查看" });
|
||||
}
|
||||
OssObject obj;
|
||||
try
|
||||
{
|
||||
obj = _oss.GetObject(item.ObjectKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OSS 读取失败:{Key}", item.ObjectKey);
|
||||
return StatusCode(500, new { message = "文件读取失败,请稍后重试" });
|
||||
}
|
||||
using var ms = new MemoryStream();
|
||||
await obj.Content.CopyToAsync(ms);
|
||||
var bytes = ms.ToArray();
|
||||
var text = DecodeText(bytes);
|
||||
return Content(text, "text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
private static string DecodeText(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
|
||||
{
|
||||
return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3);
|
||||
}
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
|
||||
{
|
||||
return Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2);
|
||||
}
|
||||
return IsValidUtf8(bytes) ? Encoding.UTF8.GetString(bytes) : Encoding.GetEncoding("GBK").GetString(bytes);
|
||||
}
|
||||
|
||||
private static bool IsValidUtf8(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var decoder = new UTF8Encoding(false, true).GetDecoder();
|
||||
var charCount = decoder.GetCharCount(bytes, 0, bytes.Length, flush: true);
|
||||
return charCount >= 0;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseRange(string rangeHeader, long total, out long start, out long end)
|
||||
{
|
||||
start = 0;
|
||||
end = -1; // -1 表示到文件末尾
|
||||
if (string.IsNullOrEmpty(rangeHeader) || !rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var spec = rangeHeader["bytes=".Length..];
|
||||
var idx = spec.IndexOf('-');
|
||||
if (idx < 0) return false;
|
||||
var startPart = spec[..idx];
|
||||
var endPart = spec[(idx + 1)..];
|
||||
if (startPart.Length > 0 && long.TryParse(startPart, out start))
|
||||
{
|
||||
if (start >= total) return false;
|
||||
if (endPart.Length > 0 && long.TryParse(endPart, out var e))
|
||||
{
|
||||
end = Math.Min(e, total - 1);
|
||||
if (start > end) return false;
|
||||
}
|
||||
// "bytes=start-":end 保持 -1(到末尾)
|
||||
}
|
||||
else if (endPart.Length > 0 && long.TryParse(endPart, out var suffix))
|
||||
{
|
||||
// "bytes=-N":末尾 N 字节
|
||||
start = Math.Max(0, total - suffix);
|
||||
end = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user