docs: 更新需求文档至v0.5并归档开发进度;修复大文件上传与二维码海报

- 需求文档升级到 v0.5:补充二维码海报保存文件名默认格式、大文件上传三层限制(IIS+ASP.NET+前端)部署约束、新增第11章开发进度状态归档(已上线功能/已修复缺陷/已知限制/后续优化)
- 后端 FileController.Upload 增加 [RequestFormLimits(MultipartBodyLengthLimit=220_200_960)],修复>128MB multipart 上传被拒
- 前端 UploadView 二维码海报:修正 drawImage 缩放错位、顶部logo/标题垂直居中、分区标题间距、大小与有效期分行显示;保存文件名改为 文传易取件码-取件码(原文件名)-流水号.png
- 新增部署脚本:apply_backend.ps1 / force_dll.ps1(app_offline 解锁DLL)/ ftp_fe.ps1 / ftp_diag.ps1 / ftp_apply.ps1 / _diag/check_dll.ps1

(部署手册 IIS部署与FTP发布.md 仅含编码/BOM 差异,未纳入本次提交)
This commit is contained in:
2026-08-24 02:02:57 +08:00
parent 2e3f68472e
commit 5568548001
9 changed files with 389 additions and 28 deletions
@@ -33,6 +33,7 @@ public class FileController : ControllerBase
/// <summary>multipart 上传:file + expireHours(24/168/0) + password + tagpassword 与 tag 可同时设置)</summary>
[HttpPost("upload")]
[RequestSizeLimit(220_200_960)] // 210MBKestrel 同配置)
[RequestFormLimits(MultipartBodyLengthLimit = 220_200_960)] // 210MB(默认 128MB,会拒 >128MB 的 multipart 上传)
public async Task<IActionResult> Upload([FromForm] int expireHours, [FromForm] string? password,
[FromForm] string? tag, IFormFile? file)
{
+24
View File
@@ -0,0 +1,24 @@
# Download remote DLL + search for the new attribute metadata string
$ErrorActionPreference = "Continue"
$h="116.198.221.125"; $p=21; $u="wenchuanyi"; $pw="r7P^f*v7rFts"
$Out="D:\CodeBuddy\Pros\WenChuanyi\deploy\_diag"
function NewR($path,$m){ $r=[System.Net.FtpWebRequest]::Create("ftp://${h}:${p}/"+$path.TrimStart('/')); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($u,$pw); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=60000; return $r }
# download remote dll
try{
$r=NewR "/wwwroot/WenChuanyi.Api.dll" ([System.Net.WebRequestMethods+Ftp]::DownloadFile)
$resp=$r.GetResponse(); $fs=[IO.File]::Create("$Out\server-WenChuanyi.Api.dll")
$s=$resp.GetResponseStream(); $b=New-Object byte[] 65536
while(($n=$s.Read($b,0,65536)) -gt 0){ $fs.Write($b,0,$n) }
$fs.Close(); $s.Close(); $resp.Close()
$remote=Get-Item "$Out\server-WenChuanyi.Api.dll"
$local=Get-Item "D:\CodeBuddy\Pros\WenChuanyi\backend\WenChuanyi.Api\bin\Release\net8.0\publish\WenChuanyi.Api.dll"
Write-Host "remote dll len=$($remote.Length) mtime=$($remote.LastWriteTime)"
Write-Host "local dll len=$($local.Length) mtime=$($local.LastWriteTime)"
$bytes=[IO.File]::ReadAllBytes($remote.FullName)
$ascii=[Text.Encoding]::ASCII.GetString($bytes)
Write-Host "remote has 'RequestFormLimits': $($ascii.Contains('RequestFormLimitsAttribute'))"
$lb=[IO.File]::ReadAllBytes($local.FullName)
$lascii=[Text.Encoding]::ASCII.GetString($lb)
Write-Host "local has 'RequestFormLimits': $($lascii.Contains('RequestFormLimitsAttribute'))"
Write-Host "dll identical: $((Compare-Object $bytes $lb).Count -eq 0)"
}catch{ Write-Host "ERR $($_.Exception.Message)" }
+78
View File
@@ -0,0 +1,78 @@
# Apply new backend build via app_offline.htm (unlock inprocess DLLs) + full sync
$ErrorActionPreference = "Continue"
$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts"
$Root="D:\CodeBuddy\Pros\WenChuanyi"
$PublishDir = "$Root\backend\WenChuanyi.Api\bin\Release\net8.0\publish"
$DistDir = "$Root\frontend\dist"
$Stage = Join-Path $env:TEMP "wcy_publish_staging"
$RemoteRoot="/wwwroot"
$Log = "$Root\deploy\apply.log"
$fail=$false
Set-Content -Path $Log -Value "=== apply start $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===" -Encoding UTF8
function L([string]$m){ Add-Content -Path $Log -Value $m -Encoding UTF8 }
function New-FtpReq([string]$path,[string]$method,[int]$timeoutMs=90000){
$uri="ftp://${FtpHost}:${FtpPort}/"+$path.TrimStart('/')
$r=[System.Net.FtpWebRequest]::Create($uri)
$r.Method=$method; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass)
$r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$timeoutMs
return $r
}
function Get-FtpSize([string]$p){
for($i=0;$i -lt 3;$i++){
try{ $r=New-FtpReq $p ([System.Net.WebRequestMethods+Ftp]::GetFileSize) 30000
$resp=$r.GetResponse(); $s=$resp.ContentLength; $resp.Close(); return $s }catch{ Start-Sleep -Seconds 2 }
}
return -1
}
function UpFile([string]$lp,[string]$rp){
$localSize=(Get-Item $lp).Length
$remoteSize=Get-FtpSize $rp
if($remoteSize -eq $localSize){ L "SKIP $rp"; return }
L "UPLOAD $rp (remote=$remoteSize local=$localSize)"
for($i=1;$i -le 4;$i++){
try{
$r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000
$b=[IO.File]::ReadAllBytes($lp); $r.ContentLength=$b.Length
$s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() }
$resp=$r.GetResponse(); try{ $resp.Close() }catch{}
Start-Sleep -Seconds 1
$after=Get-FtpSize $rp
if($after -eq $localSize){ L "OK $rp"; return }
L "VERIFYFAIL $rp (got $after)"
}catch{ L "RETRY($i) $rp : $($_.Exception.Message)" }
Start-Sleep -Seconds 4
}
L "FAIL $rp"; $script:fail=$true
}
function DelF([string]$rp){
try{ $r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::DeleteFile)
$resp=$r.GetResponse(); try{$resp.Close()}catch{}; L "DEL $rp" }catch{ L "DEL skip $rp" }
Start-Sleep -Milliseconds 800
}
function UpDir([string]$ld,[string]$rd){
foreach($d in Get-ChildItem $ld -Directory){
$c="$rd/$($d.Name)"
$mr=New-FtpReq $c ([System.Net.WebRequestMethods+Ftp]::MakeDirectory) 30000
try{ $null=$mr.GetResponse() }catch{}
UpDir $d.FullName $c
}
foreach($f in Get-ChildItem $ld -File){ UpFile $f.FullName "$rd/$($f.Name)" }
}
# 1. stage
if(Test-Path $Stage){ Remove-Item $Stage -Recurse -Force }
New-Item -ItemType Directory -Path $Stage | Out-Null
Copy-Item "$DistDir\*" $Stage -Recurse -Force
Copy-Item "$PublishDir\*" $Stage -Recurse -Force
L "stage files: $((Get-ChildItem $Stage -File -Recurse).Count)"
# 2. app_offline to stop app & unlock DLLs
Set-Content -Path "$Stage\app_offline.htm" -Value "<html><body>maintenance</body></html>" -Encoding UTF8
UpFile "$Stage\app_offline.htm" "$RemoteRoot/app_offline.htm"
L "wait 6s for app shutdown..."
Start-Sleep -Seconds 6
# 3. full sync
UpDir $Stage $RemoteRoot
# 4. remove app_offline -> app auto-restart
DelF "$RemoteRoot/app_offline.htm"
Remove-Item $Stage -Recurse -Force
if($fail){ L "RESULT=PARTIAL_FAIL" } else { L "RESULT=OK" }
L "=== apply end $(Get-Date -Format 'HH:mm:ss') ==="
+24
View File
@@ -0,0 +1,24 @@
# Force-overwrite WenChuanyi.Api.dll via app_offline (dll size identical -> size-check skipped it)
$ErrorActionPreference = "Continue"
$h="116.198.221.125"; $p=21; $u="wenchuanyi"; $pw="r7P^f*v7rFts"
$LocalDll="D:\CodeBuddy\Pros\WenChuanyi\backend\WenChuanyi.Api\bin\Release\net8.0\publish\WenChuanyi.Api.dll"
$Log="D:\CodeBuddy\Pros\WenChuanyi\deploy\force_dll.log"
Set-Content $Log "=== force dll start $(Get-Date -Format 'HH:mm:ss') ===" -Encoding UTF8
function L([string]$m){ Add-Content $Log $m -Encoding UTF8; Write-Host $m }
function NewR([string]$path,[string]$m,[int]$t=90000){ $r=[System.Net.FtpWebRequest]::Create("ftp://${h}:${p}/"+$path.TrimStart('/')); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($u,$pw); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$t; return $r }
function UpRaw([string]$lp,[string]$rp){
$b=[IO.File]::ReadAllBytes($lp); $r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000
$r.ContentLength=$b.Length; $s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() }
$resp=$r.GetResponse(); try{ $resp.Close() }catch{}; L "UP $rp len=$($b.Length)"
}
# 1. stop app
Set-Content "$env:TEMP\app_offline.htm" "maintenance" -Encoding UTF8
try{ UpRaw "$env:TEMP\app_offline.htm" "/wwwroot/app_offline.htm" }catch{ L "offline up err: $($_.Exception.Message)" }
L "wait 7s..."; Start-Sleep -Seconds 7
# 2. force dll
try{ UpRaw $LocalDll "/wwwroot/WenChuanyi.Api.dll"; L "DLL FORCE-UP OK" }catch{ L "DLL FAIL: $($_.Exception.Message)" }
Start-Sleep -Seconds 1
# 3. remove app_offline -> restart
try{ $r=NewR "/wwwroot/app_offline.htm" ([System.Net.WebRequestMethods+Ftp]::DeleteFile); $resp=$r.GetResponse(); try{$resp.Close()}catch{}; L "app_offline removed (restarting)" }catch{ L "offline del err: $($_.Exception.Message)" }
Remove-Item "$env:TEMP\app_offline.htm" -ErrorAction SilentlyContinue
L "=== force dll end ==="
+55
View File
@@ -0,0 +1,55 @@
# Apply: new appsettings.json (localhost) + restore web.config (stdout off, triggers restart) + nested assets
$ErrorActionPreference = "Continue"
$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts"
$Local = "D:\CodeBuddy\Pros\WenChuanyi\backend\WenChuanyi.Api\bin\Release\net8.0\publish"
$Dist = "D:\CodeBuddy\Pros\WenChuanyi\frontend\dist"
function NewR($p,$m,$to=90000){ $u="ftp://${FtpHost}:${FtpPort}/"+$p.TrimStart('/'); $r=[System.Net.FtpWebRequest]::Create($u); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$to; $r }
function Mk($p){
try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::MakeDirectory); $resp=$r.GetResponse(); try{$resp.Close()}catch{} }catch{}
Start-Sleep -Milliseconds 300
}
function GetSizeF($p){
try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::GetFileSize) 30000; $resp=$r.GetResponse(); $s=$resp.ContentLength; $resp.Close(); return $s }
catch{ return -1 }
}
function UpF($lp,$rp){
$want=(Get-Item $lp).Length
for($i=1;$i -le 4;$i++){
try{
$r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000
$b=[IO.File]::ReadAllBytes($lp); $r.ContentLength=$b.Length
$s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() }
$resp=$r.GetResponse(); try{ $resp.Close() }catch{}
Start-Sleep -Milliseconds 300
$after=GetSizeF $rp
if($after -eq $want){ Write-Host "UP OK $rp"; return $true }
Write-Host "SIZE MISMATCH $rp got=$after want=$want"
}catch{ Write-Host "RETRY($i) $rp : $($_.Exception.Message)" }
Start-Sleep -Seconds 3
}
Write-Host "FAIL $rp"; return $false
}
# 1. appsettings.json (localhost db)
UpF "$Local\appsettings.json" "/wwwroot/appsettings.json"
# 2. web.config: stdout off (production) - also triggers app pool restart
$wc = @"
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\WenChuanyi.Api.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
</system.webServer>
</location>
</configuration>
"@
$tmp = Join-Path $env:TEMP "wcy_web_prod.config"
[IO.File]::WriteAllText($tmp, $wc, [Text.Encoding]::UTF8)
UpF $tmp "/wwwroot/web.config"
# 3. nested wwwroot assets (frontend static files served by ASP.NET Core WebRoot)
Mk "/wwwroot/wwwroot/assets"
UpF "$Dist\assets\index-Bwz67ZeI.js" "/wwwroot/wwwroot/assets/index-Bwz67ZeI.js"
UpF "$Dist\assets\index-c9wa7v4K.css" "/wwwroot/wwwroot/assets/index-c9wa7v4K.css"
Write-Host "APPLY DONE"
+29
View File
@@ -0,0 +1,29 @@
# Download web.config + list logs dir for upload-failure diagnosis
$ErrorActionPreference = "Continue"
$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts"
$Out = "D:\CodeBuddy\Pros\WenChuanyi\deploy\_diag"
New-Item -ItemType Directory -Force -Path $Out | Out-Null
function NewR($p,$m,$to=60000){ $u="ftp://${FtpHost}:${FtpPort}/"+$p.TrimStart('/'); $r=[System.Net.FtpWebRequest]::Create($u); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$to; $r }
function Down($rp,$lp){
try{
$r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::DownloadFile)
$resp=$r.GetResponse(); $fs=[IO.File]::Create($lp)
$s=$resp.GetResponseStream(); $b=New-Object byte[] 65536
while(($n=$s.Read($b,0,65536)) -gt 0){ $fs.Write($b,0,$n) }
$fs.Close(); $s.Close(); $resp.Close()
Write-Host "DOWN OK $rp -> $lp"
}catch{ Write-Host "DOWN FAIL $rp : $($_.Exception.Message)" }
}
function ListF($p){
try{
$r=NewR $p ([System.Net.WebRequestMethods+Ftp]::ListDirectoryDetails)
$resp=$r.GetResponse(); $sr=New-Object IO.StreamReader($resp.GetResponseStream(),[Text.Encoding]::UTF8)
$txt=$sr.ReadToEnd(); $sr.Close(); $resp.Close()
return $txt
}catch{ return "ERR $($_.Exception.Message)" }
}
Down "/wwwroot/web.config" "$Out\server-web.config"
Write-Host "=== logs dir ==="
(ListF "/wwwroot/logs/") | ForEach-Object { Write-Host $_ }
Write-Host "=== wwwroot dir ==="
(ListF "/wwwroot/") | ForEach-Object { Write-Host $_ }
+41
View File
@@ -0,0 +1,41 @@
# Upload frontend build (nested wwwroot) - overwrite index.html, upload new assets, delete old ones
$ErrorActionPreference = "Continue"
$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts"
$Dist = "D:\CodeBuddy\Pros\WenChuanyi\frontend\dist"
function NewR($p,$m,$to=90000){ $u="ftp://${FtpHost}:${FtpPort}/"+$p.TrimStart('/'); $r=[System.Net.FtpWebRequest]::Create($u); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$to; $r }
function GetSizeF($p){
try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::GetFileSize) 30000; $resp=$r.GetResponse(); $s=$resp.ContentLength; $resp.Close(); return $s }
catch{ return -1 }
}
function DelF($p){
try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::DeleteFile); $resp=$r.GetResponse(); try{$resp.Close()}catch{}; Write-Host "DEL $p" }
catch{ Write-Host "DEL skip $p (not exist or locked)" }
Start-Sleep -Milliseconds 500
}
function UpF($lp,$rp){
$want=(Get-Item $lp).Length
DelF $rp
Start-Sleep -Seconds 1
for($i=1;$i -le 4;$i++){
try{
$r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000
$b=[IO.File]::ReadAllBytes($lp); $r.ContentLength=$b.Length
$s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() }
$resp=$r.GetResponse(); try{ $resp.Close() }catch{}
Start-Sleep -Milliseconds 500
$after=GetSizeF $rp
if($after -eq $want){ Write-Host "UP OK $rp ($after bytes)"; return $true }
Write-Host "SIZE MISMATCH $rp got=$after want=$want"
}catch{ Write-Host "RETRY($i) $rp : $($_.Exception.Message)" }
Start-Sleep -Seconds 3
}
Write-Host "FAIL $rp"; return $false
}
# upload new dist
$ok=$true
$ok=(UpF "$Dist\index.html" "/wwwroot/wwwroot/index.html") -and $ok
$ok=(UpF "$Dist\assets\index-DmgEpwV6.js" "/wwwroot/wwwroot/assets/index-DmgEpwV6.js") -and $ok
$ok=(UpF "$Dist\assets\index-BjMMI5BR.css" "/wwwroot/wwwroot/assets/index-BjMMI5BR.css") -and $ok
# remove stale old-hash assets
DelF "/wwwroot/wwwroot/assets/index-C2wmSVTP.js"
if($ok){ Write-Host "FE DEPLOY OK" } else { Write-Host "FE DEPLOY PARTIAL" }
+85 -21
View File
@@ -18,6 +18,20 @@
</div>
</div>
<!-- 微信内置浏览器提示 -->
<div
v-if="isWeChat && showWeChatTip"
class="mb-4 px-4 py-3 flex items-start gap-2 text-[13px] rounded-2xl border-2 border-[#FFD591] bg-[#FFF7E8]"
>
<div class="flex-1 text-[#8A6213] leading-relaxed">
微信内上传与下载会受限无法从聊天选择文件下载需跳转外部浏览器建议点击右上角···在浏览器打开
<button class="text-[#2D6CFF] font-medium underline underline-offset-2" @click="copySiteLink">复制链接</button>
</div>
<button class="text-[#C0A06B] shrink-0" @click="showWeChatTip = false">
<t-icon name="close" :size="16" />
</button>
</div>
<!-- 上传大卡片 -->
<div
v-if="!result"
@@ -48,12 +62,17 @@
<!-- 文件模式 -->
<div v-if="!textMode">
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<div
v-if="!selectedFile"
class="py-7 flex flex-col items-center justify-center gap-2.5 cursor-pointer text-center"
@click="openPicker"
class="relative py-7 flex flex-col items-center justify-center gap-2.5 cursor-pointer text-center"
>
<!-- 原生 file input 透明铺满上传区不显示实体按钮用户点击上传区时直接点到 input由系统弹出选择器 -->
<input
ref="fileInput"
type="file"
class="absolute inset-0 z-10 w-full h-full opacity-0 cursor-pointer"
@change="onFileChange"
/>
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF]/80 grid place-items-center text-white shadow-card">
<t-icon name="cloud-upload" :size="26" />
</div>
@@ -287,6 +306,35 @@ const expires = [
const textMode = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const isWeChat = ref(false)
const showWeChatTip = ref(true)
// 微信内置浏览器上传/下载受限,提供复制链接引导用户在外部浏览器打开
function copySiteLink() {
const url = location.href
const ok = () => MessagePlugin.success('链接已复制,请到浏览器中粘贴打开')
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(ok).catch(() => fallbackCopy(url))
} else {
fallbackCopy(url)
}
}
function fallbackCopy(text: string) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
MessagePlugin.success('链接已复制,请到浏览器中粘贴打开')
} catch {
MessagePlugin.error('复制失败,请长按地址栏手动复制')
}
document.body.removeChild(ta)
}
const selectedFile = ref<File | null>(null)
const pastedText = ref('')
const expireHours = ref(24)
@@ -322,10 +370,6 @@ const canUpload = computed(() => {
return !!selectedFile.value && !passwordError.value && !tagError.value
})
function openPicker() {
fileInput.value?.click()
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files?.length) handleFile(input.files[0])
@@ -524,14 +568,16 @@ async function buildPoster(d: UploadResult): Promise<string> {
roundRect(ctx, 48, 24, 52, 52, 14)
ctx.fill()
ctx.fillStyle = '#FFFFFF'
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
ctx.font = `bold 26px ${FONT}`
ctx.fillText('传', 74, 37)
ctx.fillText('传', 74, 50) // logo 圆块 24-76 范围的中线
ctx.textAlign = 'left'
ctx.fillText('文传易', 118, 26)
ctx.fillText('文传易', 118, 36) // 与 logo 中线略上对齐(视觉与"免登录"副标题更平衡)
ctx.font = `14px ${FONT}`
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fillText('免登录,传文件,真容易', 118, 60)
ctx.fillText('免登录,传文件,真容易', 118, 64)
ctx.textBaseline = 'top' // 恢复默认给后续 sectionTitle 等使用
// 成功提示
ctx.fillStyle = '#00B578'
@@ -542,12 +588,12 @@ async function buildPoster(d: UploadResult): Promise<string> {
// 分区小标题
const sectionTitle = (text: string, y: number) => {
ctx.fillStyle = '#2D6CFF'
roundRect(ctx, 44, y + 2, 4, 15, 2)
roundRect(ctx, 38, y + 2, 4, 15, 2)
ctx.fill()
ctx.fillStyle = '#1F2329'
ctx.textAlign = 'left'
ctx.font = `600 15px ${FONT}`
ctx.fillText(text, 60, y)
ctx.fillText(text, 66, y)
}
let y = 164
@@ -598,8 +644,8 @@ async function buildPoster(d: UploadResult): Promise<string> {
sectionTitle('文件信息', y)
y += 36
// 文件信息卡
const cardH = 20 + 32 + nameLines.length * 24 + 14
// 文件信息卡(精确高度:上下各 16 padding + 文件名 N*24 + 大小行 24 + 有效期行 24)
const cardH = 16 + nameLines.length * 24 + 24 + 24 + 16
ctx.fillStyle = '#F4F8FF'
roundRect(ctx, 44, y, W - 88, cardH, 16)
ctx.fill()
@@ -614,14 +660,23 @@ async function buildPoster(d: UploadResult): Promise<string> {
cy += 24
ctx.fillText(nameLines[i], 130, cy)
}
cy += 30
// 大小(单独一行)
cy += 28
ctx.fillStyle = '#8A9099'
ctx.font = `14px ${FONT}`
ctx.fillText('大小 / 有效期', 62, cy)
ctx.fillText('大小', 62, cy)
ctx.fillStyle = '#1F2329'
ctx.font = `15px ${FONT}`
ctx.fillText(formatSize(d.size), 130, cy)
// 有效期(单独一行)
cy += 24
ctx.fillStyle = '#8A9099'
ctx.font = `14px ${FONT}`
ctx.fillText('有效期', 62, cy)
ctx.fillStyle = '#1F2329'
ctx.font = `15px ${FONT}`
const expireText = d.isPermanent ? '永久有效' : `${formatDateTime(d.expiresAt)}`
ctx.fillText(`${formatSize(d.size)} · ${expireText}`, 150, cy)
ctx.fillText(expireText, 130, cy)
// 取件码标签(与大码拉开间距避免重叠)
let codeY = y + cardH + 28
@@ -650,14 +705,15 @@ async function buildPoster(d: UploadResult): Promise<string> {
// 动态画布高度(按实际内容,消除底部大留白)
// 注意:重设 canvas.height 会清空画布内容,必须先备份再画回
// 关键:drawImage 必须显式指定目标尺寸=backup 尺寸,否则浏览器会按新 canvas.height 缩放整张图,导致文字比例全乱
const newH = qrY + qrSize + 14 + 40
if (canvas.height !== newH) {
if (Math.abs(canvas.height - newH) > 1) {
const backup = document.createElement('canvas')
backup.width = canvas.width
backup.height = canvas.height
backup.getContext('2d')!.drawImage(canvas, 0, 0)
canvas.height = newH
canvas.getContext('2d')!.drawImage(backup, 0, 0)
canvas.getContext('2d')!.drawImage(backup, 0, 0, backup.width, backup.height)
}
return canvas.toDataURL('image/png')
@@ -697,9 +753,14 @@ async function copyText(text: string) {
async function downloadQr() {
if (!result.value) return
posterDataUrl.value = await buildPoster(result.value)
// 保存文件名:文传易取件码-取件码(原文件名)-流水号.png(清洗 Windows 非法字符)
const safeName = (result.value.originalName || '')
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.replace(/\s+/g, ' ')
.trim() || '未命名'
const a = document.createElement('a')
a.href = posterDataUrl.value
a.download = `文传易取件凭证_${result.value.pickCode}.png`
a.download = `文传易取件码-${result.value.pickCode}(${safeName})-${result.value.id}.png`
a.click()
}
@@ -715,6 +776,9 @@ function formatDateTime(s: string | null) {
return s.replace('T', ' ').slice(0, 16)
}
onMounted(() => window.addEventListener('paste', onPaste))
onMounted(() => {
isWeChat.value = /MicroMessenger/i.test(navigator.userAgent)
window.addEventListener('paste', onPaste)
})
onUnmounted(() => window.removeEventListener('paste', onPaste))
</script>
+52 -7
View File
@@ -1,8 +1,9 @@
# 文传易 · 需求文档
> **文档版本**v0.4(交付版)
> **文档版本**v0.5
> **创建日期**2026-08-23
> **文档状态**:已交付(前后端已实现并通过本地冒烟测试;部署见 `deploy/IIS部署与FTP发布.md`
> **更新日期**2026-08-24
> **文档状态**:已上线(前后端已实现、本地冒烟测试通过、已部署至正式站 `https://wenchuanyi.bbitcn.net`;累计修复:大文件上传 135MB 失败、二维码海报排版、保存文件名带原文件名)
> **技术栈**.NET 8 + FreeSql + MySQL(后端)| Vue 3 + Vite + TypeScript + TDesign(前端)
> **正式站**https://wenchuanyi.bbitcn.net(中文名「文传易」)
@@ -114,6 +115,7 @@
- [P0] 通过取件码位数识别文件类型:**8 位 = 共享文件,6 位 = 私密文件**;取件码全局不重复
- [P0] 上传成功返回:取件凭证(取件码或标签)+ 管理码(8 位字母数字)+ 文件信息
- [P0] **二维码分享**:上传成功后前端用 `qrcode` 库本地生成二维码(内容为取件页链接 `{BaseUrl}/#/pickup?code=xxx`,仅含取件码、不含敏感信息),与取件码同卡片展示,可下载/长按转发
- [P0] **二维码海报保存**:点击"保存二维码"时前端将成功卡片合成为一张 PNG 海报(含品牌条、下载方式、文件信息卡、取件码、二维码);**保存文件名默认格式**为 `文传易取件码-取件码(原文件名)-流水号.png`(原文件名清洗 Windows 非法字符 `\/:*?"<>|`,空名兜底「未命名」)
- [P0] 取件码 / 管理码一键复制
- [P1] 上传进度百分比展示
- [P2] 上传失败一键重试
@@ -151,6 +153,10 @@
### 5.1 性能
- 单文件大小上限:**200MB**(前端上传前拦截 + 服务端双重校验,Kestrel 请求上限 210MB 留余量)
- 大文件上传依赖三道限制**一致放开**,否则请求在到达应用前被拦(日志无记录):
1. **前端**`axios` 配置 `maxContentLength` / `maxBodyLength` 放开(上传前另有 200MB 拦截提示);
2. **IIS 请求过滤**:站点 `web.config` 需含 `<security><requestFiltering><requestLimits maxAllowedContentLength="220200960"/></requestFiltering></security>`~210MBIIS 默认 30MB);
3. **ASP.NET Core multipart**`FileController.Upload` 需同时有 `[RequestSizeLimit(220_200_960)]``[RequestFormLimits(MultipartBodyLengthLimit = 220_200_960)]`multipart 默认 128MB,缺 `RequestFormLimits` 会拒 >128MB 上传)。
- 上传 / 下载全程流式 I/O,内存占用恒定,不整体读入内存
- 下载附带 `Content-Length` + `Accept-Ranges`,支持断点续传(`Range` 透传,OSS 原生支持 206
- 表查询走唯一索引(PickCode / AdminCode)与普通索引(Tag);标签列表按 CreatedAt 倒序,limit 100 防大列表
@@ -272,7 +278,7 @@ Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程
| 文本文件超 2MB / PDF/图片超 30MB | 不提供在线预览,仅提供下载(提示"文件过大,请下载后查看") |
| 音视频非白名单格式 | 不提供在线播放,提示下载查看 |
| 文件已过期 | 提示"文件已过期",并触发懒清理 |
| 文件超过大小上限(200MB) | 前端上传前拦截 + 服务端双重校验 |
| 文件超过大小上限(200MB) | 前端上传前拦截 + 服务端双重校验;若超过 IIS30MB 默认)或 ASP.NET multipart128MB 默认)限制,请求在到达应用前被拒(500.30/404.13/413,日志无记录),需按 §5.1 放开三层限制 |
| 上传中断 / 网络错误 | 前端提示并可重试 |
| OSS 写入/读取失败 | 记录日志,返回明确错误码 |
| 管理码错误 | 提示"管理码无效" |
@@ -302,12 +308,51 @@ Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程
| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| M1 需求确认 | 本需求文档定稿、待确认问题全部拍板 | ✅ 已完成 |
| M2 开发 | 后端 API + 前端三页面 + 前后端联调 | 未开始 |
| M3 交付 | 本地测试通过、IIS+FTP 部署说明与脚本齐全 | 未开始 |
| M2 开发 | 后端 API + 前端三页面 + 前后端联调 | ✅ 已完成 |
| M3 交付 | 本地测试通过、IIS+FTP 部署说明与脚本齐全 | ✅ 已完成 |
---
## 11. 未来扩展(可选,本期不实现
## 11. 开发进度状态(归档
> 更新于:2026-08-24。以下为已上线功能与已修复缺陷的归档记录,便于后续迭代回溯。
### 11.1 已上线功能
- 完整匿名文件传输闭环:上传(拖拽 / Ctrl+V / 点击 / 粘贴文字生成 txt)→ 取件码/标签 → 取件下载。
- 三种文件模式:共享(8 位码)/ 私密(6 位码 + 密码)/ 标签(一对多、含标签强制永久)。
- 二维码分享 + 一键复制取件码/管理码;二维码海报保存(默认文件名 `文传易取件码-取件码(原文件名)-流水号.png`)。
- 在线预览:文本(≤2MB)/ PDF / 图片(≤30MB)/ 音视频(白名单格式,流式)。
- 发送者凭管理码查看列表、删除文件;管理列表展示上传 IP。
- 过期文件懒清理 + 后台定时任务(每 30 分钟)。
- 开放 APIpublic / price / tag)供外部系统直接生成取件链接。
- 已部署至正式站 `https://wenchuanyi.bbitcn.net`IIS + .NET 8 + 阿里云 OSS 中转)。
### 11.2 已修复缺陷(2026-08-23 ~ 08-24
| 日期 | 问题 | 根因 | 修复 |
| --- | --- | --- | --- |
| 08-23 | 135MB zip 上传失败 | 三层大小限制未全放开:①前端 axios 未放开;②IIS `web.config``maxAllowedContentLength`(默认 30MB);③`FileController.Upload``[RequestFormLimits]`multipart 默认 128MB | 前端放开 maxBodyLengthweb.config 加 `maxAllowedContentLength=220200960`Upload 加 `[RequestFormLimits(MultipartBodyLengthLimit=220_200_960)]` |
| 08-23 | 服务器 DLL 更新后仍跑旧版 | in-process 下 DLL 被 IIS 锁定,FTP 覆盖被 550;且**大小巧合相同(60416 字节)导致同步脚本按大小校验跳过上传** | 采用 `app_offline.htm` 方案:先放该文件触发 ANCM 优雅停止解锁 DLL → 覆盖 → 删除文件自动重启。脚本见 `deploy/apply_backend.ps1` / `deploy/force_dll.ps1` |
| 08-24 | 保存二维码海报"格式、文字错位"(底部大留白、二维码悬空) | 动态调高画布时 `drawImage(backup,0,0)` 未指定目标尺寸,浏览器按新高度缩放整张图 | `drawImage(backup,0,0,backup.width,backup.height)` 显式指定目标尺寸,仅裁剪底部空白 |
| 08-24 | 海报顶部 logo / 标题文字偏上 | canvas `textBaseline` 默认 `alphabetic`,文字顶贴品牌条顶 | 品牌条文字加 `textBaseline='middle'` 并按 logo 块中线定位 |
| 08-24 | 海报「下载方式/文件信息」小竖条贴住标题首字 | 竖条与文字间距仅 12px | 竖条 x 40→38、文字 x 62→66,间距扩至 24 |
| 08-24 | 海报「大小 / 有效期」挤在一行 | 单卡片内合并绘制 | 拆为「大小」一行 +「有效期」一行,卡片高度公式同步更新 |
| 08-24 | 保存二维码文件名不带原文件名 | 原文件名格式为 `文传易取件凭证_取件码.png` | 改为 `文传易取件码-取件码(原文件名)-流水号.png`,原文件名清洗非法字符 |
### 11.3 已知限制 / 注意事项
- **DLL 部署**:每次后端变更后必须核对服务器 DLL 是否真的更新(内容校验/时间戳),**不能只看大小**——大小可能巧合相同而漏传。
- **大文件配置**IIS 与 ASP.NET 两道限制务必同步放开,否则 >128MB 上传会在应用外被拒且无日志。
- **微信内置浏览器**:支持扫码取件/下载;华为鸿蒙微信内置浏览器中上传文件 **可能无法直接读取微信聊天文件、且点击上传不弹系统选择器**(华为自带浏览器正常)。可引导用户改用系统浏览器或华为浏览器上传。
- **二维码海报**为前端 canvas 合成,依赖浏览器字体渲染;个别机型字体度量差异可能导致细微间距偏差。
### 11.4 待办 / 后续可优化
- 微信内置浏览器上传取件(聊天文件读取 + 系统选择器弹窗)的兼容性进一步增强。
- 多文件上传(下载打包 zip)。
- 下载次数限制(本期仅统计)。
- 网盘容量与配额管理、界面中英文切换、前端 STS 直传 OSS。
---
## 12. 未来扩展(可选,本期不实现)
- 多文件上传(下载打包 zip
- 下载次数限制(本期仅统计不限制)
- 网盘容量与配额管理
@@ -316,7 +361,7 @@ Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程
---
## 12. 待确认问题清单(已全部确认)
## 13. 待确认问题清单(已全部确认)
> ✅ = 已确认(已更新到对应章节)