feat: 文传易匿名临时文件传输站初始版本

- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式
- 前端 Vue3 + Vite + TDesign,上传/取件/管理三页
- 取件码/二维码/海报合成(含保存二维码为图片)
- deploy/ 部署文档与一键 FTP 发布脚本
- appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
2026-08-24 00:03:28 +08:00
commit 1bfc72f9cb
40 changed files with 6849 additions and 0 deletions
@@ -0,0 +1,92 @@
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/admin")]
public class AdminController : ControllerBase
{
private readonly IFreeSql _fsql;
private readonly OssStorageService _oss;
private readonly ILogger<AdminController> _logger;
public AdminController(IFreeSql fsql, OssStorageService oss, ILogger<AdminController> logger)
{
_fsql = fsql;
_oss = oss;
_logger = logger;
}
/// <summary>管理码查询文件列表(含上传 IP</summary>
[HttpGet("files/{adminCode}")]
public async Task<IActionResult> List(string adminCode)
{
if (string.IsNullOrEmpty(adminCode) || adminCode.Length != 8)
{
return BadRequest(new { message = "管理码无效" });
}
var list = await _fsql.Select<FileItem>()
.Where(f => f.AdminCode == adminCode)
.OrderByDescending(f => f.CreatedAt)
.ToListAsync();
return Ok(list.Select(ToDto));
}
/// <summary>删除指定文件(body 传 id 或取件码,校验管理码)</summary>
[HttpDelete("files/{adminCode}")]
public async Task<IActionResult> Delete(string adminCode, [FromBody] DeleteRequest? req)
{
if (string.IsNullOrEmpty(adminCode) || adminCode.Length != 8)
{
return BadRequest(new { message = "管理码无效" });
}
if (req == null || (req.Id == null && string.IsNullOrEmpty(req.PickCode)))
{
return BadRequest(new { message = "请提供要删除的文件 Id 或取件码" });
}
FileItem? item;
if (req.Id != null)
{
item = await _fsql.Select<FileItem>()
.Where(f => f.Id == req.Id.Value && f.AdminCode == adminCode).FirstAsync();
}
else
{
item = await _fsql.Select<FileItem>()
.Where(f => f.PickCode == req.PickCode && f.AdminCode == adminCode).FirstAsync();
}
if (item == null)
{
return NotFound(new { message = "文件不存在或管理码不匹配" });
}
var deleted = await _fsql.Delete<FileItem>().Where(f => f.Id == item.Id).ExecuteAffrowsAsync() > 0;
if (deleted)
{
_oss.TryDeleteObject(item.ObjectKey);
}
return Ok(new { message = "已删除" });
}
private static 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,
UploadIp = f.UploadIp,
IsPermanent = f.IsPermanent,
ExpiresAt = f.ExpiresAt,
CreatedAt = f.CreatedAt,
ExpireText = FileRules.ExpireText(f)
};
}
@@ -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 + tagpassword 与 tag 可同时设置)</summary>
[HttpPost("upload")]
[RequestSizeLimit(220_200_960)] // 210MBKestrel 同配置)
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>文本内容读取(≤2MBUTF-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;
}
}
@@ -0,0 +1,156 @@
using Microsoft.AspNetCore.Mvc;
using WenChuanyi.Api.Dtos;
using WenChuanyi.Api.Models;
using WenChuanyi.Api.Services;
namespace WenChuanyi.Api.Controllers;
/// <summary>开放 API:按服务端文件路径上传(共享/私密/标签)+ 下载链接查询</summary>
[ApiController]
[Route("api/open")]
public class OpenApiController : ControllerBase
{
private readonly IFreeSql _fsql;
private readonly CodeGeneratorService _codeGen;
private readonly OssStorageService _oss;
private readonly string _baseUrl;
private readonly string _uploadRoot;
private readonly ILogger<OpenApiController> _logger;
public OpenApiController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss,
IConfiguration cfg, ILogger<OpenApiController> logger)
{
_fsql = fsql;
_codeGen = codeGen;
_oss = oss;
_baseUrl = cfg["App:BaseUrl"] ?? "";
_uploadRoot = Path.GetFullPath(cfg["OpenApi:UploadRoot"] ?? throw new InvalidOperationException("缺少配置 OpenApi:UploadRoot"));
_logger = logger;
}
/// <summary>公开上传-共享:expiresHours0=永久,缺省 24);pwd/tag 可同时提供(不互斥)</summary>
[HttpPost("upload-public")]
public Task<IActionResult> UploadPublic([FromBody] OpenUploadRequest req)
=> UploadFromPathAsync(req.FilePath, req.ExpiresHours, req.Pwd, req.Tag);
/// <summary>公开上传-私密:必填密码,可同时带标签</summary>
[HttpPost("upload-price")]
public Task<IActionResult> UploadPrice([FromBody] OpenUploadPriceRequest req)
=> UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag);
/// <summary>公开上传-标签:必填标签(永久保存),可同时带密码</summary>
[HttpPost("upload-tag")]
public Task<IActionResult> UploadTag([FromBody] OpenUploadTagRequest req)
=> UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag);
/// <summary>公开下载链接查询:共享/标签返回直接下载 URL,私密跳取件页手动输密码</summary>
[HttpGet("download/{pickCode}")]
public async Task<IActionResult> Download(string pickCode)
{
if (pickCode.Length is not (6 or 8) || !pickCode.All(char.IsDigit))
{
return NotFound(new { message = "取件码无效" });
}
var item = await _fsql.Select<FileItem>().Where(f => f.PickCode == pickCode).FirstAsync();
if (item == null)
{
return NotFound(new { message = "取件码无效" });
}
if (!item.IsPermanent && item.ExpiresAt != null && item.ExpiresAt < DateTime.Now)
{
return StatusCode(410, new { message = "文件已过期" });
}
var needPassword = item.FileType == "private";
return Ok(new OpenDownloadResult
{
PickUrl = BuildPickUrl(pickCode),
DownloadUrl = needPassword ? BuildPickUrl(pickCode) : BuildDownloadUrl(pickCode),
NeedPassword = needPassword
});
}
// ---------- 私有 ----------
private async Task<IActionResult> UploadFromPathAsync(string filePath, long? expiresHours, string? pwd, string? tag)
{
var fullPath = ResolveSafePath(filePath);
if (fullPath == null)
{
return BadRequest(new { message = "文件路径不在白名单目录内" });
}
if (!System.IO.File.Exists(fullPath))
{
return NotFound(new { message = "文件不存在" });
}
var fi = new FileInfo(fullPath);
if (fi.Length > FileRules.MaxFileBytes)
{
return BadRequest(new { message = "文件超过 200MB 限制" });
}
var pwdErr = FileRules.ValidatePassword(pwd);
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(pwd);
var hasTag = !string.IsNullOrEmpty(tag);
var isPermanent = hasTag || expiresHours == 0;
DateTime? expiresAt = isPermanent ? null : DateTime.Now.AddHours(expiresHours ?? 24);
var fileType = FileRules.ResolveFileType(hasPassword, hasTag);
var objectKey = _oss.BuildObjectKey(fi.Name);
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
try
{
await using var stream = System.IO.File.OpenRead(fullPath);
await _oss.UploadAsync(objectKey, stream, "application/octet-stream");
}
catch (Exception ex)
{
_logger.LogError(ex, "公开上传 OSS 失败:{Path}", fullPath);
return StatusCode(500, new { message = "文件上传失败" });
}
var item = new FileItem
{
PickCode = await _codeGen.GeneratePickCodeAsync(FileRules.PickCodeLength(fileType)),
AdminCode = await _codeGen.GenerateAdminCodeAsync(),
FileType = fileType,
Password = hasPassword ? pwd : null,
Tag = hasTag ? tag : null,
OriginalName = fi.Name,
ObjectKey = objectKey,
Size = fi.Length,
MimeType = "application/octet-stream",
DownloadCount = 0,
UploadIp = ip,
IsPermanent = isPermanent,
ExpiresAt = expiresAt,
CreatedAt = DateTime.Now
};
await _fsql.Insert(item).ExecuteAffrowsAsync();
return Ok(new OpenResult
{
PickCode = item.PickCode,
PickUrl = BuildPickUrl(item.PickCode),
DownloadUrl = fileType == "private" ? BuildPickUrl(item.PickCode) : BuildDownloadUrl(item.PickCode)
});
}
/// <summary>路径规范化 + 白名单前缀校验,防路径穿越</summary>
private string? ResolveSafePath(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath)) return null;
var full = Path.GetFullPath(filePath);
if (!full.StartsWith(_uploadRoot, StringComparison.OrdinalIgnoreCase)) return null;
return full;
}
private string BuildPickUrl(string pickCode) => $"{_baseUrl}/#/pickup?code={pickCode}";
private string BuildDownloadUrl(string pickCode) => $"{_baseUrl}/api/files/{pickCode}/download";
}