using Microsoft.AspNetCore.Mvc; using WenChuanyi.Api.Dtos; using WenChuanyi.Api.Models; using WenChuanyi.Api.Services; namespace WenChuanyi.Api.Controllers; /// 开放 API:按服务端文件路径上传(共享/私密/标签)+ 下载链接查询 [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 _logger; public OpenApiController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss, IConfiguration cfg, ILogger logger) { _fsql = fsql; _codeGen = codeGen; _oss = oss; _baseUrl = cfg["App:BaseUrl"] ?? ""; _uploadRoot = Path.GetFullPath(cfg["OpenApi:UploadRoot"] ?? throw new InvalidOperationException("缺少配置 OpenApi:UploadRoot")); _logger = logger; } /// 公开上传-共享:expiresHours(0=永久,缺省 24);pwd/tag 可同时提供(不互斥) [HttpPost("upload-public")] public Task UploadPublic([FromBody] OpenUploadRequest req) => UploadFromPathAsync(req.FilePath, req.ExpiresHours, req.Pwd, req.Tag); /// 公开上传-私密:必填密码,可同时带标签 [HttpPost("upload-price")] public Task UploadPrice([FromBody] OpenUploadPriceRequest req) => UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag); /// 公开上传-标签:必填标签(永久保存),可同时带密码 [HttpPost("upload-tag")] public Task UploadTag([FromBody] OpenUploadTagRequest req) => UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag); /// 公开下载链接查询:共享/标签返回直接下载 URL,私密跳取件页手动输密码 [HttpGet("download/{pickCode}")] public async Task Download(string pickCode) { if (pickCode.Length is not (6 or 8) || !pickCode.All(char.IsDigit)) { return NotFound(new { message = "取件码无效" }); } var item = await _fsql.Select().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 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) }); } /// 路径规范化 + 白名单前缀校验,防路径穿越 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"; }