---
slug: "self-dev-service-deploy-skill"
displayName: "Web服务部署"
version: "2.0.3"
description: "Web服务一站式部署技能。覆盖DNS配置、Nginx反向代理、前端静态文件部署、EdgeOne CDN加速和SSL证书申请。触发词：部署服务、上线、配域名、nginx反代、开端口、子域名、回源配置、DNS记录、acme证书、前端部署、白屏修复、冒烟测试。"
---


# Service Deploy — 服务上线全流程 v2

从"有源码"到"公网可访问且冒烟通过"的标准流程。
适用于：腾讯云轻量/CVM + DNSPod + EdgeOne CDN + React/Vite 前端 + Express BFF 后端。

## 前置条件

- [ ] 目标服务器 SSH 访问（root，密钥认证）
- [ ] tccli 已配置（腾讯云凭证）
- [ ] 有域名管理权限（DNSPod 或 EdgeOne 接入）
- [ ] Node.js >= 22（前端构建用，服务器 nvm 管理）
- [ ] Playwright 已安装（冒烟测试用）

## 完整工作流（9 步）

### Step 1: 环境检查

```bash
# 服务器连接测试
SSH_CMD="ssh -i <KEY> -o StrictHostKeyChecking=no root@<IP>"
$SSH_CMD "uname -a; node -v; nginx -v 2>&1; ss -tlnp | grep ':80\|:443'"

# 检查 Node 版本（Vite 8 需要 Node >= 22）
$SSH_CMD 'source /root/.nvm/nvm.sh && nvm use 22 && node -v'

# 检查端口占用
$SSH_CMD 'ss -tlnp | grep :80'
# 如被占用：systemctl stop <占用服务> && systemctl disable <占用服务>
```

### Step 2: DNS 解析

```bash
# 方案A: DNSPod 直解（适合无 CDN）
tccli dnspod CreateRecord --Domain <主域名> --SubDomain <子域名> --RecordType A --RecordLine 默认 --Value <IP>

# 方案B: EdgeOne CDN 接入（推荐，自带 HTTPS + 加速）
# 1. 在 EdgeOne 控制台添加加速域名
tccli teo CreateAccelerationDomain --ZoneId <zone-id> --DomainName <子域名>.<主域名> --OriginType IP_DOMAIN --Origin <IP>
# 2. DNS CNAME 到 EdgeOne
# 在 DNSPod 添加 CNAME: <子域名> → <子域名>.<主域名>.eo.dnse2.com
```

**验证：** `dig <子域名>.<主域名> +short`

### Step 3: Nginx 配置

```bash
# 写入配置（注意：如果走 EdgeOne CDN，回源协议必须设为 HTTP）
$SSH_CMD "cat > /etc/nginx/conf.d/<子域名>.conf << 'EOF'
server {
    listen 80;
    server_name <子域名>.<主域名>;

    root /opt/<项目名>/client/dist;
    index index.html;

    # API 反代 + CORS（前后端分离时必需）
    location /api/ {
        if (\$request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' '*' always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
            add_header 'Access-Control-Max-Age' 86400;
            return 204;
        }
        proxy_pass http://127.0.0.1:<后端端口>;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
    }

    location / {
        try_files \$uri \$uri/ /index.html;
    }
}
EOF"
$SSH_CMD 'nginx -t && nginx -s reload'
```

### Step 4: EdgeOne CDN 配置（可选但推荐）

```bash
# 关键：设置回源协议为 HTTP（nginx 只监听 80）
tccli teo ModifyAccelerationDomain --ZoneId <zone-id> --DomainName <子域名>.<主域名> --OriginProtocol HTTP

# 申请免费 HTTPS 证书
tccli teo ApplyFreeCertificate --ZoneId <zone-id> --DomainName <子域名>.<主域名>

# 验证回源
curl -sk https://<子域名>.<主域名>/api/<health-path>
```

**⚠️ OriginProtocol 必须为 HTTP**：
- 如果设为 FOLLOW/HTTPS，CDN 用 HTTPS 回源但 nginx 只监听 80 → 回源失败 → 返回缓存旧内容或错误页面
- 这是白屏问题的高频根因之一

### Step 5: 前端构建与部署

