feat: 文传易匿名临时文件传输站初始版本
- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式 - 前端 Vue3 + Vite + TDesign,上传/取件/管理三页 - 取件码/二维码/海报合成(含保存二维码为图片) - deploy/ 部署文档与一键 FTP 发布脚本 - appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user