Files
fanhongcai 1bfc72f9cb feat: 文传易匿名临时文件传输站初始版本
- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式
- 前端 Vue3 + Vite + TDesign,上传/取件/管理三页
- 取件码/二维码/海报合成(含保存二维码为图片)
- deploy/ 部署文档与一键 FTP 发布脚本
- appsettings.json 含真实凭据,已 gitignore 不入库
2026-08-24 00:03:28 +08:00

157 lines
6.3 KiB
C#
Raw Permalink 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 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>公开上传-共享:expiresHours0=永久,缺省 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";
}