Files
wenchuanyi/backend/WenChuanyi.Api/Controllers/FileController.cs
T
fanhongcai 01c9cc2330 docs: 鏇存柊闇€姹傛枃妗h嚦v0.5骞跺綊妗e紑鍙戣繘搴︼紱淇澶ф枃浠朵笂浼犱笌浜岀淮鐮佹捣鎶?
- 闇€姹傛枃妗e崌绾у埌 v0.5锛氳ˉ鍏呬簩缁寸爜娴锋姤淇濆瓨鏂囦欢鍚嶉粯璁ゆ牸寮忋€佸ぇ鏂囦欢涓婁紶涓夊眰闄愬埗锛圛IS+ASP.NET+鍓嶇锛夐儴缃茬害鏉熴€佹柊澧炵11绔犲紑鍙戣繘搴︾姸鎬佸綊妗o紙宸蹭笂绾垮姛鑳?宸蹭慨澶嶇己闄?宸茬煡闄愬埗/鍚庣画浼樺寲锛?- 鍚庣 FileController.Upload 澧炲姞 [RequestFormLimits(MultipartBodyLengthLimit=220_200_960)]锛屼慨澶?128MB multipart 涓婁紶琚嫆
- 鍓嶇 UploadView 浜岀淮鐮佹捣鎶ワ細淇 drawImage 缂╂斁閿欎綅銆侀《閮╨ogo/鏍囬鍨傜洿灞呬腑銆佸垎鍖烘爣棰橀棿璺濄€佸ぇ灏忎笌鏈夋晥鏈熷垎琛屾樉绀猴紱淇濆瓨鏂囦欢鍚嶆敼涓?鏂囦紶鏄撳彇浠剁爜-鍙栦欢鐮?鍘熸枃浠跺悕)-娴佹按鍙?png
- 鏂板閮ㄧ讲鑴氭湰锛歛pply_backend.ps1 / force_dll.ps1锛坅pp_offline 瑙i攣DLL锛? ftp_fe.ps1 / ftp_diag.ps1 / ftp_apply.ps1 / _diag/check_dll.ps1

(閮ㄧ讲鎵嬪唽 IIS閮ㄧ讲涓嶧TP鍙戝竷.md 浠呭惈缂栫爜/BOM 宸紓锛屾湭绾冲叆鏈鎻愪氦)
2026-08-24 01:59:27 +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;
}
}