# 交付包 · 部署与运维手册

> 项目：**要店帮 · 红包商城**（芋道商城多租户底座 + 店员积分返现 / 晒单核销 / 红包提现）
> 版本：V1.0（对应交付批次 20261104）
> 读者：**接手运维的工程师**（不需要是芋道 / Spring Boot 专家，但需要会 Linux、Docker、SQL）
> 关联文档：《交付包-运营SOP.md》《交付包-合规与资质清单.md》《腾讯云商用上线准备方案.md》

---

## 0. 阅读指引

| 你要做的事 | 直接跳到 |
|---|---|
| 拿到一台空服务器，从零把系统跑起来 | [第 4 章 首次部署全流程](#4-首次部署全流程) |
| 每天巡检、看日志、做备份 | [第 5 章 日常运维](#5-日常运维) |
| 用户报障了，要定位 | [第 7 章 故障排查手册](#7-故障排查手册) |
| 要换密钥 / 要评估密钥泄露风险 | [第 8 章 密钥管理规范](#8-密钥管理规范) |
| 要发一个新版本，出问题要退回去 | [第 9 章 升级与回滚](#9-升级与回滚) |
| 要知道有哪些现成脚本可以直接跑 | [第 10 章 命令速查](#10-附录命令速查) |

> ⚠️ 本手册中所有标注「**铁律**」的条目，都是本项目在真实环境中踩过坑总结出来的，**照做，不要绕过**；绕过的后果写在每条后面。标注「**以实际为准**」的数值，需要业务方或云厂商确认后填写，不要凭印象填。

---

## 1. 交付物清单与系统构成

### 1.1 交付文件

| 文件 | 路径 | 用途 | 备注 |
|---|---|---|---|
| 生产编排文件 | `_prod/deploy-prod/docker-compose.yml` | 四容器生产编排 | 密钥全部走 `${}` 环境变量，必填项用 `:?` 强制校验 |
| 环境变量模板 | `_prod/deploy-prod/.env.example` | 生产环境变量清单 | 复制为 `.env` 后填写，权限必须 600 |
| nginx 生产配置 | `_prod/deploy-prod/nginx/ydb-hb-prod.conf` | HTTPS + HSTS + 安全头 + ACME + 支付回调 + `/healthz` | 需按实际域名改 `server_name` 与证书路径 |
| CVM 初始化脚本 | `_prod/deploy-prod/scripts/init_cvm.sh` | 一键装 Docker/Compose/chrony/Swap/ufw/SSH 加固/certbot + 每日 03:17 备份 cron | 在全新 Ubuntu 22.04 上执行一次 |
| 运维巡检脚本 | `_prod/deploy-prod/scripts/ops-prod.sh` | `check` / `backup` / `clean` / `restore` 四个子命令 | 日常巡检主入口 |
| 租户初始化脚本 | `_prod/deploy-prod/scripts/init_tenant.sh` | 为新客户建租户 + 管理员 + 角色 + 菜单授权 + 费用池基线 | 每新增一个客户执行一次 |
| 演示数据清理 | `_prod/deploy-prod/scripts/clean_demo_data.sh` | 把演示数据清成可交付基线 | 支持 `--dry-run` / `--apply`，逻辑删除可回滚 |
| 人工打款台账迁移 | `_prod/sql/20261104_manual_payout.sql` | `clerk_withdraw` 表结构变更 + 对账视图 | 升级到本版本时必须执行一次 |
| 商用上线准备方案 | `腾讯云商用上线准备方案.md` | 资质清单、成本估算、安全加固总表 | 资质与成本内容以该文档为准，本手册不重复 |

### 1.2 系统构成与运行时容器

| 层 | 内容 |
|---|---|
| 技术底座 | 芋道商城 `ruoyi-vue-pro` 多租户版，Spring Boot 2.7 + JDK 8 + MyBatis-Plus + Vue3 / Element Plus |
| 定制模块 | `yudao-module-incentive`（店员激励）：任务中心 / 晒单核销 / 指定品种 / 统一钱包 / 费用池三账 / 红包提现 / 成长体系 / 对账中心 / 增长看板 |
| 数据库 | `ydb_hb_mall`，约 160+ 表 |
| 激励核心表（8 张） | `clerk_member`、`clerk_merchant_bind`、`clerk_store_bind`、`clerk_task`、`clerk_task_submission`、`clerk_wallet`、`clerk_wallet_flow`、`clerk_withdraw` |
| 四端 | 管理后台 `/`、店员端 H5 `/clerk/`、医药营销前台 `/pharma/`、商城 H5（开发端口 `8081`） |

| 容器名 | 镜像 | 宿主端口 | 说明 |
|---|---|---|---|
| `ydb-hb-server` | TCR `ydb-hb-server` | `127.0.0.1:48080` | 后端 Spring Boot。**只绑 127.0.0.1** |
| `ydb-hb-web` | `nginx:1.27-alpine` | `80:80`、`443:443` | 前端静态资源 + 反向代理 + 证书 |
| `ydb-hb-mysql` | `mysql:8.0` | `127.0.0.1:3306` | 数据库。**不对公网暴露** |
| `ydb-hb-redis` | `redis:6.2-alpine` | `127.0.0.1:6379` | 缓存 + 会话。**不对公网暴露** |

部署目录约定：`/opt/ydb-hb`，编排文件 `/opt/ydb-hb/docker-compose.yml`，nginx 配置 `/opt/ydb-hb/nginx/ydb-hb-prod.conf`。

---

## 2. 访问路径与请求头

### 2.1 URL 前缀映射

| 前端入口 | 访问路径 | 反代目标 | 后端接口前缀 |
|---|---|---|---|
| 管理后台 | `/` | 静态资源 | `/prod-api/**` → 后端 `/admin-api/**` |
| 店员端 H5 | `/clerk/` | 静态资源 | `/app-api/**`（店员端与公开 API 免登录） |
| 医药营销前台 | `/pharma/` | 静态资源 | `/app-api/**` + 部分 `/admin-api/incentive/pharma/**`（已在白名单放行） |
| 商城 H5 | 开发端口 `8081` | — | `/app-api/**` |

### 2.2 铁律：所有请求必须带 `tenant-id` 请求头

芋道框架的租户隔离是**强制**的：登录态中的租户与请求头 `tenant-id` 不一致，**直接返回 403**，不做兜底。平台超管用 `tenant-id: 1`，客户用自己租户的 `tenant_id`。

排障提示：凡是「接口 403 / 数据查不出来 / 串租户」，第一件事是检查请求头 `tenant-id` 有没有传、传的对不对。

### 2.3 域名绑定

`system_tenant.websites` 字段用于域名绑定，实现「多客户独立域名」。客户在自己的域名下访问时，后端按域名反查租户。

---

## 3. 环境要求与腾讯云资源清单

### 3.1 服务器规格建议

| 项 | 建议 | 理由 |
|---|---|---|
| 形态 | CVM（推荐）或轻量应用服务器 | 选型对比见《腾讯云商用上线准备方案.md》第 4.1 节 |
| 规格 | 生产 **2 核 4G 起**，推荐 4 核 8G | 4 核 7G 机器构建期 load 会飙到 38 导致整机重启 |
| 系统盘 | ≥ 100G SSD | 数据库 + 镜像 + 日志 + 备份 |
| 系统 | Ubuntu 22.04 LTS | `init_cvm.sh` 按此编写 |
| 架构 | x86_64 | 避免 ARM 镜像兼容问题 |

> 具体机型、单价、一次性采购清单见《腾讯云商用上线准备方案.md》第 4 章，本文不重复。

### 3.2 安全组端口表

| 端口 | 来源 | 用途 | 是否必需 |
|---|---|---|---|
| 22 | 运维办公 IP 段（白名单） | SSH | 必需 |
| 80 | `0.0.0.0/0` | HTTP，ACME 证书校验 + 301 跳 HTTPS | 必需 |
| 443 | `0.0.0.0/0` | HTTPS 主站 | 必需 |
| 3306 / 6379 / 48080 | — | MySQL / Redis / 后端 | **禁止开放**（容器只绑 127.0.0.1） |

> 主机层面由 `init_cvm.sh` 用 `ufw` 二次收紧：只放行 22/80/443。
> **铁律**：后端 48080 必须绑 `127.0.0.1`。绑 `0.0.0.0` 会让 API 绕过前端鉴权被直连暴露。

### 3.3 其他依赖资源

| 资源 | 用途 | 必填性 | 说明 |
|---|---|---|---|
| 腾讯云 TCR | 后端镜像仓库 | 必需 | 内网 Registry 生产不可达 |
| 腾讯云 COS | 晒单凭证图片存储（大数据量时） | 建议 | 详见方案第 4.5 节 |
| 腾讯云 OCR | 小票识别 | 可选 | 未开通时系统自动降级为人工审核，不阻断业务 |
| 腾讯云短信 | 通知 | 可选 | 接入方式见第 8 章密钥表与 7.5 排障 |
| 微信支付商户号 | 每客户一套 | 必需（商城收款） | 见第 8 章 |
| Let's Encrypt | HTTPS 证书 | 必需 | `certbot` 自动续期 |

---

## 4. 首次部署全流程

> 全流程预计耗时以实际为准。建议在**非业务高峰**执行，执行前确认已联系业务方。

### 步骤 0：准备清单

- [ ] 满足 3.1 规格的 CVM，已装好 SSH 密钥对
- [ ] 一个已备案、已解析到该 CVM 的域名
- [ ] 交付文件已上传到本地（`docker-compose.yml` / `.env.example` / `nginx/` / `scripts/` / `sql/`）
- [ ] 后端镜像已推到 TCR，或可访问的镜像地址
- [ ] 数据库初始化 SQL（芋道框架基础表 + 本项目增量表）
### 步骤 1：初始化 CVM

```bash
scp -i <你的密钥> _prod/deploy-prod/scripts/init_cvm.sh ubuntu@<公网IP>:~/
ssh -i <你的密钥> ubuntu@<公网IP> 'sudo bash init_cvm.sh'
```

**预期输出**：脚本逐段打印 `[1/8] 系统更新` … `[8/8] 备份定时任务`，最后提示部署目录 `/opt/ydb-hb` 已就绪。

**失败排查**：`Permission denied` → 查 `~/.ssh/authorized_keys`；`apt-get update` 404 → 换腾讯云内网源；Docker 安装失败 → 查镜像加速配置；`ufw` 启用后 SSH 断开 → 提前 `sudo ufw allow 22/tcp`。

### 步骤 2：上传交付文件

```bash
# 本地执行
cd <交付包根目录>
scp -r _prod/deploy-prod/* ubuntu@<公网IP>:/tmp/ydb-hb/

# 远端归位
ssh -i <你的密钥> ubuntu@<公网IP> <<'EOF'
sudo mkdir -p /opt/ydb-hb/nginx /opt/ydb-hb/scripts /opt/ydb-hb/sql /opt/ydb-hb/frontend
sudo cp -r /tmp/ydb-hb/nginx /tmp/ydb-hb/scripts /tmp/ydb-hb/sql /opt/ydb-hb/
sudo cp /tmp/ydb-hb/docker-compose.yml /opt/ydb-hb/
sudo chown -R ubuntu:ubuntu /opt/ydb-hb
EOF
```

### 步骤 3：生成并填写 `.env`

```bash
cd /opt/ydb-hb
cp .env.example .env
chmod 600 .env          # 铁律：含全部密钥，权限必须 600

# 生成三段口令（生成后立刻写入 .env）
openssl rand -base64 24 | tr -d '/+=' | head -c 24   # MYSQL_ROOT_PASSWORD
openssl rand -base64 24 | tr -d '/+=' | head -c 24   # REDIS_PASSWORD
openssl rand -base64 16 | tr -d '/+=' | head -c 16   # MYBATIS_ENCRYPTOR_PASSWORD（必须 16 位）

# 自检：无输出即正确（所有必填项都已被填上）
grep -E '^[A-Z_]+=$' .env
```

其余项按 `.env.example` 内注释填写。⚠️ `MYBATIS_ENCRYPTOR_PASSWORD` 一旦有加密数据落库就**不可更换**，生成后必须立即备份（详见第 8 章）。

### 步骤 4：初始化数据库

把芋道框架基础表 + 本项目全部增量 SQL 放进 `/opt/ydb-hb/sql/`（compose 已把该目录挂到 `docker-entrypoint-initdb.d`，**仅首次启动 MySQL 时自动执行**）。已初始化过的库需手工执行增量 SQL：

```bash
cd /opt/ydb-hb
docker compose --env-file .env exec -T mysql mysql -uroot -p"$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)" ydb_hb_mall < sql/20261104_manual_payout.sql
```

**预期输出**：无报错；随后校验 `SHOW COLUMNS FROM clerk_withdraw LIKE 'payout_mode';` 与 `SHOW FULL TABLES LIKE 'v_clerk_withdraw_reconcile';`，两条都应有结果，缺一说明迁移没跑成功。

### 步骤 5：改域名并申请 HTTPS 证书

```bash
cd /opt/ydb-hb
sudo sed -i 's/mall.example.com/你的域名/g' nginx/ydb-hb-prod.conf
grep -n 'server_name\|ssl_certificate' nginx/ydb-hb-prod.conf

sudo certbot certonly --webroot -w /opt/ydb-hb/certbot/www -d 你的域名
```

**预期输出**：`Successfully received certificate.`　`init_cvm.sh` 已配置 certbot 的 webroot 续期钩子与定时任务。签发前需确保 80 端口对外可达、域名已解析到本机。

### 步骤 6：拉起服务

```bash
cd /opt/ydb-hb
docker compose --env-file .env up -d
docker compose --env-file .env ps
```

**预期输出**：四个容器均为 `Up` / `healthy`。

**失败排查**：compose 报 `variable is not set` → `.env` 有必填项为空，看报错里的变量名；`ydb-hb-mysql` 一直 restarting → 数据卷已存在但初始化脚本没跑，回到步骤 4；`Access denied for user 'root'` → MySQL 刚启动 socket 未就绪，用 `-h 127.0.0.1`；`ydb-hb-web` 502 → server 未 ready（Spring Boot 启动需 1–2 分钟），看 `docker compose logs -f server`。

### 步骤 7：部署前端产物

**铁律：不能 `rm -rf dist`。** `dist` 是 bind mount 的挂载点，`rm -rf` 会连挂载点 inode 一起删掉，nginx `try_files` 陷入循环 → 持续 500。

```bash
cd /opt/ydb-hb/frontend/admin/dist
find . -mindepth 1 -delete          # 保留 dist 目录本身及其 inode
cp -r /tmp/admin-dist/. .           # 复制新产物
cd /opt/ydb-hb
docker compose --env-file .env up -d --force-recreate web
```

其他三端（`mall-h5/dist`、`clerk`、`pharma`）同理，各自替换目录路径。**预期输出**：`ydb-hb-web` 重新创建后状态为 `Up`。

### 步骤 8：首次访问验证

```bash
curl -s https://你的域名/healthz          # 期望：ok

for p in / /clerk/ /pharma/ ; do
  echo -n "$p → "; curl -s -o /dev/null -w '%{http_code}\n' "https://你的域名$p"
done

# 接口探活（必须带 tenant-id）
curl -s -H 'tenant-id: 1' https://你的域名/prod-api/system/auth/login -X POST \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"你的密码","captchaVerification":""}'
```

**预期输出**：HTTP 200；登录接口返回 `{"code":0,"data":{...token...}}`。

**失败排查**：`/healthz` 200 但页面 404 → 前端 `dist` 没部署或 nginx `root`/`alias` 路径不对；页面 500 + nginx 报 `rewrite or internal redirection cycle` → 用了 `rm -rf dist`，按步骤 7 重来；接口 404 → 前缀用错了（后台 `/prod-api/**`、店员端 `/app-api/**`）；接口 403 → 缺 `tenant-id` 或与登录态租户不一致。

### 步骤 9：初始化客户租户并录入商户号

每个客户执行一次：

```bash
cd /opt/ydb-hb
bash scripts/init_tenant.sh 深圳示例大药房 demo456 'Xxx@123456' 111 365
```

**预期输出**：脚本回显新建的租户 ID，并提示管理员账号与费用池状态。租户 ID 由数据库自增生成，务必记录——商户号配置、费用池、菜单授权全部按这个 ID 走。

商户号在后台「支付管理 → 商户号配置」录入（接口 `POST /prod-api/pay/merchant-config/save`），详见第 8 章。

### 步骤 10：交付验收

```bash
cd /opt/ydb-hb
bash scripts/ops-prod.sh check
```

**预期输出**：容器状态、入口健康、业务健康（待审晒单 / 待打款 / 未对账 / 租户数）四个板块全部正常。

同时跑一遍自动化回归（17 套件 / 466 用例）：`node test_runner.js --target http://<host>:8082`
**预期输出**：466/466 全绿。任一条失败，先按第 7 章定位，不要带病上线。

---

## 5. 日常运维

### 5.1 巡检、备份与恢复

```bash
cd /opt/ydb-hb

bash scripts/ops-prod.sh check      # 巡检：容器/入口/业务/资源四板块，建议每日一次
bash scripts/ops-prod.sh backup     # 立即备份数据库
ls -lh /opt/ydb-hb/backup/db/       # 查看备份
bash scripts/ops-prod.sh clean      # 清理悬空镜像与旧日志
crontab -l                          # 查看 init_cvm.sh 装的每日 03:17 备份 cron

# 恢复（危险，需二次确认）
bash scripts/ops-prod.sh restore backup/db/ydb_hb_mall_20261104_031700.sql.gz
```

**铁律（恢复）**：恢复会覆盖现有数据，执行前必须 ① 先做一次当前备份；② 确认目标备份完整（`gzip -t` 通过）；③ 通知业务方并停写；④ 恢复后立刻跑 `ops-prod.sh check` 验证。

手动清理时注意：**不要删 `mysql-data` / `redis-data` 数据卷**，那是生产数据。

### 5.2 查看日志

```bash
cd /opt/ydb-hb
tail -f logs/spring.log                                        # 后端日志（宿主机 ./logs 挂载）
docker compose --env-file .env logs -f server                   # 容器标准输出
docker compose --env-file .env exec web tail -f /var/log/nginx/error.log   # nginx 错误日志
docker compose --env-file .env logs --since 10m server | grep -iE 'ERROR|Exception'
```

### 5.3 扩容

| 场景 | 操作 | 注意 |
|---|---|---|
| 加 CPU / 内存 | 腾讯云控制台调整实例规格，重启实例 | 重启期间服务中断，需提前通知业务方 |
| 加磁盘 | 控制台扩容云盘并 `resize2fs` | 备份需确认在新盘上 |
| 升 Java 堆 | 改 `.env` 的 `JAVA_XMS` / `JAVA_XMX` | 改完 `docker compose up -d server`；堆不应超过物理内存 70% |
| MySQL 独立 | 从 compose 迁出到云数据库 MySQL | 需同步改 `docker-compose.yml` 的 `server.ARGS` 数据源 URL；**必须先备份并做连通性验证** |
| Redis 独立 | 同上 | 注意 `REDIS_PASSWORD` 与 `--requirepass` 保持一致 |

### 5.4 证书续期

`init_cvm.sh` 已配置 certbot 续期钩子与定时任务。手动验证：

```bash
sudo certbot renew --dry-run     # 预期：Congratulations, all simulated renewals succeeded.
openssl x509 -enddate -noout -in /opt/ydb-hb/certbot/conf/live/你的域名/fullchain.pem
```

剩余天数少于 15 天时，排查续期钩子是否生效（`/etc/letsencrypt/renewal-hooks/deploy/` 下应有脚本）。

### 5.5 演示数据清理（上线前必做一次）

```bash
cd /opt/ydb-hb
bash scripts/clean_demo_data.sh --dry-run    # 先看清单
bash scripts/clean_demo_data.sh --apply      # 确认后执行
```

清理采用**逻辑删除**（`deleted=b'1'`），保留可审计痕迹，符合合规要求；如需回滚可在数据库中改回。

---

## 6. 监控与告警建议

### 6.1 必须看的指标

| 指标 | 数据来源 | 建议阈值 | 不达标时 |
|---|---|---|---|
| 容器存活状态 | `docker compose ps` | 任一非 `Up` | 立即告警 + 查 `docker compose logs` |
| `/healthz` HTTP 状态码 | curl 定时探测 | 非 200 | 立即告警 |
| 后端 5xx 比例 | nginx access log 统计 | > 1% | 告警，查 server 日志 |
| 后端响应时间 P95 | nginx access log | 以实际为准 | 告警，查慢 SQL / GC |
| 待审核晒单积压 | `clerk_task_submission` where `review_status=0` | 以实际为准 | 通知商家处理 |
| 待打款提现积压 | `clerk_withdraw` where `status in (1,2)` | 以实际为准 | 通知商家处理 |
| 未对账打款 | `clerk_withdraw` where `status=3 and reconcile_status=0` | > 0 | 财务介入 |
| 费用池余额 | `incentive/fund/summary` 接口 | 低于安全水位（以实际为准） | 通知商家充值 |
| 磁盘 / 内存使用率 | `df -h` / `free -m` | > 80% / > 85% | 清理日志 / 调整堆或扩配 |
| 容器重启次数 | `docker inspect -f '{{.RestartCount}}'` | 持续增长 | 查 OOM / 崩溃日志 |
| 证书剩余天数 | `openssl x509 -enddate` | < 15 天 | 手动续期 |

### 6.2 告警触发的排查顺序

容器挂了 → `docker compose ps` → `docker compose logs --tail 200 <服务>`；没挂但接口 500 → nginx error.log → server 日志 → 慢 SQL / GC；接口 403 → `tenant-id` 请求头 → 登录态租户；磁盘满 → `docker system df` → `ops-prod.sh clean` → 扩盘。

### 6.3 建议接入的腾讯云能力

云监控（容器/主机指标）、CLB 健康检查（可指向 `/healthz`）、CLB 访问日志、COS 备份、腾讯云短信/企业微信告警通知。具体配置见《腾讯云商用上线准备方案.md》第 10 章。

---

## 7. 故障排查手册

> 排障通用第一步：`cd /opt/ydb-hb && bash scripts/ops-prod.sh check`，先看四个板块的输出。

### 7.1 页面白屏

| 现象 | 原因 | 处理 |
|---|---|---|
| 打开后台一片空白，控制台报 `Cannot read properties of null (reading 'nextSibling')` | 前端产物结构错乱，通常是删了 `vite-plugin-top-level-await` 插件 | 恢复该插件并用正确的 `@swc/core` 版本重新构建；重新部署 dist |
| 同上，且构建时报 `missing field 'type'` | `@swc/core` 版本冲突 | **必须锁 `@swc/core@1.15.47`**。`1.16.2` 与 `vite-plugin-top-level-await@1.6.0` 冲突 |
| 白屏且刷新后路由丢失 | 芋道动态路由在登录后注入内存，整页刷新会丢路由表 | 引导用户**用菜单切换**，不要整页刷新 |
| 白屏但网络面板 404 某个 JS chunk | `index.html` 被缓存了旧版本 | 检查 nginx `index.html` 的 `Cache-Control` 是否为 `no-cache, no-store, must-revalidate` |
| 页面被初始化两次 | 有人改写了入口 `index.html` 的 `<script src>` | 还原 `index.html`；**入口文件的 script src 绝对不能改写** |

> **铁律（构建）**：构建必须加 `nice -n 15`。4 核 7G 机器不加限流，构建期 load 会飙到 38，导致整机重启。

### 7.2 菜单点击 404 / 登录失败

| 现象 | 原因 | 处理 |
|---|---|---|
| 菜单能显示，点击进空白/404 | 前端路由表未注入（芋道动态路由） | 不用整页刷新，改用菜单切换；若持续出现，看前端控制台路由注入报错 |
| 点击 404 且后台返回 404 | 菜单权限里的组件路径与实际目录不一致 | 检查 `system_menu.component` 与 `frontend/*/src/views` 目录对应关系 |
| 菜单项不显示 | 该角色未授权该菜单 | 用 `init_tenant.sh` 的角色菜单授权逻辑补授权，或后台手工授权 |
| 提示验证码相关错误 | 图形验证码开关或 Redis 异常 | `redis-cli -a "$REDIS_PASSWORD" ping`；确认 Redis 密码与 `.env` 一致 |
| 登录 401 | token 过期或未携带 token | 前端 token 存储问题；后端查 `security` 相关日志 |
| 登录 403 且提示租户相关 | `tenant-id` 缺失或与登录态不一致 | 补请求头；平台超管用 `tenant-id: 1` |
| 登录后立刻掉线 | Redis 未持久化 / 连接数打满 | 检查 Redis 容器状态与 `maxmemory` 配置 |

> 回归套件 `M6 菜单点击404回归` 与 `T1 动态路由刷新白屏回归` 专门覆盖前两类问题，回归全绿即可排除代码问题。

### 7.3 OCR 不生效

| 现象 | 原因 | 处理 |
|---|---|---|
| 晒单提交后一直停在待人工审核，看不到 OCR 结果 | OCR 未启用或密钥不对 | 检查 `.env` 的 `TENCENT_OCR_ENABLED=true`、`TENCENT_OCR_SECRET_ID/KEY`；重启 `server` 容器 |
| OCR 接口报错 401/403 | SecretId/Key 错误，或 CAM 权限不足 | 到腾讯云 OCR 控制台核对密钥；确认子账号有 `ocr:GeneralAccurateOCR` 权限 |
| OCR 超时 | 网络或区域不对 | 检查 `TENCENT_OCR_REGION`（默认 `ap-guangzhou`）与出网 |
| OCR 完全不调用，但业务不阻断 | 这是**设计的降级行为** | 未启用 OCR 时自动转人工审核，不阻断提交。是否开通见第 8 章 |

> OCR 链路是异步的：`OcrRecognizeEvent` / `OcrRecognizeListener` 在**事务提交后**才触发。提交后立刻查结果可能查不到，等几秒再试。
> OCR 识别置信度 `< 70` 会强制转人工，这是业务规则不是故障。

### 7.4 提现打不了款

| 现象 | 原因 | 处理 |
|---|---|---|
| 点「发起打款」报错 `该提现单不是人工打款模式` | `payout_mode` 不是 1 | 查 `clerk_withdraw.payout_mode`；本版本人工打款应为 `1` |
| 报 `该提现单已打款成功，请勿重复打款` | 已是 `status=3` | 属于保护，核对是否真的已打款，不要重复打 |
| 报 `打款凭证号不能为空` | 回填凭证时 voucher 为空 | 补银行流水号 / 微信交易单号 |
| 报 `打款渠道不合法` | 渠道值不在枚举内 | 只允许 `bank_transfer` / `wx_transfer` / `cash` / `other` |
| 报 `对账状态不合法或该提现单尚未打款成功` | 对 `status != 3` 的单子做对账 | 先完成打款确认再对账 |
| 状态卡在「打款中」（status=2） | 转账已发起但未回填凭证 | 线下确认是否真的到账 → 回填凭证（`/confirm`）或标记失败（`/mark-failed`）后重试（`/retry`） |
| 唯一索引冲突 `uk_clerk_withdraw_id_payout` | 同一单同一凭证号重复提交 | 检查是否重复回填；确需覆盖先核对流水 |

> 错误码区间：`1_001_010_011` ~ `1_001_010_015`（打款凭证必填 / 渠道非法 / 非人工打款模式 / 重复打款 / 对账状态非法）。

### 7.5 短信发不出

| 现象 | 原因 | 处理 |
|---|---|---|
| 发送报鉴权失败 | `api_key` 格式错误 | `system_sms_channel.code=TENCENT` 的 `api_key` 必须是「**SecretId 空格 SdkAppId**」，`api_secret` 填 SecretKey |
| 报签名不合法 | 短信签名未在腾讯云备案 | 控制台确认签名 `TENCENT_SMS_SIGNATURE` 状态为「已审核通过」 |
| 手机号收不到 | 模板 / 频控 | 查腾讯云短信控制台发送记录；注意频控 |
| 完全没调用 | 渠道未启用 | 后台「短信管理」确认渠道已启用并设为默认 |

### 7.6 支付回调不达

| 现象 | 原因 | 处理 |
|---|---|---|
| 微信支付一直「支付中」 | 回调没到后端 | 先确认回调地址与商户平台**完全一致**（含末尾斜杠）且为 **https** |
| 回调返回 404 | nginx 未配 `/pay/notify/` | 检查 `nginx/ydb-hb-prod.conf` 是否保留 `location /pay/notify/` 段 |
| 回调 502 | server 容器异常 | `docker compose logs -f server` |
| 回调验签失败 | APIv3 密钥不一致 | 核对 `apiV3Key`（32 位）与商户平台一致 |
| 回调时间戳过期 | 服务器时间漂移 | `chronyc tracking`；`init_cvm.sh` 已装 chrony |

> **铁律**：微信支付回调地址必须与商户平台配置**完全一致**，且必须是 https。

### 7.7 数据库连不上 / 容器反复重启

| 现象 | 原因 | 处理 |
|---|---|---|
| `Access denied for user 'root'@'localhost'` | MySQL 刚启动 socket 未就绪 | 用 `-h 127.0.0.1` 走 TCP |
| `Communications link failure` | 容器未起或网络不通 | `docker compose ps`；容器内用服务名 `ydb-hb-mysql` 连接，不用 `localhost` |
| `Unknown database 'ydb_hb_mall'` | 初始化 SQL 没执行 | 手工执行 `sql/` 下脚本（见步骤 4） |
| 连接数 Too many connections | 应用连接池过大 | compose 已设 `--max-connections=500`；同时调小应用侧连接池 |
| `OOMKilled=true`（见 `docker inspect -f '{{.State.OOMKilled}}'`） | 内存不足 | 调小 `JAVA_XMX` 或扩配；确认机器有 Swap（`init_cvm.sh` 已配） |
| 日志里 `CommandLineException: Unknown option` | `ARGS` 里有格式错误的 `--` 参数 | 核对 `docker-compose.yml` 的 `ARGS` 段落 |
| 启动即退出，日志报数据库错 | MySQL 未 ready | `depends_on` 已配 `service_healthy`，若仍失败查 MySQL 容器日志 |
| 反复重启且日志报 `Unknown column 'payout_mode'` | 增量 SQL 未执行 | 手工执行 `20261104_manual_payout.sql` |

### 7.8 磁盘满

```bash
df -h                                  # 定位是哪个挂载点
du -sh /opt/ydb-hb/* | sort -h        # 找出大目录
docker system df                       # 镜像/容器/卷占用
bash scripts/ops-prod.sh clean         # 清理悬空镜像与旧日志
```

要点：docker json-file 日志已配置轮转（server `100m×5`，mysql `50m×5`，redis `20m×3`，web `50m×5`），不要关闭轮转；`mysql-data` / `redis-data` 卷**不要删**；清理后仍不足 → 扩容云盘。

### 7.9 多租户数据串了

| 现象 | 原因 | 处理 |
|---|---|---|
| A 客户看到 B 客户数据 | 请求头 `tenant-id` 传错 | 核对前端请求拦截器注入的 `tenant-id` |
| 跨租户访问直接 403 | 框架强制隔离 | 正常行为，不要试图绕过；确认该用户属于正确租户 |
| 按域名访问定位不到租户 | `system_tenant.websites` 未配置 | 补该租户的域名配置 |

> 注意：`clerk_wallet`、`clerk_withdraw` 等表在 compose 的 `tenant.ignore-tables` 中（跨租户共享的业务表），因此这些表的租户隔离**由业务代码保证**，改代码时必须显式带租户条件，不要漏。

---

## 8. 密钥管理规范

### 8.1 密钥分布总表

| 密钥 | 存放位置 | 是否出响应 | 轮换难度 |
|---|---|---|---|
| `MYSQL_ROOT_PASSWORD` | `.env`（600） | 否 | 中（需改库密码 + 重启） |
| `REDIS_PASSWORD` | `.env`（600） | 否 | 中 |
| `MYBATIS_ENCRYPTOR_PASSWORD` | `.env`（600） | 否 | **不可轮换**（见 8.4） |
| `TENCENT_OCR_SECRET_ID` / `_KEY` | `.env`（600） | 否 | 低（腾讯云侧换密钥即可） |
| `TENCENT_SMS_*` | `.env`（600）+ `system_sms_channel` | 否 | 低 |
| 微信 `apiV3Key` / `privateKeyContent` / `publicKeyContent` / `certSerialNo` | 数据库 `pay_channel.config`（整段 AES 加密） | **否**（只返回 `hasApiV3Key` / `hasPrivateKey` 等布尔位） | 中（商户平台侧换） |

### 8.2 .env 管理规范

`.env` **禁止提交 Git**（`.gitignore` 已含 `.env`）；权限必须 `chmod 600 .env`、`chown ubuntu:ubuntu .env`；交付客户时**只给客户自己那套商户号的配置**，不给平台密钥；修改后需 `docker compose --env-file .env up -d` 才生效；建议另存一份离线加密备份（加密压缩包 + 口令分开保管）。

### 8.3 微信商户号凭据的加密机制

- `pay_app` / `pay_channel` 原生带 `tenant_id`，天然支持每客户独立商户号。
- `pay_channel.config` 整段经 `PayClientConfigEncryptTypeHandler` 做 **AES 加密**落库，密文带 `ENC1:` 前缀。
- **兼容历史明文**：读的时候遇到没有 `ENC1:` 前缀的值按明文处理，存量数据不会被读坏。
- 保存接口 `POST /prod-api/pay/merchant-config/save` 提交字段：`code` / `appIdWx` / `mchId` / `apiVersion` / `apiV3Key` / `certSerialNo` / `privateKeyContent` / `publicKeyContent` / `publicKeyId` / `feeRate` / `remark`。
- 查询接口 `GET /prod-api/pay/merchant-config/list` 与 `/get?code=` **不会**返回明文凭据，只返回布尔位。排查时**不要**指望在响应里看到密钥。
- 校验配置是否可用：`GET /prod-api/pay/merchant-config/ready?code=`。

> 私钥内容里的换行在提交时用 `\n` 表示，服务端会还原为真实换行。

### 8.4 铁律：`MYBATIS_ENCRYPTOR_PASSWORD` 不可更换

该密钥用于 MyBatis-Plus 字段级加密（含 `pay_channel.config`）。一旦有加密数据落库，**更换密钥会导致历史密文无法解密**——表现为：商户号配置读不出、支付调用报解密异常、数据"看起来丢了"。

- 初次部署生成后，**立即离线备份**（密码管理器 / 加密 U 盘 / 密封信封），并记录在交接单上。
- 若怀疑泄露：**不要直接改**。正确做法是写迁移脚本，用旧密钥解密、重新用新密钥加密，逐行回写。工期以实际为准，需先评估存量行数。
- 交接给客户运维时，此项必须写进交接单并让接收人签字确认已妥善保管。

> 配置项原文为 `mybatis-plus.encryptor.password`（注意 `encryptor` 拼写），现已由硬编码改为 `${MYBATIS_ENCRYPTOR_PASSWORD}`。

### 8.5 密钥轮换流程（以 OCR 为例）

```bash
cd /opt/ydb-hb
# 1) 腾讯云控制台生成新密钥，禁用旧密钥
# 2) 更新 .env
vi .env
# 3) 生效
docker compose --env-file .env up -d server
# 4) 验证
docker compose --env-file .env logs --since 2m server | grep -i ocr
```

---

## 9. 升级与回滚

### 9.1 升级流程

```
① 备份  →  ② 拉新镜像  →  ③ 执行增量 SQL  →  ④ 停服换镜像  →  ⑤ 起服务  →  ⑥ 验证  →  ⑦ 观察
```

```bash
cd /opt/ydb-hb
bash scripts/ops-prod.sh backup                                       # ①
docker compose --env-file .env pull server                             # ②（.env 的 SERVER_IMAGE 打新 tag）
docker compose --env-file .env exec -T mysql mysql -uroot -p"$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)" ydb_hb_mall < sql/新增脚本.sql   # ③
docker compose --env-file .env up -d --no-deps server                  # ④⑤
docker compose --env-file .env ps                                     # ⑥
curl -s https://你的域名/healthz
bash scripts/ops-prod.sh check
docker compose --env-file .env logs --since 30m server | grep -iE 'ERROR|Exception'   # ⑦ 观察 30 分钟
```

前端升级同样按第 5 节的 dist 替换方式（`find -mindepth 1 -delete` → 复制 → `--force-recreate web`）。

### 9.2 回滚流程

| 场景 | 回滚动作 |
|---|---|
| 仅后端镜像有问题 | 把 `.env` 的 `SERVER_IMAGE` 改回旧 tag → `docker compose up -d --no-deps server` |
| 仅前端产物有问题 | 用备份的 dist 目录按同样方式重新部署 → `--force-recreate web` |
| 增量 SQL 有问题 | 用步骤 ① 的备份恢复数据库（**必须先确认是否已有新数据写入**） |
| 数据已写坏且备份过期 | 从备份恢复到临时库，人工比对差异后再回写；不要直接整库覆盖 |

> **回滚铁律**：`git reset --hard` 之类的操作不适用于生产数据。回滚的最小单位是「镜像 tag + 备份文件」，两者都要在升级前确认存在。

### 9.3 升级注意事项

- 增量 SQL 必须在**测试环境先跑一遍**，并记录预计耗时与影响行数。
- 涉及 `clerk_withdraw` 等资金表的迁移，执行后跑一次 `ops-prod.sh check` 核对业务计数是否合理。
- 升级窗口避开周五与月末结算期。
- 升级期间保持至少一名运维和一名业务对接人在线。

---

## 10. 附录：命令速查

### 10.1 容器与日志

```bash
cd /opt/ydb-hb
COMPOSE="docker compose --env-file .env"

$COMPOSE ps                     # 状态
$COMPOSE up -d                  # 起服务
$COMPOSE restart server         # 重启单个服务
$COMPOSE stop                   # 停止
$COMPOSE down                   # 不加 -v：不要删数据卷
$COMPOSE logs -f server         # 跟踪日志
$COMPOSE logs --since 30m server
$COMPOSE exec server bash       # 进容器
$COMPOSE exec web nginx -t        # 校验 nginx 配置语法
$COMPOSE exec web nginx -s reload # 热加载配置
```

### 10.2 数据库与 Redis

```bash
# 取数据库口令
MYSQL_PWD=$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)

# 交互式登录（用 -h 127.0.0.1，避免 socket 未就绪误报）
$COMPOSE exec -T mysql mysql -h 127.0.0.1 -uroot -p"$MYSQL_PWD" ydb_hb_mall

# 单条 SQL
$COMPOSE exec -T mysql mysql -h 127.0.0.1 -uroot -p"$MYSQL_PWD" ydb_hb_mall -e "SELECT COUNT(*) FROM system_tenant;"

# 备份 / 导入
$COMPOSE exec -T mysql mysqldump -h 127.0.0.1 -uroot -p"$MYSQL_PWD" --single-transaction ydb_hb_mall | gzip > backup/db/manual_$(date +%Y%m%d_%H%M%S).sql.gz
gunzip -c backup/db/xxx.sql.gz | $COMPOSE exec -T mysql mysql -h 127.0.0.1 -uroot -p"$MYSQL_PWD" ydb_hb_mall

# Redis
$COMPOSE exec -T redis redis-cli -a "$REDIS_PASSWORD" ping
$COMPOSE exec -T redis redis-cli -a "$REDIS_PASSWORD" info memory | head -20
```

### 10.3 常用排查 SQL

```sql
-- 各租户与域名
SELECT id, name, websites FROM system_tenant WHERE deleted = b'0';
-- 待审核晒单
SELECT COUNT(*) FROM clerk_task_submission WHERE deleted = b'0' AND review_status = 0;
-- 待打款 / 未对账
SELECT status, COUNT(*) FROM clerk_withdraw WHERE deleted = b'0' GROUP BY status;
SELECT COUNT(*) FROM clerk_withdraw WHERE deleted = b'0' AND status = 3 AND reconcile_status = 0;
-- 对账视图
SELECT * FROM v_clerk_withdraw_reconcile ORDER BY withdrawId DESC LIMIT 50;
-- 费用池流水
SELECT * FROM mall_fund_flow ORDER BY id DESC LIMIT 20;
-- 商户号配置（只看非敏感位）
SELECT id, tenant_id, code, remark FROM pay_channel WHERE deleted = b'0';
```

### 10.4 前端构建与部署

```bash
# 构建（必须限流）
nice -n 15 npm run build:prod

# 部署（禁止 rm -rf dist）
cd /opt/ydb-hb/frontend/admin/dist
find . -mindepth 1 -delete
cp -r /tmp/admin-dist/. .
cd /opt/ydb-hb && docker compose --env-file .env up -d --force-recreate web
```

### 10.5 证书与网络

```bash
sudo certbot renew --dry-run
openssl x509 -enddate -noout -in /opt/ydb-hb/certbot/conf/live/你的域名/fullchain.pem
chronyc tracking
curl -s https://你的域名/healthz
```

### 10.8 关键 API 速查（排障用，全部需带 `tenant-id` 头）

| 用途 | 方法与路径 |
|---|---|
| 平台登录 | `POST /prod-api/system/auth/login` |
| 提现 | `GET /prod-api/incentive/withdraw/page`、`/summary`、`/ledger`（打款台账） |
| 提现审核 | `POST /prod-api/incentive/withdraw/audit` |
| 发起打款 | `POST /prod-api/incentive/withdraw/payout` |
| 回填打款凭证 | `POST /prod-api/incentive/withdraw/confirm`（`id` / `voucher` / `payoutChannel` / `remark`） |
| 标记失败 / 重试 | `POST /prod-api/incentive/withdraw/mark-failed`、`/retry` |
| 打款对账 | `POST /prod-api/incentive/withdraw/reconcile`（`id` / `reconcileStatus` / `remark`） |
| 费用池 | `POST /prod-api/incentive/fund/recharge`；`GET /summary`、`/flow-list`、`/payout-list` |
| 任务 | `GET /prod-api/incentive/task/list`；`POST /create`、`/submit`、`/review` |
| 商户号 | `POST /prod-api/pay/merchant-config/save`；`GET /list`、`/get?code=`、`/ready?code=`；`DELETE /delete?id=` |
| 店员端（免登录，公开） | `POST /app-api/incentive/clerk/submit`、`/withdraw-apply`；`GET /withdraw-list`、`/task-list`、`/wallet`、`/binds` |

### 10.9 自动化回归

```bash
cd <回归代码目录>
node test_runner.js --target http://<host>:8082
```

17 套件 / 466 用例，覆盖：M1 任务中心后端、M2 指定品种 + 统一钱包、M2b 费用池三账、TOB 定向寻址、UIP1B 商家端 4 页 + 店员端 H5、PHARMA 医药版商城、HOME 工作台首页、SLIM 后台精简 + 动销看板、M3 红包提现闭环、M5 打款对账中心、GROW 成长体系与排行榜、T1 动态路由刷新白屏回归、M6 菜单点击 404 回归、TASK 任务发布可见性、PAYOUT 人工打款台账、MCHPAY 每客户独立商户号、OCR 接入降级链路。

---

## 11. 铁律速查卡（打印贴在机房）

| # | 铁律 | 违反后果 |
|---|---|---|
| 1 | 前端部署**不得 `rm -rf dist`**，用 `find -mindepth 1 -delete` | 删掉挂载点 inode → nginx try_files 循环 500 |
| 2 | 后端 48080 **必须绑 127.0.0.1** | API 绕过前端鉴权被直连暴露 |
| 3 | MySQL / Redis **不对公网开放** | 数据泄露 |
| 4 | `@swc/core` **锁 1.15.47** | 构建报 `missing field 'type'`；删插件则产物错乱、刷新白屏 |
| 5 | 芋道动态路由**不能整页刷新** | 丢路由表 → 白屏 |
| 6 | 入口 `index.html` **不缓存、不改写 `<script src>`** | Vue 应用被初始化两次 → 白屏 |
| 7 | 构建**必须 `nice -n 15`** | 4 核 7G 机器 load 飙 38 → 整机重启 |
| 8 | `MYBATIS_ENCRYPTOR_PASSWORD` **不可更换** | 密文无法解密，数据"看起来丢了" |
| 9 | 支付回调地址**与商户平台完全一致且为 https** | 支付回调不达，订单卡「支付中」 |
| 10 | 微信「商家转账」需开通运营账户并配置请求来源 IP | 自动打款 API 调不通 |
| 11 | 所有请求**必须带 `tenant-id`** | 403 或串租户 |
| 12 | `.env` 权限 **600**，禁止入库 | 密钥泄露 |

> 下一步：运维侧就绪后，请商务与法务按《交付包-合规与资质清单.md》完成资质办理；业务侧按《交付包-运营SOP.md》开始日常运营。
