feat: 文传易匿名临时文件传输站初始版本
- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式 - 前端 Vue3 + Vite + TDesign,上传/取件/管理三页 - 取件码/二维码/海报合成(含保存二维码为图片) - deploy/ 部署文档与一键 FTP 发布脚本 - appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
appsettings.json
|
||||
*.user
|
||||
.vs/
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WenChuanyi.Api.Dtos;
|
||||
using WenChuanyi.Api.Models;
|
||||
using WenChuanyi.Api.Services;
|
||||
|
||||
namespace WenChuanyi.Api.Controllers;
|
||||
|
||||
/// <summary>发送者管理:凭管理码查看文件列表 / 删除文件</summary>
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly IFreeSql _fsql;
|
||||
private readonly OssStorageService _oss;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
|
||||
public AdminController(IFreeSql fsql, OssStorageService oss, ILogger<AdminController> logger)
|
||||
{
|
||||
_fsql = fsql;
|
||||
_oss = oss;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>管理码查询文件列表(含上传 IP)</summary>
|
||||
[HttpGet("files/{adminCode}")]
|
||||
public async Task<IActionResult> List(string adminCode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(adminCode) || adminCode.Length != 8)
|
||||
{
|
||||
return BadRequest(new { message = "管理码无效" });
|
||||
}
|
||||
var list = await _fsql.Select<FileItem>()
|
||||
.Where(f => f.AdminCode == adminCode)
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(list.Select(ToDto));
|
||||
}
|
||||
|
||||
/// <summary>删除指定文件(body 传 id 或取件码,校验管理码)</summary>
|
||||
[HttpDelete("files/{adminCode}")]
|
||||
public async Task<IActionResult> Delete(string adminCode, [FromBody] DeleteRequest? req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(adminCode) || adminCode.Length != 8)
|
||||
{
|
||||
return BadRequest(new { message = "管理码无效" });
|
||||
}
|
||||
if (req == null || (req.Id == null && string.IsNullOrEmpty(req.PickCode)))
|
||||
{
|
||||
return BadRequest(new { message = "请提供要删除的文件 Id 或取件码" });
|
||||
}
|
||||
|
||||
FileItem? item;
|
||||
if (req.Id != null)
|
||||
{
|
||||
item = await _fsql.Select<FileItem>()
|
||||
.Where(f => f.Id == req.Id.Value && f.AdminCode == adminCode).FirstAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
item = await _fsql.Select<FileItem>()
|
||||
.Where(f => f.PickCode == req.PickCode && f.AdminCode == adminCode).FirstAsync();
|
||||
}
|
||||
if (item == null)
|
||||
{
|
||||
return NotFound(new { message = "文件不存在或管理码不匹配" });
|
||||
}
|
||||
|
||||
var deleted = await _fsql.Delete<FileItem>().Where(f => f.Id == item.Id).ExecuteAffrowsAsync() > 0;
|
||||
if (deleted)
|
||||
{
|
||||
_oss.TryDeleteObject(item.ObjectKey);
|
||||
}
|
||||
return Ok(new { message = "已删除" });
|
||||
}
|
||||
|
||||
private static 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,
|
||||
UploadIp = f.UploadIp,
|
||||
IsPermanent = f.IsPermanent,
|
||||
ExpiresAt = f.ExpiresAt,
|
||||
CreatedAt = f.CreatedAt,
|
||||
ExpireText = FileRules.ExpireText(f)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
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;
|
||||
|
||||
/// <summary>文件上传 / 查询 / 标签列表 / 预览 / 下载</summary>
|
||||
[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<FileController> _logger;
|
||||
|
||||
public FileController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss,
|
||||
IConfiguration cfg, ILogger<FileController> logger)
|
||||
{
|
||||
_fsql = fsql;
|
||||
_codeGen = codeGen;
|
||||
_oss = oss;
|
||||
_baseUrl = cfg["App:BaseUrl"] ?? "";
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ---------- 上传 ----------
|
||||
|
||||
/// <summary>multipart 上传:file + expireHours(24/168/0) + password + tag(password 与 tag 可同时设置)</summary>
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(220_200_960)] // 210MB(Kestrel 同配置)
|
||||
public async Task<IActionResult> 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)
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 查询 ----------
|
||||
|
||||
/// <summary>按取件码查询(私密需带 password;过期返回 410)</summary>
|
||||
[HttpGet("{code}")]
|
||||
public async Task<IActionResult> 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));
|
||||
}
|
||||
|
||||
/// <summary>按标签查询文件列表(CreatedAt 倒序,limit 100)</summary>
|
||||
[HttpGet("by-tag/{tag}")]
|
||||
public async Task<IActionResult> ByTag(string tag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tag) || !tag.All(char.IsLetterOrDigit))
|
||||
{
|
||||
return BadRequest(new { message = "标签无效" });
|
||||
}
|
||||
var list = await _fsql.Select<FileItem>()
|
||||
.Where(f => f.Tag == tag)
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Limit(100)
|
||||
.ToListAsync();
|
||||
return Ok(list.Select(ToDto));
|
||||
}
|
||||
|
||||
// ---------- 下载 ----------
|
||||
|
||||
/// <summary>流式下载:计数 +1(仅统计),还原原始文件名,支持 Range 断点续传(私密需密码)</summary>
|
||||
[HttpGet("{code}/download")]
|
||||
public async Task<IActionResult> 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<FileItem>()
|
||||
.Set(f => f.DownloadCount + 1)
|
||||
.Where(f => f.Id == item.Id)
|
||||
.ExecuteAffrowsAsync();
|
||||
await ServeStreamAsync(item, inline: false);
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
// ---------- 预览 ----------
|
||||
|
||||
/// <summary>在线预览:PDF/图片(≤30MB)/ 音视频白名单(不限大小)inline 流;不计次数</summary>
|
||||
[HttpGet("{code}/preview")]
|
||||
public async Task<IActionResult> 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 = "该格式不支持在线预览,请下载查看" });
|
||||
}
|
||||
|
||||
/// <summary>文本类内容(≤2MB),供在线阅读渲染(私密需密码)</summary>
|
||||
[HttpGet("{code}/content")]
|
||||
public async Task<IActionResult> 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);
|
||||
|
||||
/// <summary>校验取件码 + 过期(懒检查)+ 私密密码;错误时返回 (null, message, status)</summary>
|
||||
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<FileItem>().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}";
|
||||
|
||||
/// <summary>OSS 流式输出到响应(inline/attachment + Range 206 透传)</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>文本内容读取(≤2MB,UTF-8 优先,GBK 兜底)</summary>
|
||||
private async Task<IActionResult> 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace WenChuanyi.Api.Dtos;
|
||||
|
||||
/// <summary>文件信息 / 管理列表 / 标签列表 DTO(不暴露密码明文)</summary>
|
||||
public class FileInfoDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string PickCode { get; set; } = null!;
|
||||
public string FileType { get; set; } = null!;
|
||||
public bool HasPassword { get; set; }
|
||||
public string OriginalName { get; set; } = null!;
|
||||
public long Size { get; set; }
|
||||
public string? MimeType { get; set; }
|
||||
public int DownloadCount { get; set; }
|
||||
/// <summary>上传者 IP(安全审计):仅管理列表填充,取件/标签/公开接口不暴露</summary>
|
||||
public string? UploadIp { get; set; }
|
||||
public bool IsPermanent { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>剩余有效期文本(永久/已过期/N天N小时)</summary>
|
||||
public string ExpireText { get; set; } = "永久";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace WenChuanyi.Api.Dtos;
|
||||
|
||||
/// <summary>公开上传-共享文件:有效期小时数(0=永久,缺省 24),pwd/tag 可同时提供(不互斥)</summary>
|
||||
public class OpenUploadRequest
|
||||
{
|
||||
public string FilePath { get; set; } = null!;
|
||||
public long ExpiresHours { get; set; } = 24;
|
||||
public string? Pwd { get; set; }
|
||||
public string? Tag { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>公开上传-私密文件:必须有密码,可同时带标签</summary>
|
||||
public class OpenUploadPriceRequest
|
||||
{
|
||||
public string FilePath { get; set; } = null!;
|
||||
public string Pwd { get; set; } = null!;
|
||||
public string? Tag { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>公开上传-标签文件:必须带标签(永久保存),可同时带密码</summary>
|
||||
public class OpenUploadTagRequest
|
||||
{
|
||||
public string FilePath { get; set; } = null!;
|
||||
public string Tag { get; set; } = null!;
|
||||
public string? Pwd { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>公开上传响应</summary>
|
||||
public class OpenResult
|
||||
{
|
||||
public string PickCode { get; set; } = null!;
|
||||
public string PickUrl { get; set; } = null!;
|
||||
public string DownloadUrl { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>公开下载链接查询响应</summary>
|
||||
public class OpenDownloadResult
|
||||
{
|
||||
public string DownloadUrl { get; set; } = null!;
|
||||
public string PickUrl { get; set; } = null!;
|
||||
public bool NeedPassword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>管理删除请求体:按 Id 或取件码删除</summary>
|
||||
public class DeleteRequest
|
||||
{
|
||||
public long? Id { get; set; }
|
||||
public string? PickCode { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace WenChuanyi.Api.Dtos;
|
||||
|
||||
/// <summary>上传响应:取件凭证 + 管理码 + 文件信息</summary>
|
||||
public class UploadResultDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string PickCode { get; set; } = null!;
|
||||
public string AdminCode { get; set; } = null!;
|
||||
public string FileType { get; set; } = null!;
|
||||
public string OriginalName { get; set; } = null!;
|
||||
public long Size { get; set; }
|
||||
public bool IsPermanent { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>取件页链接(二维码内容)</summary>
|
||||
public string PickUrl { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace WenChuanyi.Api.Models;
|
||||
|
||||
/// <summary>文件记录(files 表,FreeSql CodeFirst 自动建表)</summary>
|
||||
[Table(Name = "files")]
|
||||
[Index("uk_pickcode", nameof(PickCode), true)]
|
||||
[Index("uk_admincode", nameof(AdminCode), true)]
|
||||
[Index("idx_tag", nameof(Tag))]
|
||||
public class FileItem
|
||||
{
|
||||
[Column(IsPrimary = true, IsIdentity = true)]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>取件码(共享 8 位 / 私密 6 位),唯一索引</summary>
|
||||
[Column(StringLength = 8)]
|
||||
public string PickCode { get; set; } = null!;
|
||||
|
||||
/// <summary>管理码 8 位字母数字,唯一索引</summary>
|
||||
[Column(StringLength = 8)]
|
||||
public string AdminCode { get; set; } = null!;
|
||||
|
||||
/// <summary>standard(无密码无标签)/ private(有密码)/ tagged(仅标签)</summary>
|
||||
[Column(StringLength = 10)]
|
||||
public string FileType { get; set; } = null!;
|
||||
|
||||
/// <summary>私密文件 4-12 位密码(可同时设置标签,不互斥)</summary>
|
||||
[Column(StringLength = 12)]
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>标签(仅英文+数字),普通索引(非唯一,一对多),可与密码同时设置</summary>
|
||||
[Column(StringLength = 32)]
|
||||
public string? Tag { get; set; }
|
||||
|
||||
/// <summary>原始文件名(下载展示)</summary>
|
||||
[Column(StringLength = 255)]
|
||||
public string OriginalName { get; set; } = null!;
|
||||
|
||||
/// <summary>OSS 对象键:文传易2026/{yyyyMM}/{Guid}{ext}</summary>
|
||||
[Column(StringLength = 255)]
|
||||
public string ObjectKey { get; set; } = null!;
|
||||
|
||||
/// <summary>字节数</summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
[Column(StringLength = 100)]
|
||||
public string? MimeType { get; set; }
|
||||
|
||||
/// <summary>下载次数(仅统计、不限制)</summary>
|
||||
public int DownloadCount { get; set; }
|
||||
|
||||
/// <summary>上传者 IP(IPv4/IPv6,安全审计,仅管理列表展示)</summary>
|
||||
[Column(StringLength = 45)]
|
||||
public string? UploadIp { get; set; }
|
||||
|
||||
/// <summary>含标签(无论是否设密码)强制 true(永久保存,不自动过期)</summary>
|
||||
public bool IsPermanent { get; set; }
|
||||
|
||||
/// <summary>过期时间(IsPermanent=true 时为 null)</summary>
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text;
|
||||
using FreeSql;
|
||||
using WenChuanyi.Api.Models;
|
||||
using WenChuanyi.Api.Services;
|
||||
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// CORS:开发期前后端分离宽松策略;生产同目录同源不受影响
|
||||
builder.Services.AddCors(o => o.AddPolicy("any", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
// FreeSql + CodeFirst 自动建表
|
||||
var fsql = new FreeSqlBuilder()
|
||||
.UseConnectionString(DataType.MySql, builder.Configuration.GetConnectionString("MySql")!)
|
||||
.UseAutoSyncStructure(true)
|
||||
.UseNoneCommandParameter(true)
|
||||
.Build();
|
||||
|
||||
try
|
||||
{
|
||||
fsql.CodeFirst.SyncStructure<FileItem>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[文传易] 数据库初始化失败:" + ex.Message);
|
||||
Console.WriteLine("[文传易] 请检查 appsettings.json 的 ConnectionStrings:MySql");
|
||||
Console.WriteLine("[文传易] (服务器 116.198.221.125:3306,账号 wenchuanyi 需具备建表权限)");
|
||||
throw;
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<IFreeSql>(fsql);
|
||||
builder.Services.AddSingleton<CodeGeneratorService>();
|
||||
builder.Services.AddSingleton<OssStorageService>();
|
||||
builder.Services.AddHostedService<ExpiredFileCleanerService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors("any");
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:6294",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "weatherforecast",
|
||||
"applicationUrl": "http://localhost:5039",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "weatherforecast",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using WenChuanyi.Api.Models;
|
||||
|
||||
namespace WenChuanyi.Api.Services;
|
||||
|
||||
/// <summary>取件码/管理码生成与查重(冲突自动重生成)</summary>
|
||||
public class CodeGeneratorService
|
||||
{
|
||||
// 排除易混淆 0/O/1/I/l
|
||||
private const string AdminChars = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
|
||||
private readonly IFreeSql _fsql;
|
||||
|
||||
public CodeGeneratorService(IFreeSql fsql) => _fsql = fsql;
|
||||
|
||||
/// <summary>生成 length 位纯数字取件码(全局唯一)</summary>
|
||||
public async Task<string> GeneratePickCodeAsync(int length)
|
||||
{
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var code = GenerateDigits(length);
|
||||
var exists = await _fsql.Select<FileItem>().Where(f => f.PickCode == code).AnyAsync();
|
||||
if (!exists)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("取件码生成冲突次数过多,请重试");
|
||||
}
|
||||
|
||||
/// <summary>生成 8 位字母数字管理码(全局唯一)</summary>
|
||||
public async Task<string> GenerateAdminCodeAsync()
|
||||
{
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var code = new string(Enumerable.Range(0, 8)
|
||||
.Select(_ => AdminChars[Random.Shared.Next(AdminChars.Length)]).ToArray());
|
||||
var exists = await _fsql.Select<FileItem>().Where(f => f.AdminCode == code).AnyAsync();
|
||||
if (!exists)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("管理码生成冲突次数过多,请重试");
|
||||
}
|
||||
|
||||
private static string GenerateDigits(int length)
|
||||
{
|
||||
var first = Random.Shared.Next(1, 10); // 首位非 0
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append(first);
|
||||
for (var i = 1; i < length; i++)
|
||||
{
|
||||
sb.Append(Random.Shared.Next(0, 10));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using WenChuanyi.Api.Models;
|
||||
|
||||
namespace WenChuanyi.Api.Services;
|
||||
|
||||
/// <summary>过期文件定时清理:每 N 分钟扫描 IsPermanent=false 且已过期的记录,先删记录再删 OSS 对象</summary>
|
||||
public class ExpiredFileCleanerService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<ExpiredFileCleanerService> _logger;
|
||||
private readonly TimeSpan _interval;
|
||||
|
||||
public ExpiredFileCleanerService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration cfg,
|
||||
ILogger<ExpiredFileCleanerService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
var minutes = cfg.GetValue<int>("Cleaner:IntervalMinutes", 30);
|
||||
_interval = TimeSpan.FromMinutes(Math.Max(1, minutes));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("过期文件清理任务启动,间隔 {Minutes} 分钟", _interval.TotalMinutes);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CleanExpiredAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "过期文件清理任务异常");
|
||||
}
|
||||
await Task.Delay(_interval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanExpiredAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var fsql = scope.ServiceProvider.GetRequiredService<IFreeSql>();
|
||||
var oss = scope.ServiceProvider.GetRequiredService<OssStorageService>();
|
||||
|
||||
var expired = await fsql.Select<FileItem>()
|
||||
.Where(f => !f.IsPermanent && f.ExpiresAt != null && f.ExpiresAt < DateTime.Now)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("发现 {Count} 个过期文件,开始清理", expired.Count);
|
||||
foreach (var item in expired)
|
||||
{
|
||||
// 先删数据库记录
|
||||
var deleted = await fsql.Delete<FileItem>().Where(f => f.Id == item.Id).ExecuteAffrowsAsync(ct) > 0;
|
||||
// 再删 OSS 对象(失败仅记日志,不阻塞任务)
|
||||
if (deleted)
|
||||
{
|
||||
oss.TryDeleteObject(item.ObjectKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using WenChuanyi.Api.Models;
|
||||
|
||||
namespace WenChuanyi.Api.Services;
|
||||
|
||||
/// <summary>业务规则共享静态工具:密码/标签校验、模式判定、预览分类</summary>
|
||||
public static class FileRules
|
||||
{
|
||||
public const long MaxFileBytes = 200L * 1024 * 1024; // 200MB
|
||||
public const long MaxTextPreview = 2L * 1024 * 1024; // 文本预览 2MB
|
||||
public const long MaxPdfImagePreview = 30L * 1024 * 1024; // PDF/图片预览 30MB
|
||||
|
||||
/// <summary>清洗文件名:去除 Windows 非法字符/控制字符、首尾空格与点、保留名与超长限制,防止存储路径注入</summary>
|
||||
public static string SanitizeFileName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "未命名";
|
||||
var dot = name.LastIndexOf('.');
|
||||
var ext = dot >= 0 ? name[(dot + 1)..] : "";
|
||||
var baseName = dot >= 0 ? name[..dot] : name;
|
||||
|
||||
var cleanBase = new string(baseName
|
||||
.Where(c => c >= 32 && c != 127 && c != '<' && c != '>' && c != ':' && c != '"'
|
||||
&& c != '/' && c != '\\' && c != '|' && c != '?' && c != '*')
|
||||
.ToArray()).Trim().TrimEnd('.', ' ');
|
||||
|
||||
var result = string.IsNullOrEmpty(cleanBase) ? "未命名" : cleanBase;
|
||||
|
||||
// Windows 保留设备名:CON PRN AUX NUL COM1-9 LPT1-9(含扩展名也禁止)
|
||||
var upperBase = result.Split('.')[0].ToUpperInvariant();
|
||||
if (upperBase is "CON" or "PRN" or "AUX" or "NUL"
|
||||
|| Regex.IsMatch(upperBase, @"^COM[1-9]$") || Regex.IsMatch(upperBase, @"^LPT[1-9]$"))
|
||||
{
|
||||
result = "_" + result;
|
||||
}
|
||||
|
||||
// 长度限制(保留扩展名,主体最长 180 字符)
|
||||
if (result.Length > 180) result = result[..180];
|
||||
|
||||
// 扩展名仅保留字母/数字/点,最长 10 字符
|
||||
var extRaw = new string(ext.Where(c => char.IsLetterOrDigit(c) || c == '.').ToArray()).TrimStart('.');
|
||||
var cleanExt = extRaw.Length > 0 ? "." + (extRaw.Length > 10 ? extRaw[..10] : extRaw) : "";
|
||||
|
||||
return result + cleanExt;
|
||||
}
|
||||
|
||||
/// <summary>校验密码(4-12 位),null 表示通过</summary>
|
||||
public static string? ValidatePassword(string? pwd)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pwd)) return null;
|
||||
if (pwd.Length < 4 || pwd.Length > 12)
|
||||
{
|
||||
return "密码长度须为 4-12 位";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>校验标签:仅英文+数字;纯数字须 >8 位;含英文须 >4 位;null 表示通过</summary>
|
||||
public static string? ValidateTag(string? tag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tag)) return null;
|
||||
if (!Regex.IsMatch(tag, "^[A-Za-z0-9]+$"))
|
||||
{
|
||||
return "标签仅允许英文与数字,不能包含符号或空格";
|
||||
}
|
||||
if (Regex.IsMatch(tag, "^[0-9]+$"))
|
||||
{
|
||||
if (tag.Length <= 8)
|
||||
{
|
||||
return "纯数字标签须超过 8 位(建议使用 11 位手机号码)";
|
||||
}
|
||||
}
|
||||
else if (tag.Length <= 4)
|
||||
{
|
||||
return "含英文字母的标签须超过 4 位";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>模式判定:有密码→private;仅标签→tagged;否则 standard</summary>
|
||||
public static string ResolveFileType(bool hasPassword, bool hasTag)
|
||||
=> hasPassword ? "private" : hasTag ? "tagged" : "standard";
|
||||
|
||||
/// <summary>取件码位数:私密 6 位,其余 8 位</summary>
|
||||
public static int PickCodeLength(string fileType) => fileType == "private" ? 6 : 8;
|
||||
|
||||
/// <summary>剩余有效期文本</summary>
|
||||
public static string ExpireText(FileItem f)
|
||||
{
|
||||
if (f.IsPermanent || f.ExpiresAt == null) return "永久";
|
||||
var remain = f.ExpiresAt.Value - DateTime.Now;
|
||||
if (remain <= TimeSpan.Zero) return "已过期";
|
||||
if (remain.TotalDays >= 1) return $"{(int)remain.TotalDays} 天";
|
||||
if (remain.TotalHours >= 1) return $"{(int)remain.TotalHours} 小时";
|
||||
return $"{(int)Math.Max(1, remain.TotalMinutes)} 分钟";
|
||||
}
|
||||
|
||||
public static bool IsText(string ext)
|
||||
=> ext is ".txt" or ".md" or ".xml" or ".json" or ".csv" or ".log" or ".ini" or ".conf"
|
||||
or ".srt" or ".html" or ".htm" or ".css" or ".js" or ".ts" or ".cs" or ".java" or ".sql"
|
||||
or ".yml" or ".yaml" or ".sh" or ".bat" or ".ps1" or ".py" or ".go" or ".c" or ".h" or ".php";
|
||||
|
||||
public static bool IsPdfOrImage(string mime)
|
||||
=> mime is "application/pdf"
|
||||
or "image/png" or "image/jpeg" or "image/gif" or "image/webp" or "image/bmp" or "image/svg+xml";
|
||||
|
||||
public static bool IsVideoWhitelist(string ext) => ext is ".mp4" or ".webm";
|
||||
public static bool IsAudioWhitelist(string ext) => ext is ".mp3" or ".wav" or ".m4a" or ".aac" or ".ogg";
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Aliyun.OSS;
|
||||
|
||||
namespace WenChuanyi.Api.Services;
|
||||
|
||||
/// <summary>OSS 存储服务:对象键构造、流式上传/下载/删除(后端中转,前端不接触凭据)</summary>
|
||||
public class OssStorageService
|
||||
{
|
||||
private readonly OssClient _client;
|
||||
private readonly string _bucket;
|
||||
private readonly string _baseKey;
|
||||
private readonly ILogger<OssStorageService> _logger;
|
||||
|
||||
public OssStorageService(IConfiguration cfg, ILogger<OssStorageService> logger)
|
||||
{
|
||||
var endpoint = cfg["Oss:Endpoint"] ?? throw new InvalidOperationException("缺少配置 Oss:Endpoint");
|
||||
var ak = cfg["Oss:AccessKeyId"] ?? throw new InvalidOperationException("缺少配置 Oss:AccessKeyId");
|
||||
var sk = cfg["Oss:AccessKeySecret"] ?? throw new InvalidOperationException("缺少配置 Oss:AccessKeySecret");
|
||||
_bucket = cfg["Oss:Bucket"] ?? throw new InvalidOperationException("缺少配置 Oss:Bucket");
|
||||
_baseKey = cfg["Oss:BaseKey"] ?? "文传易2026";
|
||||
_client = new OssClient(endpoint, ak, sk);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>构造对象键:文传易2026/{yyyyMM}/{Guid}{ext}(按上传月份建子目录)</summary>
|
||||
public string BuildObjectKey(string originalName)
|
||||
{
|
||||
var month = DateTime.Now.ToString("yyyyMM");
|
||||
var ext = Path.GetExtension(originalName).ToLowerInvariant();
|
||||
return $"{_baseKey}/{month}/{Guid.NewGuid():N}{ext}";
|
||||
}
|
||||
|
||||
/// <summary>流式上传(不落本地、不整体读内存)。ContentType 会去除参数部分(如 ";charset=utf-8"),
|
||||
/// 因为 OSS SDK 对带参数的 Content-Type 签名会与服务端校验不一致,导致 403 签名不匹配。</summary>
|
||||
public async Task UploadAsync(string objectKey, Stream stream, string contentType)
|
||||
{
|
||||
var safeContentType = (contentType ?? "application/octet-stream").Split(';')[0].Trim();
|
||||
if (string.IsNullOrEmpty(safeContentType)) safeContentType = "application/octet-stream";
|
||||
var req = new PutObjectRequest(_bucket, objectKey, stream);
|
||||
req.Metadata = new ObjectMetadata { ContentType = safeContentType };
|
||||
await Task.Run(() => _client.PutObject(req)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>获取对象(支持 Range;end 为 -1 表示到文件末尾)</summary>
|
||||
public OssObject GetObject(string objectKey, long? start = null, long? end = null)
|
||||
{
|
||||
var req = new GetObjectRequest(_bucket, objectKey);
|
||||
if (start.HasValue || end.HasValue)
|
||||
{
|
||||
req.SetRange(start ?? 0, end ?? -1);
|
||||
}
|
||||
return _client.GetObject(req);
|
||||
}
|
||||
|
||||
/// <summary>删除对象(失败不抛出,由调用方记录日志)</summary>
|
||||
public bool TryDeleteObject(string objectKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
_client.DeleteObject(_bucket, objectKey);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OSS 对象删除失败:{ObjectKey}", objectKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.1" />
|
||||
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@WenChuanyi.Api_HostAddress = http://localhost:5039
|
||||
|
||||
GET {{WenChuanyi.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"App": {
|
||||
"BaseUrl": "https://wenchuanyi.bbitcn.net"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"MySql": "Data Source=116.198.221.125;Port=3306;User ID=wenchuanyi;Password=YOUR_DB_PASSWORD;Initial Catalog=wenchuanyi;Charset=utf8mb4;SslMode=None;Allow User Variables=true;Treat Tiny As Boolean=true"
|
||||
},
|
||||
"Oss": {
|
||||
"Endpoint": "oss-cn-chengdu.aliyuncs.com",
|
||||
"STSEndpoint": "sts.cn-chengdu.aliyuncs.com",
|
||||
"Bucket": "bbit-f8-web",
|
||||
"AccessKeyId": "YOUR_ACCESS_KEY_ID",
|
||||
"AccessKeySecret": "YOUR_ACCESS_KEY_SECRET",
|
||||
"BaseKey": "文传易2026"
|
||||
},
|
||||
"OpenApi": {
|
||||
"UploadRoot": "D:\\WenChuanyi\\UploadRoot"
|
||||
},
|
||||
"Cleaner": {
|
||||
"IntervalMinutes": 30
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://0.0.0.0:5280"
|
||||
}
|
||||
},
|
||||
"Limits": {
|
||||
"MaxRequestBodySize": 220200960
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user