feat: 文传易匿名临时文件传输站初始版本
- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式 - 前端 Vue3 + Vite + TDesign,上传/取件/管理三页 - 取件码/二维码/海报合成(含保存二维码为图片) - deploy/ 部署文档与一键 FTP 发布脚本 - appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WenChuanyi.Api.Dtos;
|
||||
using WenChuanyi.Api.Models;
|
||||
using WenChuanyi.Api.Services;
|
||||
|
||||
namespace WenChuanyi.Api.Controllers;
|
||||
|
||||
/// <summary>开放 API:按服务端文件路径上传(共享/私密/标签)+ 下载链接查询</summary>
|
||||
[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<OpenApiController> _logger;
|
||||
|
||||
public OpenApiController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss,
|
||||
IConfiguration cfg, ILogger<OpenApiController> logger)
|
||||
{
|
||||
_fsql = fsql;
|
||||
_codeGen = codeGen;
|
||||
_oss = oss;
|
||||
_baseUrl = cfg["App:BaseUrl"] ?? "";
|
||||
_uploadRoot = Path.GetFullPath(cfg["OpenApi:UploadRoot"] ?? throw new InvalidOperationException("缺少配置 OpenApi:UploadRoot"));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>公开上传-共享:expiresHours(0=永久,缺省 24);pwd/tag 可同时提供(不互斥)</summary>
|
||||
[HttpPost("upload-public")]
|
||||
public Task<IActionResult> UploadPublic([FromBody] OpenUploadRequest req)
|
||||
=> UploadFromPathAsync(req.FilePath, req.ExpiresHours, req.Pwd, req.Tag);
|
||||
|
||||
/// <summary>公开上传-私密:必填密码,可同时带标签</summary>
|
||||
[HttpPost("upload-price")]
|
||||
public Task<IActionResult> UploadPrice([FromBody] OpenUploadPriceRequest req)
|
||||
=> UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag);
|
||||
|
||||
/// <summary>公开上传-标签:必填标签(永久保存),可同时带密码</summary>
|
||||
[HttpPost("upload-tag")]
|
||||
public Task<IActionResult> UploadTag([FromBody] OpenUploadTagRequest req)
|
||||
=> UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag);
|
||||
|
||||
/// <summary>公开下载链接查询:共享/标签返回直接下载 URL,私密跳取件页手动输密码</summary>
|
||||
[HttpGet("download/{pickCode}")]
|
||||
public async Task<IActionResult> Download(string pickCode)
|
||||
{
|
||||
if (pickCode.Length is not (6 or 8) || !pickCode.All(char.IsDigit))
|
||||
{
|
||||
return NotFound(new { message = "取件码无效" });
|
||||
}
|
||||
var item = await _fsql.Select<FileItem>().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<IActionResult> 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)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>路径规范化 + 白名单前缀校验,防路径穿越</summary>
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user