```bash
# 用 Node 22 构建（Vite 8 强制要求）
$SSH_CMD 'source /root/.nvm/nvm.sh && nvm use 22 && cd /opt/<项目>/client && npm install && npx vite build'
# 或在本地构建后 scp
# cd /tmp/<project>-client && npm install && npx vite build
# scp -r dist/* $SSH_CMD:"/opt/<project>/client/dist/"

# 清除 CDN 缓存（每次构建后必须执行）
tccli teo CreatePurgeTask --ZoneId <zone-id> --Type purge_host --Targets '["<子域名>.<主域名>"]'
```

**前端常见坑：**

| 问题 | 根因 | 修复 |
|------|------|------|
| 白屏 | `index.css` 是 Vite 脚手架残留模板，覆盖了 Ant Design 样式 | 替换为最小 reset |
| 白屏 | 图标在 Ant Design 6 中不存在（如 `NetworkOutlined`） | 替换为存在的图标（如 `ApiOutlined`） |
| 白屏 | CDN 缓存旧 HTML，引用不存在的 JS 文件 | 每次 build 后清 CDN 缓存 |
| 白屏 | EdgeOne 回源协议 HTTPS，nginx 只监听 80 | `OriginProtocol = HTTP` |
| 样式错乱 | `index.css` 有 `#root { width: 1126px; text-align: center }` 等 | 清理脚手架 CSS |
| 构建失败 | Node 18 + Vite 8 | 用 nvm 切 Node 22 |

### Step 6: 后端 API 修复（关键：字段映射）

腾讯云 TEO SDK 的返回字段名经常和文档不一致，**必须实测**：

```bash
# 1. 用 tccli 先调通，确认返回字段名
tccli teo DescribeAccelerationDomains --ZoneId <zone-id> --Limit 5
# 发现返回 AccelerationDomains 而非 Domains

tccli teo DescribeDefaultCertificates --ZoneId <zone-id>
# 发现 DescribeCertificates 根本不存在

# 2. 用 SDK 列出所有可用方法
node -e "
const t = require('tencentcloud-sdk-nodejs');
const c = t.teo.v20220901.Client.prototype;
console.log(Object.getOwnPropertyNames(c).filter(m => m.startsWith('Describe')).sort().join('\n'));
"

# 3. 后端做字段映射
# 例：domain.js
# resp.AccelerationDomains → map → { DomainName, Status: DomainStatus, OriginType, Origin }
```

**常见字段不匹配：**

| 前端期望 | API 实际返回 | 映射层 |
|----------|-------------|--------|
| `Records` | `Zones` / `Domains` / `DnsRecords` | 后端返回统一字段名 |
| `Status` | `ActiveStatus` / `DomainStatus` | 后端映射为 `Status` |
| `Domains` | `AccelerationDomains` | 后端提取并映射 |
| `Origin` | `OriginDetail.Origin` | 后端解包嵌套结构 |
| `TotalCount` + 分页数据 | `TotalCount` + 空数组 | 检查 Offset/Limit 参数 |

**时间格式（TEO API）：**
```javascript
// ✗ 不行：toISOString() = "2026-05-06T07:00:00.815Z"（有毫秒）
// ✗ 不行：toISOString().replace('Z','+08:00') = "2026-05-06T07:00:00.815+08:00"（有毫秒）
// ✓ 正确：
const fmt = (dt) => dt.toISOString().split('.')[0] + '+08:00';
// 结果："2026-05-06T07:00:00+08:00"
```

### Step 7: 冒烟测试

```bash
# === 1. 静态资源 ===
HTML=$(curl -sk https://<域名>)
JS_HASH=$(echo "$HTML" | grep -o 'index-[^.]*\.js' | head -1)
curl -sk -o /dev/null -w "%{http_code}" "https://<域名>/assets/$JS_HASH"  # 必须 200

# === 2. API 端点逐个验证 ===
for ep in /api/xxx/list /api/yyy/detail; do
  result=$(curl -sk "https://<域名>$ep")
  code=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code','?'))")
  echo "$ep → $code"
done

# === 3. Headless 浏览器渲染测试 ===
node -e "
const {chromium} = require('playwright');
(async () => {
  const browser = await chromium.launch({headless:true});
  const pages = ['/', '/sites', '/domains', '/dns', '/cache', '/security'];
  for (const p of pages) {
    const pg = await browser.newPage();
    const errs = [];
    pg.on('pageerror', e => errs.push(e.message));
    await pg.goto('https://<域名>' + p, {waitUntil:'networkidle',timeout:15000});
    await pg.waitForTimeout(2000);
    const ok = await pg.evaluate(() => document.getElementById('root').children.length > 0);
    console.log(p.padEnd(15), ok ? '✓' : '✗', errs.length ? 'ERR:'+errs.length : '');
    await pg.close();
  }
  await browser.close();
})();
"
```

