feat: 文传易匿名临时文件传输站初始版本

- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式
- 前端 Vue3 + Vite + TDesign,上传/取件/管理三页
- 取件码/二维码/海报合成(含保存二维码为图片)
- deploy/ 部署文档与一键 FTP 发布脚本
- appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
2026-08-24 00:03:28 +08:00
commit 1bfc72f9cb
40 changed files with 6849 additions and 0 deletions
@@ -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>校验标签:仅英文+数字;纯数字须 &gt;8 位;含英文须 &gt;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>获取对象(支持 Rangeend 为 -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;
}
}
}