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
+5
View File
@@ -0,0 +1,5 @@
bin/
obj/
appsettings.json
*.user
.vs/
@@ -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";
}
@@ -0,0 +1,22 @@
namespace WenChuanyi.Api.Dtos;
/// <summary>文件信息 / 管理列表 / 标签列表 DTO(不暴露密码明文)</summary>
public class FileInfoDto
{
public long Id { get; set; }
public string PickCode { get; set; } = null!;
public string FileType { get; set; } = null!;
public bool HasPassword { get; set; }
public string OriginalName { get; set; } = null!;
public long Size { get; set; }
public string? MimeType { get; set; }
public int DownloadCount { get; set; }
/// <summary>上传者 IP(安全审计):仅管理列表填充,取件/标签/公开接口不暴露</summary>
public string? UploadIp { get; set; }
public bool IsPermanent { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>剩余有效期文本(永久/已过期/N天N小时)</summary>
public string ExpireText { get; set; } = "永久";
}
@@ -0,0 +1,49 @@
namespace WenChuanyi.Api.Dtos;
/// <summary>公开上传-共享文件:有效期小时数(0=永久,缺省 24),pwd/tag 可同时提供(不互斥)</summary>
public class OpenUploadRequest
{
public string FilePath { get; set; } = null!;
public long ExpiresHours { get; set; } = 24;
public string? Pwd { get; set; }
public string? Tag { get; set; }
}
/// <summary>公开上传-私密文件:必须有密码,可同时带标签</summary>
public class OpenUploadPriceRequest
{
public string FilePath { get; set; } = null!;
public string Pwd { get; set; } = null!;
public string? Tag { get; set; }
}
/// <summary>公开上传-标签文件:必须带标签(永久保存),可同时带密码</summary>
public class OpenUploadTagRequest
{
public string FilePath { get; set; } = null!;
public string Tag { get; set; } = null!;
public string? Pwd { get; set; }
}
/// <summary>公开上传响应</summary>
public class OpenResult
{
public string PickCode { get; set; } = null!;
public string PickUrl { get; set; } = null!;
public string DownloadUrl { get; set; } = null!;
}
/// <summary>公开下载链接查询响应</summary>
public class OpenDownloadResult
{
public string DownloadUrl { get; set; } = null!;
public string PickUrl { get; set; } = null!;
public bool NeedPassword { get; set; }
}
/// <summary>管理删除请求体:按 Id 或取件码删除</summary>
public class DeleteRequest
{
public long? Id { get; set; }
public string? PickCode { get; set; }
}
@@ -0,0 +1,17 @@
namespace WenChuanyi.Api.Dtos;
/// <summary>上传响应:取件凭证 + 管理码 + 文件信息</summary>
public class UploadResultDto
{
public long Id { get; set; }
public string PickCode { get; set; } = null!;
public string AdminCode { get; set; } = null!;
public string FileType { get; set; } = null!;
public string OriginalName { get; set; } = null!;
public long Size { get; set; }
public bool IsPermanent { get; set; }
public DateTime? ExpiresAt { get; set; }
/// <summary>取件页链接(二维码内容)</summary>
public string PickUrl { get; set; } = null!;
}
+63
View File
@@ -0,0 +1,63 @@
using FreeSql.DataAnnotations;
namespace WenChuanyi.Api.Models;
/// <summary>文件记录(files 表,FreeSql CodeFirst 自动建表)</summary>
[Table(Name = "files")]
[Index("uk_pickcode", nameof(PickCode), true)]
[Index("uk_admincode", nameof(AdminCode), true)]
[Index("idx_tag", nameof(Tag))]
public class FileItem
{
[Column(IsPrimary = true, IsIdentity = true)]
public long Id { get; set; }
/// <summary>取件码(共享 8 位 / 私密 6 位),唯一索引</summary>
[Column(StringLength = 8)]
public string PickCode { get; set; } = null!;
/// <summary>管理码 8 位字母数字,唯一索引</summary>
[Column(StringLength = 8)]
public string AdminCode { get; set; } = null!;
/// <summary>standard(无密码无标签)/ private(有密码)/ tagged(仅标签)</summary>
[Column(StringLength = 10)]
public string FileType { get; set; } = null!;
/// <summary>私密文件 4-12 位密码(可同时设置标签,不互斥)</summary>
[Column(StringLength = 12)]
public string? Password { get; set; }
/// <summary>标签(仅英文+数字),普通索引(非唯一,一对多),可与密码同时设置</summary>
[Column(StringLength = 32)]
public string? Tag { get; set; }
/// <summary>原始文件名(下载展示)</summary>
[Column(StringLength = 255)]
public string OriginalName { get; set; } = null!;
/// <summary>OSS 对象键:文传易2026/{yyyyMM}/{Guid}{ext}</summary>
[Column(StringLength = 255)]
public string ObjectKey { get; set; } = null!;
/// <summary>字节数</summary>
public long Size { get; set; }
[Column(StringLength = 100)]
public string? MimeType { get; set; }
/// <summary>下载次数(仅统计、不限制)</summary>
public int DownloadCount { get; set; }
/// <summary>上传者 IPIPv4/IPv6,安全审计,仅管理列表展示)</summary>
[Column(StringLength = 45)]
public string? UploadIp { get; set; }
/// <summary>含标签(无论是否设密码)强制 true(永久保存,不自动过期)</summary>
public bool IsPermanent { get; set; }
/// <summary>过期时间(IsPermanent=true 时为 null</summary>
public DateTime? ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text;
using FreeSql;
using WenChuanyi.Api.Models;
using WenChuanyi.Api.Services;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// CORS:开发期前后端分离宽松策略;生产同目录同源不受影响
builder.Services.AddCors(o => o.AddPolicy("any", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
// FreeSql + CodeFirst 自动建表
var fsql = new FreeSqlBuilder()
.UseConnectionString(DataType.MySql, builder.Configuration.GetConnectionString("MySql")!)
.UseAutoSyncStructure(true)
.UseNoneCommandParameter(true)
.Build();
try
{
fsql.CodeFirst.SyncStructure<FileItem>();
}
catch (Exception ex)
{
Console.WriteLine("[文传易] 数据库初始化失败:" + ex.Message);
Console.WriteLine("[文传易] 请检查 appsettings.json 的 ConnectionStrings:MySql");
Console.WriteLine("[文传易] (服务器 116.198.221.125:3306,账号 wenchuanyi 需具备建表权限)");
throw;
}
builder.Services.AddSingleton<IFreeSql>(fsql);
builder.Services.AddSingleton<CodeGeneratorService>();
builder.Services.AddSingleton<OssStorageService>();
builder.Services.AddHostedService<ExpiredFileCleanerService>();
var app = builder.Build();
app.UseCors("any");
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
@@ -0,0 +1,31 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:6294",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "http://localhost:5039",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,57 @@
using WenChuanyi.Api.Models;
namespace WenChuanyi.Api.Services;
/// <summary>取件码/管理码生成与查重(冲突自动重生成)</summary>
public class CodeGeneratorService
{
// 排除易混淆 0/O/1/I/l
private const string AdminChars = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
private readonly IFreeSql _fsql;
public CodeGeneratorService(IFreeSql fsql) => _fsql = fsql;
/// <summary>生成 length 位纯数字取件码(全局唯一)</summary>
public async Task<string> GeneratePickCodeAsync(int length)
{
for (var i = 0; i < 30; i++)
{
var code = GenerateDigits(length);
var exists = await _fsql.Select<FileItem>().Where(f => f.PickCode == code).AnyAsync();
if (!exists)
{
return code;
}
}
throw new InvalidOperationException("取件码生成冲突次数过多,请重试");
}
/// <summary>生成 8 位字母数字管理码(全局唯一)</summary>
public async Task<string> GenerateAdminCodeAsync()
{
for (var i = 0; i < 30; i++)
{
var code = new string(Enumerable.Range(0, 8)
.Select(_ => AdminChars[Random.Shared.Next(AdminChars.Length)]).ToArray());
var exists = await _fsql.Select<FileItem>().Where(f => f.AdminCode == code).AnyAsync();
if (!exists)
{
return code;
}
}
throw new InvalidOperationException("管理码生成冲突次数过多,请重试");
}
private static string GenerateDigits(int length)
{
var first = Random.Shared.Next(1, 10); // 首位非 0
var sb = new System.Text.StringBuilder();
sb.Append(first);
for (var i = 1; i < length; i++)
{
sb.Append(Random.Shared.Next(0, 10));
}
return sb.ToString();
}
}
@@ -0,0 +1,67 @@
using WenChuanyi.Api.Models;
namespace WenChuanyi.Api.Services;
/// <summary>过期文件定时清理:每 N 分钟扫描 IsPermanent=false 且已过期的记录,先删记录再删 OSS 对象</summary>
public class ExpiredFileCleanerService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExpiredFileCleanerService> _logger;
private readonly TimeSpan _interval;
public ExpiredFileCleanerService(
IServiceScopeFactory scopeFactory,
IConfiguration cfg,
ILogger<ExpiredFileCleanerService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
var minutes = cfg.GetValue<int>("Cleaner:IntervalMinutes", 30);
_interval = TimeSpan.FromMinutes(Math.Max(1, minutes));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("过期文件清理任务启动,间隔 {Minutes} 分钟", _interval.TotalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CleanExpiredAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "过期文件清理任务异常");
}
await Task.Delay(_interval, stoppingToken);
}
}
private async Task CleanExpiredAsync(CancellationToken ct)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var fsql = scope.ServiceProvider.GetRequiredService<IFreeSql>();
var oss = scope.ServiceProvider.GetRequiredService<OssStorageService>();
var expired = await fsql.Select<FileItem>()
.Where(f => !f.IsPermanent && f.ExpiresAt != null && f.ExpiresAt < DateTime.Now)
.ToListAsync(ct);
if (expired.Count == 0)
{
return;
}
_logger.LogInformation("发现 {Count} 个过期文件,开始清理", expired.Count);
foreach (var item in expired)
{
// 先删数据库记录
var deleted = await fsql.Delete<FileItem>().Where(f => f.Id == item.Id).ExecuteAffrowsAsync(ct) > 0;
// 再删 OSS 对象(失败仅记日志,不阻塞任务)
if (deleted)
{
oss.TryDeleteObject(item.ObjectKey);
}
}
}
}
@@ -0,0 +1,108 @@
using System.Text.RegularExpressions;
using WenChuanyi.Api.Models;
namespace WenChuanyi.Api.Services;
/// <summary>业务规则共享静态工具:密码/标签校验、模式判定、预览分类</summary>
public static class FileRules
{
public const long MaxFileBytes = 200L * 1024 * 1024; // 200MB
public const long MaxTextPreview = 2L * 1024 * 1024; // 文本预览 2MB
public const long MaxPdfImagePreview = 30L * 1024 * 1024; // PDF/图片预览 30MB
/// <summary>清洗文件名:去除 Windows 非法字符/控制字符、首尾空格与点、保留名与超长限制,防止存储路径注入</summary>
public static string SanitizeFileName(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "未命名";
var dot = name.LastIndexOf('.');
var ext = dot >= 0 ? name[(dot + 1)..] : "";
var baseName = dot >= 0 ? name[..dot] : name;
var cleanBase = new string(baseName
.Where(c => c >= 32 && c != 127 && c != '<' && c != '>' && c != ':' && c != '"'
&& c != '/' && c != '\\' && c != '|' && c != '?' && c != '*')
.ToArray()).Trim().TrimEnd('.', ' ');
var result = string.IsNullOrEmpty(cleanBase) ? "未命名" : cleanBase;
// Windows 保留设备名:CON PRN AUX NUL COM1-9 LPT1-9(含扩展名也禁止)
var upperBase = result.Split('.')[0].ToUpperInvariant();
if (upperBase is "CON" or "PRN" or "AUX" or "NUL"
|| Regex.IsMatch(upperBase, @"^COM[1-9]$") || Regex.IsMatch(upperBase, @"^LPT[1-9]$"))
{
result = "_" + result;
}
// 长度限制(保留扩展名,主体最长 180 字符)
if (result.Length > 180) result = result[..180];
// 扩展名仅保留字母/数字/点,最长 10 字符
var extRaw = new string(ext.Where(c => char.IsLetterOrDigit(c) || c == '.').ToArray()).TrimStart('.');
var cleanExt = extRaw.Length > 0 ? "." + (extRaw.Length > 10 ? extRaw[..10] : extRaw) : "";
return result + cleanExt;
}
/// <summary>校验密码(4-12 位),null 表示通过</summary>
public static string? ValidatePassword(string? pwd)
{
if (string.IsNullOrEmpty(pwd)) return null;
if (pwd.Length < 4 || pwd.Length > 12)
{
return "密码长度须为 4-12 位";
}
return null;
}
/// <summary>校验标签:仅英文+数字;纯数字须 &gt;8 位;含英文须 &gt;4 位;null 表示通过</summary>
public static string? ValidateTag(string? tag)
{
if (string.IsNullOrEmpty(tag)) return null;
if (!Regex.IsMatch(tag, "^[A-Za-z0-9]+$"))
{
return "标签仅允许英文与数字,不能包含符号或空格";
}
if (Regex.IsMatch(tag, "^[0-9]+$"))
{
if (tag.Length <= 8)
{
return "纯数字标签须超过 8 位(建议使用 11 位手机号码)";
}
}
else if (tag.Length <= 4)
{
return "含英文字母的标签须超过 4 位";
}
return null;
}
/// <summary>模式判定:有密码→private;仅标签→tagged;否则 standard</summary>
public static string ResolveFileType(bool hasPassword, bool hasTag)
=> hasPassword ? "private" : hasTag ? "tagged" : "standard";
/// <summary>取件码位数:私密 6 位,其余 8 位</summary>
public static int PickCodeLength(string fileType) => fileType == "private" ? 6 : 8;
/// <summary>剩余有效期文本</summary>
public static string ExpireText(FileItem f)
{
if (f.IsPermanent || f.ExpiresAt == null) return "永久";
var remain = f.ExpiresAt.Value - DateTime.Now;
if (remain <= TimeSpan.Zero) return "已过期";
if (remain.TotalDays >= 1) return $"{(int)remain.TotalDays} 天";
if (remain.TotalHours >= 1) return $"{(int)remain.TotalHours} 小时";
return $"{(int)Math.Max(1, remain.TotalMinutes)} 分钟";
}
public static bool IsText(string ext)
=> ext is ".txt" or ".md" or ".xml" or ".json" or ".csv" or ".log" or ".ini" or ".conf"
or ".srt" or ".html" or ".htm" or ".css" or ".js" or ".ts" or ".cs" or ".java" or ".sql"
or ".yml" or ".yaml" or ".sh" or ".bat" or ".ps1" or ".py" or ".go" or ".c" or ".h" or ".php";
public static bool IsPdfOrImage(string mime)
=> mime is "application/pdf"
or "image/png" or "image/jpeg" or "image/gif" or "image/webp" or "image/bmp" or "image/svg+xml";
public static bool IsVideoWhitelist(string ext) => ext is ".mp4" or ".webm";
public static bool IsAudioWhitelist(string ext) => ext is ".mp3" or ".wav" or ".m4a" or ".aac" or ".ogg";
}
@@ -0,0 +1,68 @@
using Aliyun.OSS;
namespace WenChuanyi.Api.Services;
/// <summary>OSS 存储服务:对象键构造、流式上传/下载/删除(后端中转,前端不接触凭据)</summary>
public class OssStorageService
{
private readonly OssClient _client;
private readonly string _bucket;
private readonly string _baseKey;
private readonly ILogger<OssStorageService> _logger;
public OssStorageService(IConfiguration cfg, ILogger<OssStorageService> logger)
{
var endpoint = cfg["Oss:Endpoint"] ?? throw new InvalidOperationException("缺少配置 Oss:Endpoint");
var ak = cfg["Oss:AccessKeyId"] ?? throw new InvalidOperationException("缺少配置 Oss:AccessKeyId");
var sk = cfg["Oss:AccessKeySecret"] ?? throw new InvalidOperationException("缺少配置 Oss:AccessKeySecret");
_bucket = cfg["Oss:Bucket"] ?? throw new InvalidOperationException("缺少配置 Oss:Bucket");
_baseKey = cfg["Oss:BaseKey"] ?? "文传易2026";
_client = new OssClient(endpoint, ak, sk);
_logger = logger;
}
/// <summary>构造对象键:文传易2026/{yyyyMM}/{Guid}{ext}(按上传月份建子目录)</summary>
public string BuildObjectKey(string originalName)
{
var month = DateTime.Now.ToString("yyyyMM");
var ext = Path.GetExtension(originalName).ToLowerInvariant();
return $"{_baseKey}/{month}/{Guid.NewGuid():N}{ext}";
}
/// <summary>流式上传(不落本地、不整体读内存)。ContentType 会去除参数部分(如 ";charset=utf-8"),
/// 因为 OSS SDK 对带参数的 Content-Type 签名会与服务端校验不一致,导致 403 签名不匹配。</summary>
public async Task UploadAsync(string objectKey, Stream stream, string contentType)
{
var safeContentType = (contentType ?? "application/octet-stream").Split(';')[0].Trim();
if (string.IsNullOrEmpty(safeContentType)) safeContentType = "application/octet-stream";
var req = new PutObjectRequest(_bucket, objectKey, stream);
req.Metadata = new ObjectMetadata { ContentType = safeContentType };
await Task.Run(() => _client.PutObject(req)).ConfigureAwait(false);
}
/// <summary>获取对象(支持 Rangeend 为 -1 表示到文件末尾)</summary>
public OssObject GetObject(string objectKey, long? start = null, long? end = null)
{
var req = new GetObjectRequest(_bucket, objectKey);
if (start.HasValue || end.HasValue)
{
req.SetRange(start ?? 0, end ?? -1);
}
return _client.GetObject(req);
}
/// <summary>删除对象(失败不抛出,由调用方记录日志)</summary>
public bool TryDeleteObject(string objectKey)
{
try
{
_client.DeleteObject(_bucket, objectKey);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "OSS 对象删除失败:{ObjectKey}", objectKey);
return false;
}
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.1" />
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@WenChuanyi.Api_HostAddress = http://localhost:5039
GET {{WenChuanyi.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,39 @@
{
"App": {
"BaseUrl": "https://wenchuanyi.bbitcn.net"
},
"ConnectionStrings": {
"MySql": "Data Source=116.198.221.125;Port=3306;User ID=wenchuanyi;Password=YOUR_DB_PASSWORD;Initial Catalog=wenchuanyi;Charset=utf8mb4;SslMode=None;Allow User Variables=true;Treat Tiny As Boolean=true"
},
"Oss": {
"Endpoint": "oss-cn-chengdu.aliyuncs.com",
"STSEndpoint": "sts.cn-chengdu.aliyuncs.com",
"Bucket": "bbit-f8-web",
"AccessKeyId": "YOUR_ACCESS_KEY_ID",
"AccessKeySecret": "YOUR_ACCESS_KEY_SECRET",
"BaseKey": "文传易2026"
},
"OpenApi": {
"UploadRoot": "D:\\WenChuanyi\\UploadRoot"
},
"Cleaner": {
"IntervalMinutes": 30
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:5280"
}
},
"Limits": {
"MaxRequestBodySize": 220200960
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}