airpredict/空气质量预测/源码/docs/superpowers/plans/2026-07-09-second-independe...

661 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 第二套独立部署 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在阿里云服务器 `47.95.40.218` 上部署第二套完全独立的 IAPIP 系统(`http://47.95.40.218:8081/iapip-web/`与现有生产系统在进程、数据、文件、IIS 站点四个层面全部隔离,且原系统零改动。
**Architecture:** 新系统的后端代码放 `C:\API2\iapip-svr`(端口 6061由一个**独立 PM2_HOME**`C:\API2\.pm2`)下的守护进程管理,应用名 `iapip-svr2`;前端静态文件放 `C:\iapips2`,由新建的 IIS 站点 `iapips2`(绑定 8081提供`web.config``/api` 反代到 6061数据库 `iapips2` 是原库 `iapips` 的一份 dump 副本,再用 `prisma db push` 补齐新代码多出的表。
**Tech Stack:** Node 18 / Koa / Prisma 5 / MySQL 5.7.44 / PM2 5.4.3 / IIS + URL Rewrite + ARR / UmiJS 4前端本地构建
参见设计文档:`docs/superpowers/specs/2026-07-09-second-independent-deployment-design.md`
## Global Constraints
- **绝对禁止**修改、覆盖、重启原系统的任何部分:`C:\API\iapip-svr`、`C:\iapips\`、IIS 站点 `iapips`、IIS 应用池 `iapips`、数据库 `iapips`、默认 PM2_HOME `C:\Users\Administrator\.pm2` 下的进程 `iapip-svr`
- 所有 pm2 命令**一律**通过 `C:\API2\pm2.bat` 执行。**永远不要**在本次部署中直接调用裸 `pm2`(哪怕是 `pm2 list`),除非该步骤明确标注为"原系统体检"。
- **永远不要**对 `prisma db push``--accept-data-loss`,除非先向用户展示 `migrate diff` 的 SQL 并获得确认。
- 数据库连接串只允许出现 `iapips2`。任何写操作的连接串中出现 `iapips`(无 `2` 后缀)即为事故。
- 端口:后端 `6061`,站点 `8081`。数据库名 `iapips2`。PM2 应用名 `iapip-svr2`
- 服务器不安装 pnpm。前端在本地构建只上传产物。
- 新系统**不配置开机自启****不执行** `prisma/seed.ts`
- 本地 shell 为 Git Bash服务器默认 shell 为 cmd。
### 通用变量(每个任务的命令都假设已定义)
**每个任务开始时都要先执行这一段**(子 agent 分任务执行时shell 变量不跨任务保留):
```bash
SSH="ssh -i C:/Users/11825/.ssh/iapip_deploy -o BatchMode=yes administrator@47.95.40.218"
SCP="scp -i C:/Users/11825/.ssh/iapip_deploy -o BatchMode=yes"
SRC="C:/code/空气质量预测/源码"
WORK="C:/Users/11825/AppData/Local/Temp/claude/C--code----------/fb44c3ac-7eaa-49ef-a411-7f834989572f/scratchpad"
APPCMD='C:\Windows\System32\inetsrv\appcmd'
MYSQL='"C:\MySQL\mysql-5.7.44-winx64\bin\mysql.exe" -uroot -pharvey0425'
MYSQLDUMP='"C:\MySQL\mysql-5.7.44-winx64\bin\mysqldump.exe" -uroot -pharvey0425'
mkdir -p "$WORK"
```
> **远程 PowerShell 的引号陷阱**:本地是 Git Bash双引号里的 `$x` 会被 bash 先行展开,导致远程 PowerShell 收到空变量。本计划中所有远程 PS 命令都刻意避免使用 PS 变量。若你需要写带变量的 PS 脚本,改用:把脚本写成本地 `.ps1` 文件 → `scp` 上去 → `$SSH "powershell -NoProfile -ExecutionPolicy Bypass -File C:\API2\_deploy\x.ps1"`。
### 原系统体检(下称 **HEALTHCHECK**
多个任务结束时要求执行此检查。三项全部通过才算该任务完成:
```bash
$SSH "C:\Windows\System32\inetsrv\appcmd list site iapips"
# 预期输出包含: state:Started
$SSH "pm2 jlist" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s).find(x=>x.name==="iapip-svr");console.log("name="+a.name,"status="+a.pm2_env.status,"restarts="+a.pm2_env.restart_time)})'
# 预期输出: name=iapip-svr status=online restarts=0
$SSH "powershell -NoProfile -Command \"(Invoke-WebRequest -Uri http://localhost:6060/api/test/ping -UseBasicParsing).StatusCode\""
# 预期输出: 200
```
任何一项不符 → **立即停止,报告用户,不要继续**
---
## Task 1: 本地构建前端产物
只在本地操作,不碰服务器。产出用户端与管理端的静态文件。
**Files:**
- Modify如需: `源码/用户端/iapip-web/pnpm-workspace.yaml`
- Modify如需: `源码/管理端/iapip-ms/pnpm-workspace.yaml`
- Create构建产物不入 git: `源码/用户端/iapip-web/dist/`
- Create构建产物不入 git: `源码/管理端/iapip-ms/dist/`
**Interfaces:**
- Consumes: 无
- Produces: `dist/` 目录,供 Task 6 上传。`dist/index.html` 中的资源路径前缀必须是 `/iapip-web/`(管理端为 `/iapip-ms/`
- [ ] **Step 1: 确认当前分支是 feature/source-charts**
```bash
cd "$SRC" && git rev-parse --abbrev-ref HEAD
```
预期输出:`feature/source-charts`
不是的话执行 `git checkout feature/source-charts` 再继续。
- [ ] **Step 2: 确认 pnpm 构建脚本放行**
pnpm v11 默认拦截 build scripts会以 `ERR_PNPM_IGNORED_BUILDS` 退出。检查两个 workspace 文件:
```bash
cat "$SRC/用户端/iapip-web/pnpm-workspace.yaml"
cat "$SRC/管理端/iapip-ms/pnpm-workspace.yaml"
```
`allowBuilds` 不存在或是占位文字,改成:
```yaml
allowBuilds:
core-js: true
core-js-pure: true
es5-ext: true
esbuild: true
```
- [ ] **Step 3: 构建用户端**
```bash
cd "$SRC/用户端/iapip-web" && pnpm install && pnpm build
```
预期:最后打印 `Build success`(或 umi 的成功摘要),`dist/` 目录生成。
npmmirror 源偶发 `ECONNRESET``pnpm install` 失败就重试,缓存会累积,一般 23 次装全。
- [ ] **Step 4: 验证用户端产物的 publicPath**
```bash
cd "$SRC/用户端/iapip-web" && ls dist/index.html && grep -o '/iapip-web/[a-z0-9_.-]*\.js' dist/index.html | head -3
```
预期:`dist/index.html` 存在,且 grep 打印出若干条以 `/iapip-web/` 开头的路径。
若路径不带 `/iapip-web/` 前缀 → `.umirc.ts``publicPath` 被改过,停止并报告。
- [ ] **Step 5: 构建管理端**
```bash
cd "$SRC/管理端/iapip-ms" && pnpm install && pnpm build
```
预期:构建成功,`dist/` 生成。
- [ ] **Step 6: 验证管理端产物的 publicPath**
```bash
cd "$SRC/管理端/iapip-ms" && ls dist/index.html && grep -o '/iapip-ms/[a-z0-9_.-]*\.js' dist/index.html | head -3
```
预期:`dist/index.html` 存在grep 打印出若干条 `/iapip-ms/` 开头的路径。
- [ ] **Step 7: 记录产物大小,供上传后比对**
```bash
du -sb "$SRC/用户端/iapip-web/dist" "$SRC/管理端/iapip-ms/dist"
```
把两个字节数记下来Task 6 上传后要核对。
(本任务无 commit`dist/` 是构建产物;若 `pnpm-workspace.yaml` 被改动,在 Task 1 末尾单独提交:)
```bash
cd "$SRC" && git add 用户端/iapip-web/pnpm-workspace.yaml 管理端/iapip-ms/pnpm-workspace.yaml && git commit -m "chore: 放行 pnpm 构建脚本以支持生产构建"
```
---
## Task 2: 服务器基线快照与目录骨架
在动任何东西之前,把原系统的状态记录下来,作为后续每次 HEALTHCHECK 的比对基准。然后创建新系统的空目录。
**Files:**
- Create服务器: `C:\API2\`
- Create服务器: `C:\API2\_deploy\`(存放临时脚本)
- Create服务器: `C:\iapips2\`
- Create本地: 基线记录,保存到 scratchpad
**Interfaces:**
- Consumes: 无
- Produces: 服务器上存在空目录 `C:\API2`、`C:\API2\_deploy`、`C:\iapips2`
- [ ] **Step 1: 抓取原系统基线**
```bash
$SSH "pm2 jlist" > /tmp/baseline-pm2.json
$SSH "C:\Windows\System32\inetsrv\appcmd list site" > /tmp/baseline-sites.txt
$SSH "C:\Windows\System32\inetsrv\appcmd list apppool" > /tmp/baseline-apppools.txt
cat /tmp/baseline-sites.txt
```
预期 `baseline-sites.txt` 含两行:`Default Web Site`Stopped`iapips`Started
- [ ] **Step 2: 确认 6061 与 8081 仍空闲,且 iapips2 库不存在**
```bash
$SSH "netstat -ano | findstr /R \":6061 :8081\""
```
预期:**无输出**(退出码非 0 也正常findstr 无匹配即返回 1。有输出则端口被占停止并报告。
```bash
$SSH "$MYSQL -e \"show databases;\"" 2>&1 | grep -v Warning
```
预期输出中**没有** `iapips2`。若已存在 → 停止并报告(可能是上次失败的残留,需先确认能否 DROP
- [ ] **Step 3: 创建目录骨架**
```bash
$SSH "mkdir C:\API2 & mkdir C:\API2\_deploy & mkdir C:\API2\iapip-svr & mkdir C:\iapips2"
```
- [ ] **Step 4: 验证目录已建且原目录未被触碰**
```bash
$SSH "if exist C:\API2\_deploy (echo API2_OK) & if exist C:\iapips2 (echo IAPIPS2_OK) & if exist C:\API\iapip-svr (echo ORIG_SVR_INTACT) & if exist C:\iapips\web.config (echo ORIG_WEB_INTACT)"
```
预期输出四行:`API2_OK`、`IAPIPS2_OK`、`ORIG_SVR_INTACT`、`ORIG_WEB_INTACT`
- [ ] **Step 5: HEALTHCHECK**
执行本文档开头定义的 HEALTHCHECK 三项。全部通过才进入 Task 3。
---
## Task 3: 复制数据库到 iapips2
全程只读原库。dump 用 `--single-transaction`,对 InnoDB 不加锁,不影响线上读写。
**Files:**
- Create服务器: `C:\API2\iapips_backup.sql`
- CreateMySQL: 数据库 `iapips2`
**Interfaces:**
- Consumes: `C:\API2\` 目录Task 2
- Produces: 数据库 `iapips2`内含原库全部表与数据schema 尚未补齐Task 5 处理)
- [ ] **Step 1: 记录原库表数量与关键表行数,供导入后比对**
```bash
$SSH "$MYSQL -N -e \"select count(*) from information_schema.tables where table_schema='iapips';\"" 2>&1 | grep -v Warning
$SSH "$MYSQL -N -e \"select (select count(*) from iapips.user), (select count(*) from iapips.project), (select count(*) from iapips.material);\"" 2>&1 | grep -v Warning
```
把这四个数字记下来。
- [ ] **Step 2: 导出原库**
```bash
$SSH "$MYSQLDUMP --single-transaction --default-character-set=utf8mb4 --routines --events iapips > C:\API2\iapips_backup.sql"
```
- [ ] **Step 3: 验证 dump 文件非空且以正常结尾**
```bash
$SSH "powershell -NoProfile -Command \"(Get-Item 'C:\API2\iapips_backup.sql').Length; (Get-Content 'C:\API2\iapips_backup.sql' -Tail 1)\""
```
预期:打印出一个远大于 0 的字节数,最后一行形如 `-- Dump completed on ...`
若最后一行不是 `Dump completed` → dump 不完整,停止并报告。
- [ ] **Step 4: 建空库并导入**
```bash
$SSH "$MYSQL -e \"CREATE DATABASE iapips2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\"" 2>&1 | grep -v Warning
$SSH "$MYSQL --default-character-set=utf8mb4 iapips2 < C:\API2\iapips_backup.sql" 2>&1 | grep -v Warning
```
- [ ] **Step 5: 比对新库与原库的表数量和行数**
```bash
$SSH "$MYSQL -N -e \"select count(*) from information_schema.tables where table_schema='iapips2';\"" 2>&1 | grep -v Warning
$SSH "$MYSQL -N -e \"select (select count(*) from iapips2.user), (select count(*) from iapips2.project), (select count(*) from iapips2.material);\"" 2>&1 | grep -v Warning
```
预期:四个数字与 Step 1 记录的**完全一致**。不一致 → 停止并报告。
- [ ] **Step 6: 确认原库行数没变(证明 dump 是只读的)**
```bash
$SSH "$MYSQL -N -e \"select (select count(*) from iapips.user), (select count(*) from iapips.project), (select count(*) from iapips.material);\"" 2>&1 | grep -v Warning
```
预期:与 Step 1 一致。
- [ ] **Step 7: HEALTHCHECK**
---
## Task 4: 上传后端源码并在服务器上编译
`@prisma/client``esbuild` 含平台相关原生二进制,本地 `node_modules` 不可拷贝,必须在服务器上 `npm install`
**Files:**
- Create服务器: `C:\API2\iapip-svr\{src,prisma,fonts}`、`package.json`、`tsconfig.json`、`.npmrc`、`opft.xlsx`
- Create服务器由 npm/tsc 生成): `C:\API2\iapip-svr\node_modules`、`C:\API2\iapip-svr\dist`
**Interfaces:**
- Consumes: `C:\API2\iapip-svr\` 空目录Task 2
- Produces: `C:\API2\iapip-svr\dist\index.js` —— PM2 的启动脚本
- [ ] **Step 1: 上传源码(不含 node_modules / dist / logs / .env**
```bash
cd "$SRC/服务端/iapip-svr"
$SCP -r src prisma fonts administrator@47.95.40.218:C:/API2/iapip-svr/
$SCP package.json tsconfig.json opft.xlsx administrator@47.95.40.218:C:/API2/iapip-svr/
[ -f .npmrc ] && $SCP .npmrc administrator@47.95.40.218:C:/API2/iapip-svr/ || echo "no .npmrc, will use default registry"
```
- [ ] **Step 2: 验证上传完整**
```bash
$SSH "dir C:\API2\iapip-svr & echo --- & if exist C:\API2\iapip-svr\src\index.ts (echo SRC_OK) & if exist C:\API2\iapip-svr\prisma\schema.prisma (echo PRISMA_OK) & if exist C:\API2\iapip-svr\fonts (echo FONTS_OK)"
```
预期:三行 `SRC_OK`、`PRISMA_OK`、`FONTS_OK`
- [ ] **Step 3: 安装依赖**
```bash
$SSH "cd /d C:\API2\iapip-svr && npm install"
```
预期:以 `added N packages` 收尾。
npmmirror 偶发 `ECONNRESET` / `CONNECT_TIMEOUT`,失败就**重跑同一条命令**,缓存会累积,一般 23 次装全。
- [ ] **Step 4: 生成 Prisma Client**
```bash
$SSH "cd /d C:\API2\iapip-svr && npx prisma generate"
```
预期:`Generated Prisma Client (v5.x.x) to .\node_modules\@prisma\client`
- [ ] **Step 5: 编译 TypeScript**
```bash
$SSH "cd /d C:\API2\iapip-svr && npm run build"
```
预期:`tsc` 无输出即成功(有报错则打印错误)。
- [ ] **Step 6: 验证编译产物**
```bash
$SSH "if exist C:\API2\iapip-svr\dist\index.js (echo DIST_OK) else (echo DIST_MISSING)"
```
预期:`DIST_OK`
- [ ] **Step 7: HEALTHCHECK**
---
## Task 5: 后端配置、schema 补齐、独立 PM2 启动
本任务包含**唯一一个需要用户确认的门禁**Step 4
**Files:**
- Create服务器: `C:\API2\iapip-svr\.env`
- Create服务器: `C:\API2\iapip-svr\ecosystem.config.js`
- Create服务器: `C:\API2\pm2.bat`
- CreateMySQL: `iapips2` 中新增三张表 `ForumThread` / `ForumReply` / `ForumThreadLike`
**Interfaces:**
- Consumes: `C:\API2\iapip-svr\dist\index.js`Task 4、数据库 `iapips2`Task 3
- Produces: 监听 `localhost:6061` 的后端进程PM2 应用名 `iapip-svr2`,位于 PM2_HOME `C:\API2\.pm2`
- [ ] **Step 1: 本地生成 JWT 密钥并写好两个配置文件**
在本地 scratchpad 里生成,再上传——避免远程 shell 的引号转义问题。(`$WORK` 已在"通用变量"中定义。)
```bash
JWT=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
echo "生成的 JWT 密钥: $JWT"
cat > "$WORK/.env" <<EOF
DATABASE_URL="mysql://root:harvey0425@localhost:3306/iapips2"
JWT_SECRET_KEY="$JWT"
IAPIP_SVR_PORT=6061
NODE_ENV=production
EOF
cat > "$WORK/ecosystem.config.js" <<EOF
module.exports = {
apps: [
{
name: "iapip-svr2",
script: "dist/index.js",
exec_mode: "cluster",
instance_var: "INSTANCE_ID",
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "3G",
log_date_format: "YYYY-MM-DD HH:mm",
env: {
NODE_ENV: "production",
DATABASE_URL: "mysql://root:harvey0425@localhost:3306/iapips2",
JWT_SECRET_KEY: "$JWT",
IAPIP_SVR_PORT: 6061
},
},
],
};
EOF
cat > "$WORK/pm2.bat" <<'EOF'
@echo off
set PM2_HOME=C:\API2\.pm2
pm2 %*
EOF
```
- [ ] **Step 2: 上传三个文件**
```bash
$SCP "$WORK/.env" "$WORK/ecosystem.config.js" administrator@47.95.40.218:C:/API2/iapip-svr/
$SCP "$WORK/pm2.bat" administrator@47.95.40.218:C:/API2/
$SSH "type C:\API2\pm2.bat & echo --- & findstr /C:\"iapip-svr2\" /C:\"6061\" /C:\"iapips2\" C:\API2\iapip-svr\ecosystem.config.js"
```
预期:`pm2.bat` 内容正确findstr 打印出含 `iapip-svr2`、`6061`、`iapips2` 的行。
**若 `ecosystem.config.js` 里出现不带 `2` 的 `iapips` 数据库名 → 停止,配置写错了。**
- [ ] **Step 3: 预览 prisma db push 将要执行的 SQL只读不改库**
```bash
$SSH "cd /d C:\API2\iapip-svr && npx prisma migrate diff --from-url \"mysql://root:harvey0425@localhost:3306/iapips2\" --to-schema-datamodel prisma/schema.prisma --script"
```
预期:输出一段 SQL应当以 `CREATE TABLE` 为主(`ForumThread`、`ForumReply`、`ForumThreadLike`)。
- [ ] **Step 4: 门禁 —— 把 SQL 交给用户确认**
审读上一步的 SQL。若其中包含任何 `DROP TABLE`、`DROP COLUMN`,或会导致数据丢失的 `MODIFY COLUMN`
> **停止。把完整 SQL 呈报用户,说明哪些语句有丢数据风险,等待用户明确同意后再继续。**
> 无论如何**都不要**自行加 `--accept-data-loss`。
若 SQL 只有 `CREATE TABLE` / `CREATE INDEX` / `ADD COLUMN`(可空或有默认值),可直接进入 Step 5。
- [ ] **Step 5: 应用 schema 变更到 iapips2**
```bash
$SSH "cd /d C:\API2\iapip-svr && npm run dbpush:prod"
```
(该脚本是 `dotenv -e .env -- npx prisma db push``.env` 里的 `DATABASE_URL` 指向 `iapips2`。)
预期:`Your database is now in sync with your Prisma schema.`
- [ ] **Step 6: 验证三张新表已建,且原库未被波及**
```bash
$SSH "$MYSQL -N -e \"select table_name from information_schema.tables where table_schema='iapips2' and table_name in ('ForumThread','ForumReply','ForumThreadLike','forumthread','forumreply','forumthreadlike');\"" 2>&1 | grep -v Warning
$SSH "$MYSQL -N -e \"select count(*) from information_schema.tables where table_schema='iapips' and table_name like 'forum%';\"" 2>&1 | grep -v Warning
```
预期:第一条打印 3 行表名;第二条打印 `0`(原库**没有**被加上 forum 表)。
- [ ] **Step 7: 用独立 PM2_HOME 启动新后端**
```bash
$SSH "cd /d C:\API2\iapip-svr && C:\API2\pm2.bat start ecosystem.config.js --env production"
$SSH "cd /d C:\API2\iapip-svr && C:\API2\pm2.bat save"
```
预期pm2 打印表格,含一行 `iapip-svr2`status `online`
- [ ] **Step 8: 验证后端在 6061 上活着**
```bash
$SSH "powershell -NoProfile -Command \"(Invoke-WebRequest -Uri http://localhost:6061/api/test/ping -UseBasicParsing).Content\""
```
预期输出:`OK`
- [ ] **Step 9: 验证两个 PM2 守护进程互不可见(隔离的核心证据)**
```bash
echo "=== 新系统的 pm2应只有 iapip-svr2==="
$SSH "C:\API2\pm2.bat jlist" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).map(x=>x.name)))'
echo "=== 原系统的 pm2应只有 iapip-svr==="
$SSH "pm2 jlist" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).map(x=>x.name)))'
```
预期:第一条打印 `['iapip-svr2']`,第二条打印 `['iapip-svr']`
**若任一列表里同时出现两个名字 → PM2_HOME 隔离失败,停止并排查 `pm2.bat`。**
- [ ] **Step 10: HEALTHCHECK**
---
## Task 6: 前端静态文件与 IIS 站点
**Files:**
- Create服务器: `C:\iapips2\iapip-web\`、`C:\iapips2\iapip-ms\`、`C:\iapips2\fonts\`、`C:\iapips2\fts\`
- Create服务器: `C:\iapips2\web.config`
- CreateIIS: 应用池 `iapips2`、站点 `iapips2`、应用 `/iapip-web``/iapip-ms`
- Create防火墙: 入站规则放行 TCP 8081
**Interfaces:**
- Consumes: 本地 `dist/`Task 1、后端 6061Task 5
- Produces: `http://localhost:8081/iapip-web/` 可访问,`http://localhost:8081/api/*` 反代到 6061
- [ ] **Step 1: 上传两个前端产物**
```bash
$SSH "mkdir C:\iapips2\iapip-web & mkdir C:\iapips2\iapip-ms"
$SCP -r "$SRC/用户端/iapip-web/dist/." administrator@47.95.40.218:C:/iapips2/iapip-web/
$SCP -r "$SRC/管理端/iapip-ms/dist/." administrator@47.95.40.218:C:/iapips2/iapip-ms/
```
- [ ] **Step 2: 验证上传完整(比对字节数)**
```bash
$SSH "powershell -NoProfile -Command \"'web: ' + ((Get-ChildItem C:\iapips2\iapip-web -Recurse -File | Measure-Object Length -Sum).Sum); 'ms: ' + ((Get-ChildItem C:\iapips2\iapip-ms -Recurse -File | Measure-Object Length -Sum).Sum)\""
```
预期:两个数字与 Task 1 Step 7 记录的字节数一致。
- [ ] **Step 3: 从原站只读复制 PDF 中文字体目录**
```bash
$SSH "xcopy C:\iapips\fonts C:\iapips2\fonts\ /E /I /Y & xcopy C:\iapips\fts C:\iapips2\fts\ /E /I /Y"
$SSH "if exist C:\iapips2\fonts (echo FONTS_OK) & if exist C:\iapips2\fts (echo FTS_OK) & if exist C:\iapips\fonts (echo ORIG_FONTS_INTACT)"
```
预期:`FONTS_OK`、`FTS_OK`、`ORIG_FONTS_INTACT`
`xcopy` 的源是原站目录,只读;目标是新目录。方向不要写反。)
- [ ] **Step 4: 写入 web.config反代到 6061**
```bash
cat > "$WORK/web.config" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="api">
<match url="^api/(.*)$" />
<action type="Rewrite" url="http://localhost:6061/api/{R:1}" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>
EOF
$SCP "$WORK/web.config" administrator@47.95.40.218:C:/iapips2/web.config
$SSH "type C:\iapips2\web.config | findstr 6061"
```
预期:打印出含 `localhost:6061` 的那行。
(前端是 hash 路由,不需要 SPA 回退规则;原站也没有。)
- [ ] **Step 5: 授予 IIS 应用池读取权限**
```bash
$SSH "icacls C:\iapips2 /grant \"IIS_IUSRS:(OI)(CI)RX\" /T"
```
预期:`Successfully processed N files`
- [ ] **Step 6: 建应用池与站点**
```bash
APPCMD='C:\Windows\System32\inetsrv\appcmd'
$SSH "$APPCMD add apppool /name:iapips2"
$SSH "$APPCMD add site /name:iapips2 /bindings:http/*:8081: /physicalPath:C:\iapips2"
$SSH "$APPCMD set app \"iapips2/\" /applicationPool:iapips2"
```
预期:三条依次打印 `APPPOOL object "iapips2" added.` / `SITE object "iapips2" added.` / `APP object "iapips2/" changed.`
- [ ] **Step 7: 建两个子应用**
```bash
$SSH "$APPCMD add app /site.name:iapips2 /path:/iapip-web /physicalPath:C:\iapips2\iapip-web"
$SSH "$APPCMD add app /site.name:iapips2 /path:/iapip-ms /physicalPath:C:\iapips2\iapip-ms"
$SSH "$APPCMD set app \"iapips2/iapip-web\" /applicationPool:iapips2"
$SSH "$APPCMD set app \"iapips2/iapip-ms\" /applicationPool:iapips2"
```
- [ ] **Step 8: 验证 IIS 结构,且原站点未被改动**
```bash
$SSH "$APPCMD list site & echo --- & $APPCMD list app"
```
预期:
- `iapips2` 站点存在,`bindings:http/*:8081:``state:Started`
- `iapips` 站点仍然 `state:Started`,绑定仍含 `indoorhealthair.com`
- 应用列表含 `iapips2/`、`iapips2/iapip-web`、`iapips2/iapip-ms`,且原有的 `iapips/*` 三条一字未变
- [ ] **Step 9: 放行本机防火墙 8081**
```bash
$SSH "netsh advfirewall firewall add rule name=\"IAPIP2 HTTP 8081\" dir=in action=allow protocol=TCP localport=8081"
```
预期:`确定。` 或 `Ok.`
- [ ] **Step 10: 服务器本机验证站点与反代**
```bash
$SSH "powershell -NoProfile -Command \"'web: ' + (Invoke-WebRequest -Uri http://localhost:8081/iapip-web/ -UseBasicParsing).StatusCode\""
$SSH "powershell -NoProfile -Command \"'ms: ' + (Invoke-WebRequest -Uri http://localhost:8081/iapip-ms/ -UseBasicParsing).StatusCode\""
$SSH "powershell -NoProfile -Command \"'api: ' + (Invoke-WebRequest -Uri http://localhost:8081/api/test/ping -UseBasicParsing).Content\""
```
预期:`web: 200`、`ms: 200`、`api: OK`
最后一条是关键——它证明 IIS 的 rewrite 规则把 `/api` 打到了 **6061**(新后端),而不是 6060。
- [ ] **Step 11: 证明新站点的 /api 确实走的是新后端**
新旧后端连的是不同的库。往 `iapips2` 里加一条只存在于新库的论坛帖,再通过 8081 读出来——如果读到了,说明链路确实是 `8081 → 6061 → iapips2`
更简单的等价验证:确认原后端的 6060 上 `/api/test/ping` 与新站点 8081 的 `/api/test/ping` 都返回 OK但**停掉新后端后 8081 的 /api 应当失败**
```bash
$SSH "C:\API2\pm2.bat stop iapip-svr2"
$SSH "powershell -NoProfile -Command \"try { (Invoke-WebRequest -Uri http://localhost:8081/api/test/ping -UseBasicParsing -TimeoutSec 10).StatusCode } catch { 'FAILED_AS_EXPECTED' }\""
$SSH "powershell -NoProfile -Command \"(Invoke-WebRequest -Uri http://localhost:6060/api/test/ping -UseBasicParsing).Content\""
$SSH "C:\API2\pm2.bat start iapip-svr2"
```
预期:第二条打印 `FAILED_AS_EXPECTED`(新站点的 /api 依赖新后端),第三条仍打印 `OK`(原后端不受影响)。
最后一条把新后端重新拉起。
- [ ] **Step 12: HEALTHCHECK**
---
## Task 7: 端到端验收
**Files:** 无(纯验证)
**Interfaces:**
- Consumes: Task 16 的全部产出
- Produces: 验收结论
- [ ] **Step 1: 请用户在阿里云控制台放行 8081**
> **这一步 Claude 无法代劳。** 请用户到阿里云控制台 → ECS 实例 `iZt3gm4avglu0sZ` → 安全组 → 配置规则 → 入方向 → 添加:协议 TCP端口范围 `8081/8081`,授权对象 `0.0.0.0/0`(或按需收窄)。
- [ ] **Step 2: 从本地验证外网可达**
```bash
curl -s -o /dev/null -w "%{http_code}\n" http://47.95.40.218:8081/iapip-web/
curl -s http://47.95.40.218:8081/api/test/ping
```
预期:`200`,然后 `OK`
若超时 → 安全组未生效,回到 Step 1。
(注意:在 Git Bash 里 curl 的路径 `/api/...` 可能被 MSYS 改写,若结果异常改用 `curl -s "http://47.95.40.218:8081/api/test/ping"` 加引号,或在 PowerShell 里跑。)
- [ ] **Step 3: 用户端功能验收(人工,浏览器)**
打开 `http://47.95.40.218:8081/iapip-web/`
1. 用原系统的账号登录(数据是从原库复制的,账号密码一致)
2. 新建或打开一个项目,配置空间与材料,执行一次预测
3. 进入 `/source` 页,确认**分房间柱状图**与**材料贡献饼图**两个新图表正常渲染
4. 确认能下载 PDF 报告(验证 `fonts/` 拷贝正确,中文不乱码)
- [ ] **Step 4: 管理端验收(人工,浏览器)**
打开 `http://47.95.40.218:8081/iapip-ms/`,用管理员账号登录,确认材料库与审核列表能正常加载。
- [ ] **Step 5: 数据隔离验收**
在新系统里改一条数据(例如新建一个项目),然后确认原库不受影响:
```bash
$SSH "$MYSQL -N -e \"select (select count(*) from iapips.project) as orig, (select count(*) from iapips2.project) as new_;\"" 2>&1 | grep -v Warning
```
预期:`new_` 比 `orig` 多 1`orig` 与 Task 3 Step 1 记录的值相同。
- [ ] **Step 6: 原系统最终体检**
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://indoorhealthair.com/iapip-web/
```
预期:`200`
再执行完整 HEALTHCHECK。特别确认 `iapip-svr``restart_time` **仍然是 0** —— 这证明整个部署过程中原后端一次都没被重启过。
- [ ] **Step 7: 更新部署记忆**
把新系统的坐标写入 `C:\Users\11825\.claude\projects\C--code\memory\iapip-deploy-server.md`:两套系统的端口/库名/PM2_HOME/站点名对照表,以及"新系统 pm2 必须走 `C:\API2\pm2.bat`"这条操作纪律。
---
## 回滚
任何阶段失败都可以安全回滚,原系统零改动,无需恢复。
```bash
$SSH "C:\API2\pm2.bat delete iapip-svr2"
$SSH "C:\API2\pm2.bat kill"
$SSH "C:\Windows\System32\inetsrv\appcmd delete site iapips2"
$SSH "C:\Windows\System32\inetsrv\appcmd delete apppool iapips2"
$SSH "$MYSQL -e \"DROP DATABASE iapips2;\""
$SSH "netsh advfirewall firewall delete rule name=\"IAPIP2 HTTP 8081\""
$SSH "rmdir /S /Q C:\API2 & rmdir /S /Q C:\iapips2"
```
执行后跑一次 HEALTHCHECK 确认原系统健在。