Compare commits

...
3 Commits
Author SHA1 Message Date
fanhongcai a30da88d04 docs: 部署手册按线上实战排错校正(目录结构/故障排查速查/500.30排查流程)
- 新增第0章目录结构认知:IIS 站点根=ContentRoot,前端产物需传嵌套 wwwroot/wwwroot,后端产物传 /wwwroot/
- 新增故障排查速查:500.30(publish漏传DLL/外来runtime文件/数据库连不上)、404静态资源、502 outofprocess、FTP 550(文件被IIS锁定)、FTP限流、配置不生效
- 新增500.30标准排查流程(开stdout/比对文件清单/检查目录结构)
- 更新发布方式(FTP 端口21、密码同数据库密码)与部署架构说明,按 2026-08-24 线上实战经验校正
2026-08-24 02:04:40 +08:00
fanhongcai 5568548001 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 差异,未纳入本次提交)
2026-08-24 02:02:57 +08:00
fanhongcai 2e3f68472e deploy: 修正FTP上传目标为/wwwroot并增强上传健壮性
- RemoteRoot 固定为 /wwwroot (IIS 站点物理根), 网页必须存放于 wwwroot 才可访问
- ftp_upload.ps1: 每文件独立连接、上传后校验大小、3次重试、日志记录
- 新增 ftp_fast.ps1 (快速覆盖上传) 与 ftp_sync.ps1 (仅补传缺失/大小不符文件)
- 上传前删除服务商默认欢迎页 default.html, 避免优先于 index.html
2026-08-24 00:54:09 +08:00
14 changed files with 661 additions and 169 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)
{
+108 -103
View File
@@ -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 / 上传限制
├── *.dllMySql.Data / BouncyCastle / ZstdSharp …) ← 缺一不可!
└── wwwroot\ ← FTP /wwwroot/wwwroot (嵌套 WebRoot,前端产物!)
├── index.html
└── assets\index-*.js / *.css
```
> **规则:后端产物传 `/wwwroot/`,前端产物传 `/wwwroot/wwwroot/`(嵌套)。**
> 前端产物若只放站点根(`/wwwroot/`),页面能 200IIS 默认文档/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 + ANCMAspNetCoreModuleV2InProcess
├── /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 ModuleANCM)直接处理。
- 站点根目录布局见上;后端发布产物全部文件与前端 `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 凭据仅运维持有,建议定期轮换。
+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 $_ }
+31
View File
@@ -0,0 +1,31 @@
# Minimal direct upload (no size check) - fast overwrite upload
$FtpHost="116.198.221.125"; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts"
$Stage = Join-Path $env:TEMP "wcy_publish_staging"
$Log = "D:\CodeBuddy\Pros\WenChuanyi\deploy\ftp_fast.log"
Set-Content -Path $Log -Value "start $(Get-Date -Format HH:mm:ss)" -Encoding UTF8
function L([string]$m){ Add-Content -Path $Log -Value $m -Encoding UTF8 }
function NewR($p,$m){ $r=[System.Net.FtpWebRequest]::Create("ftp://${FtpHost}/$p"); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=120000; $r }
function Mk($p){ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::MakeDirectory); try{$null=$r.GetResponse()}catch{} }
function Put($lp,$rp){
for($i=1;$i -le 3;$i++){
try{
$r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile)
$b=[IO.File]::ReadAllBytes($lp)
$r.ContentLength=$b.Length
$s=$r.GetRequestStream(); $s.Write($b,0,$b.Length); $s.Close()
$x=$r.GetResponse(); $x.Close()
L "OK $rp"; return
}catch{ L "RETRY($i) $rp : $($_.Exception.Message)"; Start-Sleep 3 }
}
L "FAIL $rp"
}
function Up($ld,$rd){
foreach($d in Get-ChildItem $ld -Directory){
# skip Kerberos native dir - rejected by server, not needed on Windows
if($d.FullName -match "win-x64[\\/]native$"){ L "SKIPDIR $($d.FullName)"; continue }
$c="$rd/$($d.Name)"; Mk $c; Up $d.FullName $c
}
foreach($f in Get-ChildItem $ld -File){ Put $f.FullName "$rd/$($f.Name)" }
}
Up $Stage "/wwwroot"
L "DONE $(Get-Date -Format HH:mm:ss)"
+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" }
+70
View File
@@ -0,0 +1,70 @@
# Sync-only upload: upload files that are missing or size-mismatched on FTP
$ErrorActionPreference = "Continue"
$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts"
$RemoteRoot="/wwwroot"
$Stage = Join-Path $env:TEMP "wcy_publish_staging"
$Log = "D:\CodeBuddy\Pros\WenChuanyi\deploy\ftp_sync.log"
$fail = $false
Set-Content -Path $Log -Value "=== sync start $(Get-Date -Format '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 3;$i++){
try{
$r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 180000
$b=[System.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)"; continue
}catch{
L "RETRY($i) $rp : $($_.Exception.Message)"
Start-Sleep -Seconds 5
}
}
L "FAIL $rp"
$script:fail=$true
}
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)"
}
}
if(-not (Test-Path $Stage)){ L "STAGING_MISSING"; exit 1 }
UpDir $Stage $RemoteRoot
if($fail){ L "RESULT=PARTIAL_FAIL" } else { L "RESULT=OK" }
L "=== sync end $(Get-Date -Format 'HH:mm:ss') ==="
+45 -31
View File
@@ -1,59 +1,73 @@
# FTP 上传脚本 (UTF-8 BOM) - 健壮版
$FtpHost="116.198.221.125"
$FtpPort=21
$FtpUser="wenchuanyi"
$FtpPass="r7P^f*v7rFts"
$RemoteRoot="/"
# Robust FTP upload: per-file connection, size-check skip, retry
$ErrorActionPreference = "Continue"
[System.Net.ServicePointManager]::DefaultConnectionLimit = 64
$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts"
$RemoteRoot="/wwwroot"
$Stage = Join-Path $env:TEMP "wcy_publish_staging"
$Log = "D:\CodeBuddy\Pros\WenChuanyi\deploy\ftp_upload.log"
$fail = $false
function New-FtpReq([string]$path,[string]$method){
function L([string]$m){ Add-Content -Path $Log -Value $m -Encoding UTF8 }
function New-FtpReq([string]$path,[string]$method,[int]$timeoutMs=120000){
$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=120000
$r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false
$r.Timeout=$timeoutMs
return $r
}
function MkDir([string]$p){
$r=New-FtpReq $p ([System.Net.WebRequestMethods+Ftp]::MakeDirectory)
try{ $null=$r.GetResponse() }catch{}
}
function Exist([string]$p){
$r=New-FtpReq $p ([System.Net.WebRequestMethods+Ftp]::GetDateTimestamp)
try{ $null=$r.GetResponse(); return $true }catch{ return $false }
function Get-FtpSize([string]$p){
$r=New-FtpReq $p ([System.Net.WebRequestMethods+Ftp]::GetFileSize) 30000
try{ $resp=$r.GetResponse(); $s=$resp.ContentLength; $resp.Close(); return $s }catch{ return -1 }
}
function UpFile([string]$lp,[string]$rp){
$r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile)
$localSize=(Get-Item $lp).Length
$remoteSize=Get-FtpSize $rp
if($remoteSize -eq $localSize){ L "SKIP $rp (size ok)"; return $true }
for($i=1;$i -le 3;$i++){
try{
$r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 240000
$b=[System.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{}
$after=Get-FtpSize $rp
if($after -eq $localSize){ L "UP $rp"; return $true }
else{ L "VERIFYFAIL $rp (got $after want $localSize)"; continue }
}catch{
L "RETRY($i) $rp : $($_.Exception.Message)"
Start-Sleep -Seconds 3
}
}
L "FAIL $rp"
$script:fail=$true
return $false
}
function UpDir([string]$ld,[string]$rd){
foreach($d in Get-ChildItem $ld -Directory){
$c="$rd/$($d.Name)"
if(-not (Exist $c)){ MkDir $c }
$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){
$rf="$rd/$($f.Name)"
try{
if(-not (Exist $rf)){ UpFile $f.FullName $rf; Write-Host "UP $rf" }
else { Write-Host "SKIP(exists) $rf" }
}catch{
Write-Host "RETRY $rf"
try{ UpFile $f.FullName $rf; Write-Host "OK $rf" }catch{ Write-Host "FAIL $rf : $_" }
}
[void](UpFile $f.FullName $rf)
}
}
if(-not (Test-Path $Stage)){ Write-Host "STAGING_MISSING"; exit 1 }
if(-not (Exist $RemoteRoot)){ MkDir $RemoteRoot }
Set-Content -Path $Log -Value "=== upload start $(Get-Date -Format 'HH:mm:ss') ===" -Encoding UTF8
if(-not (Test-Path $Stage)){ L "STAGING_MISSING"; exit 1 }
# delete welcome page first
$dr=New-FtpReq "/wwwroot/default.html" ([System.Net.WebRequestMethods+Ftp]::DeleteFile) 30000
try{ $null=$dr.GetResponse(); L "DELETED default.html" }catch{ L "default.html not present" }
$mr=New-FtpReq $RemoteRoot ([System.Net.WebRequestMethods+Ftp]::MakeDirectory) 30000
try{ $null=$mr.GetResponse() }catch{}
UpDir $Stage $RemoteRoot
Write-Host "FTP_UPLOAD_DONE"
Remove-Item $Stage -Recurse -Force
if($fail){ L "RESULT=PARTIAL_FAIL" } else { L "RESULT=OK" }
L "=== upload end ==="
+12 -1
View File
@@ -4,6 +4,9 @@
# 2. npm run build 前端 (Vite, dist)
# 3. FTP 上传合并产物至 IIS 站点根目录
#
# 重要: 官方提示 —— 网页必需存放在 wwwroot 目录下才能被访问!
# 因此 RemoteRoot 固定为 /wwwroot (FTP 上的目录, 即 IIS 站点物理根)
#
# 用法:
# powershell -ExecutionPolicy Bypass -File deploy/publish.ps1
#
@@ -14,7 +17,7 @@ param(
[int] $FtpPort = 21,
[string]$FtpUser = "wenchuanyi",
[string]$FtpPass = "r7P^f*v7rFts",
[string]$RemoteRoot = "/", # FTP 远端目标目录 (IIS 站点根目录)
[string]$RemoteRoot = "/wwwroot", # FTP 远端目标目录 = IIS 站点根目录 (勿改!)
[switch]$SkipBuild # 跳过构建, 仅上传已有产物
)
@@ -106,6 +109,14 @@ function Send-FtpDirectory([string]$localDir, [string]$remoteDir) {
}
# ---------------- 上传 ----------------
# 删除服务商默认欢迎页 default.html, 避免 IIS 默认文档优先于 index.html
function Remove-FtpFile([string]$remotePath) {
$req = New-FtpRequest $remotePath ([System.Net.WebRequestMethods+Ftp]::DeleteFile)
try { $null = $req.GetResponse(); Write-Host " DEL $remotePath" -ForegroundColor DarkGray }
catch { }
}
Remove-FtpFile "$RemoteRoot/default.html"
New-FtpDirectory $RemoteRoot
Send-FtpDirectory $StagingDir $RemoteRoot
+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. 待确认问题清单(已全部确认)
> ✅ = 已确认(已更新到对应章节)