docs: 第二套独立部署实施计划
7 个任务:本地构建前端 / 基线快照 / 复制数据库 / 上传编译后端 / 独立 PM2 启动 / IIS 站点 / 端到端验收。 每个改动性任务收尾强制跑原系统体检(restart_time 必须仍为 0)。 prisma db push 前设用户确认门禁,禁止自行加 --accept-data-loss。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RYsNhCmPk2V1hv2jmHFcV
This commit is contained in:
parent
e4ae01f5f5
commit
da5704973c
|
|
@ -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" <<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`
|
||||||
|
- 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'
|
||||||
|
<?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 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 确认原系统健在。
|
||||||
Loading…
Reference in New Issue