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";
}