diff --git a/空气质量预测/源码/docs/superpowers/plans/2026-07-09-second-independent-deployment.md b/空气质量预测/源码/docs/superpowers/plans/2026-07-09-second-independent-deployment.md new file mode 100644 index 0000000..7e7bdc0 --- /dev/null +++ b/空气质量预测/源码/docs/superpowers/plans/2026-07-09-second-independent-deployment.md @@ -0,0 +1,660 @@ +# 第二套独立部署 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` 失败就重试,缓存会累积,一般 2–3 次装全。 + +- [ ] **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` +- Create(MySQL): 数据库 `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`,失败就**重跑同一条命令**,缓存会累积,一般 2–3 次装全。 + +- [ ] **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` +- Create(MySQL): `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" < "$WORK/ecosystem.config.js" < "$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` +- Create(IIS): 应用池 `iapips2`、站点 `iapips2`、应用 `/iapip-web` 与 `/iapip-ms` +- Create(防火墙): 入站规则放行 TCP 8081 + +**Interfaces:** +- Consumes: 本地 `dist/`(Task 1)、后端 6061(Task 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' + + + + + + + + + + + + + +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 1–6 的全部产出 +- 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 确认原系统健在。