### Step 8: CNB 仓库同步

```bash
# 克隆仓库
git clone https://cnb:$CNB_TOKEN@cnb.cool/<org>/<repo>.git /tmp/<repo>
cd /tmp/<repo>

# 从服务器拉取最新代码覆盖
SSH_CMD="ssh -i <KEY> -o StrictHostKeyChecking=no root@<IP>"
scp -r $SSH_CMD:/opt/<项目>/client/src/ client/src/
scp -r $SSH_CMD:/opt/<项目>/server/routes/ server/routes/

# 提交推送
git add -A
git commit -m "fix: <修改说明>"
git push
```

### Step 9: 清理

```bash
# 清理临时文件
rm -rf /tmp/<project>-* /tmp/test-page*.mjs

# 更新 MEMORY.md 中的部署信息
# 记录：域名、IP、端口、服务名、nginx 配置路径、EdgeOne ZoneId
```

## 关键提示词模板

| 场景 | 提示词 |
|------|--------|
| 部署新服务 | "把 `<项目>` 部署到 `<IP>`，用 `<子域名>.<域名>`，后端端口 `<端口>`" |
| 白屏排查 | "访问 `<域名>` 白屏，查看源码排查" |
| 数据不显示 | "前端没有显示真实数据，检查 API 字段映射" |
| 冒烟测试 | "对 `<域名>` 做完整冒烟测试，所有 API + 所有页面" |
| 更新仓库 | "把修改同步到 CNB `<org>/<repo>` 仓库" |
| CDN 缓存 | "刷新 EdgeOne CDN 缓存，`<域名>`" |

## 常见问题速查

| 问题 | 排查命令 | 根因 |
|------|---------|------|
| 白屏 | 浏览器 F12 看 Console | JS 运行时报错（图标/CSS/API） |
| 白屏 | `curl -s https://域名 \| grep index-` | CDN 缓存旧 HTML，JS hash 不匹配 |
| 白屏 | `curl -sk https://域名/api/xxx` | CDN 回源 HTTPS 失败，返回 HTML fallback |
| API 返回空数组 | `tccli teo <API> --ZoneId <id> --Limit 5` | 字段名不对（Domains vs AccelerationDomains） |
| 前端数据全 0 | 检查 API 返回的 key 名 | 前端取 `Records` 但后端返回 `Zones` |
| 构建失败 | `node -v` + `nvm use 22` | Vite 8 要求 Node >= 22 |
| API 时间参数错误 | `toISOString().split('.')[0] + '+08:00'` | TEO 不接受带毫秒的时间 |
| nginx 404 | 检查 `root` 和 `try_files` | 静态文件路径配置错误 |

## 实际案例

### case1: design.yunjinhong.cn（简单部署）
- 服务器：175.178.24.59 | 后端：uvicorn 8080 | Nginx: 80
- 无 CDN，无前端框架，单页 HTML

### case2: eo.yunjinhong.cn（全栈 + CDN + 冒烟）
- 服务器：175.178.24.59 | 后端：Express 3001 | 前端：React + Ant Design 6 + Vite 8
- EdgeOne CDN（ZoneId: zone-3pjwxe4xqjlo）| HTTPS: eofreecert
- CNB 仓库：cnbnn/edgeone-dashboard
- 踩坑：白屏（NetworkOutlined + 残留 CSS + CDN 回源协议）、API 字段映射、时间格式
- 冒烟结果：7/7 API ✓，8/8 页面 ✓，Dashboard 显示 站点2/域名15/DNS0/证书1

## 注意事项

- **每次构建后必须清 CDN 缓存**，否则用户看到的还是旧版
- **EdgeOne OriginProtocol 必须设 HTTP**（除非 nginx 配了 443）
- **腾讯云 SDK API 名称可能和文档不一致**，用 `Object.getOwnPropertyNames(Client.prototype)` 实查
- **Vite 8 强制 Node >= 22**，systemd 的 ExecStart 也要指向正确的 node 路径
- **前后端字段映射要在后端做**，不要在前端适配各种奇怪的 API 字段名
- **Playwright 冒烟测试是最可靠的验证手段**，比 curl 更接近真实用户体验
