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; /// 文件上传 / 查询 / 标签列表 / 预览 / 下载 [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 _logger; public FileController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss, IConfiguration cfg, ILogger logger) { _fsql = fsql; _codeGen = codeGen; _oss = oss; _baseUrl = cfg["App:BaseUrl"] ?? ""; _logger = logger; } // ---------- 上传 ---------- /// multipart 上传:file + expireHours(24/168/0) + password + tag(password 与 tag 可同时设置) [HttpPost("upload")] [RequestSizeLimit(220_200_960)] // 210MB(Kestrel 同配置) [RequestFormLimits(MultipartBodyLengthLimit = 220_200_960)] // 210MB(默认 128MB,会拒 >128MB 的 multipart 上传) public async Task 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) }); } // ---------- 查询 ---------- /// 按取件码查询(私密需带 password;过期返回 410) [HttpGet("{code}")] public async Task 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)); } /// 按标签查询文件列表(CreatedAt 倒序,limit 100) [HttpGet("by-tag/{tag}")] public async Task ByTag(string tag) { if (string.IsNullOrEmpty(tag) || !tag.All(char.IsLetterOrDigit)) { return BadRequest(new { message = "标签无效" }); } var list = await _fsql.Select() .Where(f => f.Tag == tag) .OrderByDescending(f => f.CreatedAt) .Limit(100) .ToListAsync(); return Ok(list.Select(ToDto)); } // ---------- 下载 ---------- /// 流式下载:计数 +1(仅统计),还原原始文件名,支持 Range 断点续传(私密需密码) [HttpGet("{code}/download")] public async Task 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() .Set(f => f.DownloadCount + 1) .Where(f => f.Id == item.Id) .ExecuteAffrowsAsync(); await ServeStreamAsync(item, inline: false); return new EmptyResult(); } // ---------- 预览 ---------- /// 在线预览:PDF/图片(≤30MB)/ 音视频白名单(不限大小)inline 流;不计次数 [HttpGet("{code}/preview")] public async Task 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 = "该格式不支持在线预览,请下载查看" }); } /// 文本类内容(≤2MB),供在线阅读渲染(私密需密码) [HttpGet("{code}/content")] public async Task 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); /// 校验取件码 + 过期(懒检查)+ 私密密码;错误时返回 (null, message, status) 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().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}"; /// OSS 流式输出到响应(inline/attachment + Range 206 透传) 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); } /// 文本内容读取(≤2MB,UTF-8 优先,GBK 兜底) private async Task 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; } }