Files
wenchuanyi/backend/WenChuanyi.Api/Controllers/FileController.cs
T
fanhongcai 5568548001 docs: 更新需求文档至v0.5并归档开发进度;修复大文件上传与二维码海报
- 需求文档升级到 v0.5:补充二维码海报保存文件名默认格式、大文件上传三层限制(IIS+ASP.NET+前端)部署约束、新增第11章开发进度状态归档(已上线功能/已修复缺陷/已知限制/后续优化)
- 后端 FileController.Upload 增加 [RequestFormLimits(MultipartBodyLengthLimit=220_200_960)],修复>128MB multipart 上传被拒
- 前端 UploadView 二维码海报:修正 drawImage 缩放错位、顶部logo/标题垂直居中、分区标题间距、大小与有效期分行显示;保存文件名改为 文传易取件码-取件码(原文件名)-流水号.png
- 新增部署脚本:apply_backend.ps1 / force_dll.ps1(app_offline 解锁DLL)/ ftp_fe.ps1 / ftp_diag.ps1 / ftp_apply.ps1 / _diag/check_dll.ps1

(部署手册 IIS部署与FTP发布.md 仅含编码/BOM 差异,未纳入本次提交)
2026-08-24 02:02:57 +08:00

394 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 同配置)
[RequestFormLimits(MultipartBodyLengthLimit = 220_200_960)] // 210MB(默认 128MB,会拒 >128MB 的 multipart 上传)
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;
}
}