Compare commits
2
Commits
01c9cc2330
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30da88d04 | ||
|
|
5568548001 |
@@ -33,6 +33,7 @@ public class FileController : ControllerBase
|
||||
/// <summary>multipart 上传:file + expireHours(24/168/0) + password + tag(password 与 tag 可同时设置)</summary>
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(220_200_960)] // 210MB(Kestrel 同配置)
|
||||
[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)
|
||||
{
|
||||
|
||||
+108
-103
@@ -2,161 +2,166 @@
|
||||
|
||||
> 目标环境:Windows Server + IIS 10 + .NET 8 Hosting Bundle
|
||||
> 站点地址:`https://wenchuanyi.bbitcn.net`
|
||||
> 发布方式:FTP(`ftp://116.198.221.125`,默认端口 21,用户 `wenchuanyi`)
|
||||
> 发布方式:FTP(`ftp://116.198.221.125`,端口 21,用户 `wenchuanyi`,密码同数据库密码)
|
||||
> **本文档已按 2026-08-24 线上实战排错结果校正,照此执行可避免再踩坑。**
|
||||
|
||||
> **当前状态(2026-08-23)**:本地全流程已通过冒烟测试——
|
||||
> 上传(共享/私密/标签,含 200MB 拦截)→ 二维码与取件码 → 取件查询/下载(计数)→ 标签列表 → 管理列表(含上传 IP)/删除,均已验证;数据库表已建、OSS 真实凭据已写入 `appsettings.json`(该文件不入库)。服务器上首次发布后,建议用手机微信扫码访问正式站再做一轮真机验证(含微信聊天记录选文件)。
|
||||
---
|
||||
|
||||
## 0. 目录结构(最重要的认知,务必先看)
|
||||
|
||||
IIS 站点物理根 = **ContentRoot**(应用工作目录),也是后端产物所在目录;
|
||||
ASP.NET Core 的静态文件 WebRoot = **ContentRoot\wwwroot(嵌套)**。
|
||||
|
||||
实测服务器目录映射(FTP 的 `/wwwroot` ↔ 磁盘 `D:\wwwroot\wenchuanyi\wwwroot`):
|
||||
|
||||
```
|
||||
D:\wwwroot\wenchuanyi\wwwroot\ ← FTP /wwwroot (ContentRoot,后端产物 + web.config)
|
||||
├── web.config ← inprocess + dotnet(见第 3 节)
|
||||
├── WenChuanyi.Api.dll / .exe / deps.json / runtimeconfig.json
|
||||
├── appsettings.json ← 数据库 localhost / OSS / 上传限制
|
||||
├── *.dll(MySql.Data / BouncyCastle / ZstdSharp …) ← 缺一不可!
|
||||
└── wwwroot\ ← FTP /wwwroot/wwwroot (嵌套 WebRoot,前端产物!)
|
||||
├── index.html
|
||||
└── assets\index-*.js / *.css
|
||||
```
|
||||
|
||||
> **规则:后端产物传 `/wwwroot/`,前端产物传 `/wwwroot/wwwroot/`(嵌套)。**
|
||||
> 前端产物若只放站点根(`/wwwroot/`),页面能 200(IIS 默认文档/fallback)但 `/assets/*.js` 必然 404。
|
||||
|
||||
---
|
||||
|
||||
## 1. 部署架构
|
||||
|
||||
```
|
||||
浏览器
|
||||
│ https://wenchuanyi.bbitcn.net
|
||||
浏览器 → https://wenchuanyi.bbitcn.net
|
||||
▼
|
||||
IIS 站点(物理路径 = 站点根目录)
|
||||
├── index.html / assets/* ← 前端 dist 构建产物
|
||||
├── web.config ← 由 dotnet publish 自动生成(ANCM InProcess)
|
||||
├── WenChuanyi.Api.dll ← 后端发布输出
|
||||
└── appsettings.json ← 数据库连接串 / OSS 凭据 / 上传限制
|
||||
IIS + ANCM(AspNetCoreModuleV2,InProcess)
|
||||
├── /assets/*、/ → ASP.NET Core UseDefaultFiles+UseStaticFiles(只读嵌套 WebRoot)
|
||||
├── /#/pickup 等 SPA → MapFallbackToFile("index.html") 兜底(hash 路由)
|
||||
└── /api、/api/open → 后端控制器
|
||||
数据库:MySQL 同机 localhost:3306(库/用户 wenchuanyi)
|
||||
存储:阿里云 OSS(凭据在 appsettings.json,文件不入库)
|
||||
```
|
||||
|
||||
- 前端静态资源与后端发布输出**放在同一 IIS 站点物理路径**(默认文件夹或 `wwwroot`)。
|
||||
- 后端 `UseStaticFiles` 提供前端静态文件,`MapFallbackToFile("index.html")` 兜底 SPA 路由;
|
||||
`/api` 与 `/api/open` 由 ASP.NET Core Module(ANCM)直接处理。
|
||||
- 站点根目录布局见上;后端发布产物全部文件与前端 `dist/` 内容合并进同一目录。
|
||||
|
||||
---
|
||||
|
||||
## 2. 服务器一次性准备(首次部署前)
|
||||
|
||||
1. **安装 .NET 8 Hosting Bundle**
|
||||
- 下载:https://dotnet.microsoft.com/download/dotnet/8.0(选择 **Hosting Bundle**)
|
||||
- 安装后 IIS 中会出现 **ASP.NET Core Module v2**。
|
||||
2. **创建 IIS 站点**
|
||||
- IIS → 右键「网站」→ 添加网站;
|
||||
- 站点名称:`wenchuanyi`;
|
||||
- 物理路径:`D:\wenchuanyi`(或任意磁盘,站点根目录);
|
||||
- 端口 80(HTTP)→ 后续绑定 443 + 证书,域名 `wenchuanyi.bbitcn.net`;
|
||||
- 应用程序池:.NET CLR 版本选「**无托管代码**」(InProcess 托管由 ANCM 处理)。
|
||||
3. **HTTPS 证书**:为 `wenchuanyi.bbitcn.net` 绑定 SSL 证书(企业已有证书或申请免费证书)。
|
||||
4. **防火墙**:放行 80 / 443(及测试期 5280)。
|
||||
|
||||
> 排障:若站点 502.5 / 500.30,临时开启 web.config 中的 `stdoutLogEnabled="true"` 查看 `stdoutLog` 输出。
|
||||
1. **安装 .NET 8 Hosting Bundle**(dotnet.microsoft.com/download/dotnet/8.0)。
|
||||
2. **创建 IIS 站点**:物理路径指向 `D:\wwwroot\wenchuanyi\wwwroot`(后端产物目录);
|
||||
应用程序池 .NET CLR 版本选「**无托管代码**」(InProcess 由 ANCM 托管)。
|
||||
3. **HTTPS 证书**:为 `wenchuanyi.bbitcn.net` 绑定 SSL 证书。
|
||||
4. **防火墙**:放行 80 / 443(测试期 5280)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 构建发布包(开发机执行)
|
||||
## 3. web.config(不要乱改)
|
||||
|
||||
### 3.1 后端
|
||||
`dotnet publish` 自动生成的**默认配置**就是正确配置,保持原样:
|
||||
|
||||
```xml
|
||||
<aspNetCore processPath="dotnet" arguments=".\WenChuanyi.Api.dll"
|
||||
stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
|
||||
```
|
||||
|
||||
- ✅ **必须 `inprocess + dotnet`**(Framework-dependent 标准)。
|
||||
- ❌ 不要改成 `outofprocess + exe`——实测会导致 502/runtime 探测失败。
|
||||
- ✅ `stdoutLogEnabled` 平时为 false;排障时才临时开 true,**查完必须改回 false**(改 web.config 会触发应用重启)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建发布包(开发机)
|
||||
|
||||
```powershell
|
||||
# 后端
|
||||
cd backend/WenChuanyi.Api
|
||||
dotnet publish -c Release
|
||||
```
|
||||
dotnet publish -c Release # 输出 bin/Release/net8.0/publish/
|
||||
|
||||
- 输出目录:`bin/Release/net8.0/publish/`
|
||||
- Framework-dependent 模式,web.config 自动生成(`hostingModel="inprocess"`)。
|
||||
- 发布前确认 `appsettings.json` 已配置真实凭据(见第 4 节)。
|
||||
|
||||
### 3.2 前端
|
||||
|
||||
```powershell
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install # 首次
|
||||
npm run build
|
||||
npm run build # 输出 dist/(index.html + assets/)
|
||||
```
|
||||
|
||||
- 输出目录:`frontend/dist/`(index.html + assets/*)。
|
||||
- 构建目标 ES2018,兼容微信 X5 内核。
|
||||
`publish/` 目录**所有文件**都要上传(约 24 个文件 / 12.4MB),框架依赖模式**不含** coreclr.dll 等 runtime 文件。
|
||||
|
||||
---
|
||||
|
||||
## 4. 敏感配置(appsettings.json)
|
||||
## 5. 敏感配置(appsettings.json,不入库)
|
||||
|
||||
`backend/WenChuanyi.Api/appsettings.json` 中需填写真实值,**该文件已在 .gitignore 中,不会入库**:
|
||||
|
||||
| 配置节 | 键 | 说明 |
|
||||
| 配置节 | 键 | 值 |
|
||||
| --- | --- | --- |
|
||||
| `ConnectionStrings:MySql` | Password | 数据库密码(`r7P^f*v7rFts`) |
|
||||
| `Oss` | AccessKeyId | 阿里云 OSS AK |
|
||||
| `Oss` | AccessKeySecret | 阿里云 OSS SK |
|
||||
| `OpenApi` | UploadRoot | 公开 API 可读取的服务端目录白名单 |
|
||||
| `App` | BaseUrl | 站点地址,用于生成取件页链接(默认 `https://wenchuanyi.bbitcn.net`) |
|
||||
| `ConnectionStrings:MySql` | Data Source | **`localhost`**(MySQL 与站点同机,勿用公网 IP 回环) |
|
||||
| `ConnectionStrings:MySql` | Password | `r7P^f*v7rFts` |
|
||||
| `Oss` | AccessKeyId / Secret | 阿里云 OSS 凭据 |
|
||||
| `App` | BaseUrl | `https://wenchuanyi.bbitcn.net` |
|
||||
|
||||
- 连接串密码含 `^`、`*`,不含 `;` / `=`,无需特殊转义,JSON 原样写入即可。
|
||||
- 模板参考:`appsettings.example.json`(占位符 `YOUR_DB_PASSWORD` / `YOUR_ACCESS_KEY_ID` / `YOUR_ACCESS_KEY_SECRET`)。
|
||||
改连接串后**必须重启应用**(上传 web.config 或回收应用池)才生效——FreeSql 连接字符串启动时固定。
|
||||
|
||||
---
|
||||
|
||||
## 5. FTP 发布
|
||||
## 6. FTP 发布
|
||||
|
||||
### 5.1 手动发布
|
||||
|
||||
用任意 FTP 客户端(FileZilla / WinSCP 等,**被动模式**)登录:
|
||||
|
||||
- 主机:`116.198.221.125`
|
||||
- 端口:`21`
|
||||
- 用户:`wenchuanyi`
|
||||
- 密码:同数据库密码
|
||||
|
||||
上传清单(目标 = IIS 站点根目录,如 `D:\wenchuanyi`):
|
||||
### 6.1 上传清单(位置错了就会出上面的 404/500)
|
||||
|
||||
```
|
||||
前端 dist/ 全部文件 → 站点根目录
|
||||
后端 bin/Release/net8.0/publish/* → 站点根目录(覆盖)
|
||||
后端 publish/ 全部文件(web.config、appsettings.json、*.dll、*.exe、runtimes/) → FTP /wwwroot/
|
||||
前端 dist/index.html + assets/ 全部 → FTP /wwwroot/wwwroot/
|
||||
```
|
||||
|
||||
> 注意:web.config、appsettings.json 等必须位于站点根目录,勿放入子文件夹。
|
||||
### 6.2 脚本
|
||||
|
||||
### 5.2 一键脚本发布
|
||||
- `deploy/ftp_upload.ps1`:完整发布(后端 publish + 前端 build + FTP 递归上传)。
|
||||
- `deploy/ftp_fast.ps1`:快速覆盖上传当前产物。
|
||||
- `deploy/ftp_sync.ps1`:只补传缺失/大小不符的文件(推荐增量修复)。
|
||||
- `deploy/ftp_apply.ps1`:定向更新(appsettings / web.config / 嵌套 assets)。
|
||||
|
||||
已提供 `deploy/publish.ps1`,一条命令完成「后端 publish + 前端 build + FTP 上传」:
|
||||
### 6.3 发布后生效
|
||||
|
||||
上传 **web.config** 或**回收应用程序池**使新版本生效:
|
||||
```powershell
|
||||
C:\Windows\System32\inetsrv\appcmd recycle apppool /apppool.name:wenchuanyi
|
||||
```
|
||||
> 无法远程回收时:重传一次 web.config(内容不变也行)即可触发 ANCM 重启。
|
||||
|
||||
---
|
||||
|
||||
## 7. 发布后验证清单(照做)
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File deploy/publish.ps1
|
||||
# 1. 首页
|
||||
curl.exe -s -o NUL -w "%{http_code}\n" https://wenchuanyi.bbitcn.net/
|
||||
# → 200
|
||||
# 2. JS / CSS 静态资源(最关键!之前 404 就出在这)
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}\n" https://wenchuanyi.bbitcn.net/assets/index-Bwz67ZeI.js
|
||||
# → 200 1558667
|
||||
# 3. API(返回业务校验/JSON 而非 404 即后端存活)
|
||||
curl.exe -s https://wenchuanyi.bbitcn.net/api/files/by-tag/__probe__
|
||||
# → {"message":"标签无效"} HTTP 400(正常业务响应)
|
||||
# 4. 全流程:上传 → 取件码 → 下载;管理列表可见
|
||||
```
|
||||
|
||||
### 5.3 发布后生效
|
||||
|
||||
- FTP 上传完成后,**回收应用程序池**使新版本生效:
|
||||
- IIS 管理器 → 应用程序池 → `wenchuanyi` → 右键「回收」;
|
||||
- 或命令行:`C:\Windows\System32\inetsrv\appcmd recycle apppool /apppool.name:wenchuanyi`
|
||||
- 浏览器验证 `https://wenchuanyi.bbitcn.net`:
|
||||
1. 首页正常显示;
|
||||
2. `/#/pickup`、`/#/admin` 路由可访问(hash 路由不受 IIS 影响);
|
||||
3. 上传一个测试文件 → 得到取件码 → 取件页下载成功 → 管理码列表可见。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证清单
|
||||
## 8. 故障排查速查(本次实战总结)
|
||||
|
||||
| 项 | 验证方式 | 预期 |
|
||||
| 现象 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 首页静态资源 | 访问 `/` | 页面正常渲染,无 404 |
|
||||
| SPA 路由 | 访问 `/#/pickup` | 取件页正常(hash 路由不受 IIS 影响) |
|
||||
| API | `GET /api/open/download/000000` | 返回 JSON(凭证不存在提示),非 404 |
|
||||
| 上传下载 | 上传 → 取件 → 下载 | 全流程通过,文件名还原 |
|
||||
| 在线预览 | 文本/PDF/图片/音视频 | 预览正常 |
|
||||
| 清理任务 | 查看日志 | 每 30 分钟扫描,无异常报错 |
|
||||
| **500.30** app failed to start | ① publish 文件**漏传**(本次漏 `MySql.Data.dll`、`K4os.Compression.LZ4.dll`,FreeSql.MySql 运行必需);② 站点根混入外来 runtime 文件(coreclr/hostfxr/hostpolicy/aspnetcorev2_inprocess 等);③ 数据库连不上(启动时 `SyncStructure` 抛错) | 对比本地 `publish/` 与服务器文件清单;删掉外来 runtime 文件;开 stdout 看具体异常 |
|
||||
| **404 静态资源**(`/assets/*.js`) | 前端产物放错目录——只放站点根没放**嵌套 `wwwroot/wwwroot`** | 前端产物必须传 `/wwwroot/wwwroot/` |
|
||||
| **502 / outofprocess 探测失败** | web.config 被改成 `outofprocess + exe` | 恢复 `inprocess + dotnet` |
|
||||
| **FTP 上传 550** | 目标文件已存在且被 IIS 进程锁定;或服务器限流 | **先 DeleteFile 再上传**,或临时名上传 + Rename;串行 + 重试 |
|
||||
| **FTP 连接频繁中断** | 服务器对高频连接限流 | 所有 FTP 脚本已串行 + 每次间隔 + 校验大小重试 |
|
||||
| **改配置不生效** | appsettings/代码变了但没重启应用 | 重传 web.config 或回收应用池 |
|
||||
|
||||
### 排查 500.30 的标准流程
|
||||
|
||||
1. **开 stdout**:web.config 改 `stdoutLogEnabled="true"` → 等待重启 → 下载 `logs/stdout_*.log` 看异常 → 查完改回 false。
|
||||
2. **比对文件**:本地 `publish/` 文件清单(名字+大小)vs FTP `/wwwroot/` 清单,找出缺失项。
|
||||
3. **看目录结构**:确认前后端产物分别在 `/wwwroot/` 与 `/wwwroot/wwwroot/`,站点根无多余 runtime 文件。
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
| --- | --- |
|
||||
| 502.5 / 500.30 | Hosting Bundle 未装或版本不匹配;或 appsettings.json 语法错误。检查事件查看器与 stdoutLog。 |
|
||||
| 403.14 | 站点根目录无默认文档且 ANCM 未接管——确认 web.config 在站点根目录。 |
|
||||
| 404 静态资源 | 前端产物未上传到站点根目录,或未启用 `UseStaticFiles`。 |
|
||||
| API 连接 MySQL 失败 | 服务器 3306 未放行 / 白名单未加服务器出口 IP。 |
|
||||
| 上传 OSS 失败 | OSS AK/SK 未填或权限不足;确认 Bucket `bbit-f8-web` 有 Put/Get/Delete 权限。 |
|
||||
| 修改不生效 | 上传后未回收应用程序池。 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全提示
|
||||
## 9. 安全提示
|
||||
|
||||
- `appsettings.json` 含数据库密码与 OSS 凭据,**不要上传到代码仓库 / 不要外传**(已在 .gitignore)。
|
||||
- FTP 凭据仅运维持有,建议定期轮换。
|
||||
|
||||
@@ -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)" }
|
||||
@@ -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') ==="
|
||||
@@ -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 ==="
|
||||
@@ -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"
|
||||
@@ -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 $_ }
|
||||
@@ -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" }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`(~210MB;IIS 默认 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) | 前端上传前拦截 + 服务端双重校验;若超过 IIS(30MB 默认)或 ASP.NET multipart(128MB 默认)限制,请求在到达应用前被拒(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 分钟)。
|
||||
- 开放 API(public / 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) | 前端放开 maxBodyLength;web.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. 待确认问题清单(已全部确认)
|
||||
|
||||
> ✅ = 已确认(已更新到对应章节)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user