# 1dao 开发测试环境搭建工作总结

> **完整技术文档合集**
>
> **编写日期**: 2026-08-21
> **作者**: 技术文档组
> **文档版本**: 1.0
>
> 本文档为 1dao 开发测试环境搭建工作总结的完整合集，包含项目背景与环境搭建、软件栈安装与数据恢复、域名管理方案演变、换域名工具开发、经验教训与最佳实践、技术原理解析与故障排查共六个部分。

---

## 目录


- [第1卷:1dao 开发测试环境搭建工作总结(一):项目背景与虚拟机环境搭建](#chapter-1)
  - [第一章:项目背景与需求分析](#第一章-项目背景与需求分析)
  - [第二章:VMware 虚拟机创建](#第二章-VMware-虚拟机创建)
  - [第三章:Debian 系统配置](#第三章-Debian-系统配置)
  - [第四章:macOS 主机网络配置](#第四章-macOS-主机网络配置)
  - [第五章:经验教训](#第五章-经验教训)
  - [附录:关键命令速查](#附录-关键命令速查)
  - [总结](#总结)
- [第2卷:1dao 开发测试环境搭建工作总结(二):软件栈安装与数据恢复](#chapter-2)
  - [第一章 软件栈安装](#第一章-软件栈安装)
  - [第二章 备份文件解密](#第二章-备份文件解密)
  - [第三章 数据库恢复](#第三章-数据库恢复)
  - [第四章 站点代码恢复](#第四章-站点代码恢复)
  - [第五章 HTTPS证书恢复](#第五章-HTTPS证书恢复)
  - [第六章 站点测试与修复](#第六章-站点测试与修复)
  - [第七章 经验教训](#第七章-经验教训)
  - [附录:操作命令速查](#附录-操作命令速查)
  - [附录:版本信息表](#附录-版本信息表)
  - [附录:数据库用户与权限表](#附录-数据库用户与权限表)
  - [附录:站点与 PHP-FPM pool 映射](#附录-站点与-PHP-FPM-pool-映射)
  - [附录:备份文件清单与解密方式](#附录-备份文件清单与解密方式)
  - [附录:常见问题排查速查](#附录-常见问题排查速查)
  - [结语](#结语)
- [第3卷:1dao 开发测试环境搭建工作总结(三):域名管理方案演变与 dev-on/off 实现](#chapter-3)
  - [第一章:域名问题的本质](#第一章-域名问题的本质)
  - [第二章:域名方案演变历程(5 次迭代)](#第二章-域名方案演变历程-5-次迭代)
  - [第三章:HSTS 与 .dev TLD 的坑(深度解析)](#第三章-HSTS-与-dev-TLD-的坑-深度解析)
  - [第四章:dev-on / dev-off / which-env 命令实现](#第四章-dev-on-dev-off-which-env-命令实现)
  - [第五章:blog / shop 访问问题排查](#第五章-blog-shop-访问问题排查)
  - [第六章:浏览器数据隔离问题](#第六章-浏览器数据隔离问题)
  - [第七章:WiFi 断开时的域名访问问题](#第七章-WiFi-断开时的域名访问问题)
  - [第八章:经验教训](#第八章-经验教训)
  - [结语](#结语)
- [第4卷:1dao 开发测试环境搭建工作总结(四):换域名工具链开发](#chapter-4)
  - [第一章 域名耦合点调查](#第一章-域名耦合点调查)
  - [第二章 `change-domain.sh` 设计](#第二章-change-domain-sh-设计)
  - [第三章 `change-domain.sh` 实现与调试](#第三章-change-domain-sh-实现与调试)
  - [第四章 `switch-env.sh` 设计](#第四章-switch-env-sh-设计)
  - [第五章 `switch-env.sh` 实现与调试](#第五章-switch-env-sh-实现与调试)
  - [第六章 nginx dev 域名配置](#第六章-nginx-dev-域名配置)
  - [第七章 双模式架构总结](#第七章-双模式架构总结)
  - [第八章 经验教训](#第八章-经验教训)
  - [附录 A:`change-domain.sh` 关键函数完整代码](#附录-A-change-domain-sh-关键函数完整代码)
  - [附录 B:`switch-env.sh` 关键函数完整代码](#附录-B-switch-env-sh-关键函数完整代码)
  - [附录 C:常用命令速查](#附录-C-常用命令速查)
  - [附录 D:故障排查](#附录-D-故障排查)
  - [结语](#结语)
- [第5卷:1dao 开发测试环境搭建工作总结(五):经验教训与最佳实践](#chapter-5)
  - [第一章:环境隔离的教训](#第一章-环境隔离的教训)
  - [第二章:域名管理的教训](#第二章-域名管理的教训)
  - [第三章:浏览器行为的教训](#第三章-浏览器行为的教训)
  - [第四章:Shell 脚本编程的教训](#第四章-Shell-脚本编程的教训)
  - [第五章:数据库操作的教训](#第五章-数据库操作的教训)
  - [第六章:网络配置的教训](#第六章-网络配置的教训)
  - [第七章:版本控制与协作的教训](#第七章-版本控制与协作的教训)
  - [第八章:文档化的重要性](#第八章-文档化的重要性)
  - [第九章:工作方法论](#第九章-工作方法论)
  - [第十章:最佳实践速查表](#第十章-最佳实践速查表)
  - [结语](#结语)
- [第6卷:1dao 开发测试环境搭建工作总结(六):技术原理解析与故障排查手册](#chapter-6)
  - [第一章:DNS 解析机制深度解析](#第一章-DNS-解析机制深度解析)
  - [第二章:HSTS 与 HTTPS 深度解析](#第二章-HSTS-与-HTTPS-深度解析)
  - [第三章:Cookie 与浏览器存储深度解析](#第三章-Cookie-与浏览器存储深度解析)
  - [第四章:Shell 脚本技术原理](#第四章-Shell-脚本技术原理)
  - [第五章:数据库快照原理](#第五章-数据库快照原理)
  - [第六章:nginx 配置原理](#第六章-nginx-配置原理)
  - [第七章:故障排查手册](#第七章-故障排查手册)
  - [第八章:完整命令速查手册](#第八章-完整命令速查手册)
  - [第九章:项目完整时间线](#第九章-项目完整时间线)
  - [第十章:总结与展望](#第十章-总结与展望)


---


<div style="page-break-after: always;"></div>

<a id="chapter-1"></a>
# 第1卷: 1dao 开发测试环境搭建工作总结(一):项目背景与虚拟机环境搭建

> **卷首语**: 本卷聚焦于项目背景调研、需求分析、VMware 虚拟机创建、Debian 系统初始化配置以及 macOS 宿主机网络打通等基础性工作。

---




## 第一章:项目背景与需求分析

### 1.1 1dao.cc 站群概述

1dao.cc 是一家仪器公司的对外站群,以一个主域名 `1dao.cc` 为核心,通过子域名的方式横向扩展出多个功能各异的业务站点。整个站群并非单一应用,而是由若干独立部署、技术栈各异的子系统组合而成,这些子系统共享同一个根域,在对外呈现上构成一个统一的品牌矩阵。理解站群的组成结构,是后续做环境复刻、域名解耦、数据同步等所有工作的前提。

站群共包含以下六个核心站点,每个站点承担不同的业务职能,且各自基于不同的技术实现:

| 站点 | 域名 | 技术栈 | 业务职能 |
|---|---|---|---|
| 主站 | `1dao.cc` | 静态 HTML + 少量 PHP | 公司门户、产品展示、品牌入口 |
| 导航站 | `nav.1dao.cc` | 静态 HTML | 站群导航、流量分发 |
| 博客 | `blog.1dao.cc` | emlog(轻量博客系统) | 技术文章、行业资讯、SEO 内容 |
| 商城 | `shop.1dao.cc` | CRMEB(开源电商框架) | 仪器产品在线销售、订单管理 |
| 禅道 | `zentao.1dao.cc` | 禅道开源版(PHP) | 项目管理、Bug 跟踪、文档协作 |
| 部署系统 | `walle.1dao.cc` / `deploy.1dao.cc` | walle(部署工具) | 代码部署、发布管理、回滚 |

**主站(1dao.cc)** 是整个站群的门面,采用静态 HTML 加少量 PHP 脚本的形态,首页有中英文及简繁体多语言版本(`zh-hans`、`zh-hant`、`en` 目录),还包含一个 `counter.php` 计数器脚本和 `about-site`、`releases` 等子页面。主站的代码结构简单,但页面内硬编码了大量指向其他子站的链接,这些链接以绝对 URL(包含 `1dao.cc`)的形式存在,是后续域名替换工作需要重点处理的耦合点之一。

**导航站(nav.1dao.cc)** 是一个纯粹的静态页面,作用是为整个站群提供统一的入口和导航,把访客引导到各个子站。它的代码量小,但其中的导航链接同样以绝对 URL 形式指向各子站。

**博客(blog.1dao.cc)** 基于 emlog——一个国产的轻量级 PHP 博客系统。emlog 的特点是部署简单、资源占用低,适合中小型内容站点。博客站点的域名耦合点主要在两个地方:一是数据库 `emlog_options` 表里的 `blogurl` 配置项,它决定了系统生成链接时使用的基础域名;二是博客文章内容(`emlog_blog` 表)和评论(`emlog_comment` 表)中可能包含的绝对 URL。此外,emlog 的模板文件(`header.php`、`style.css`、`showcase.php`、`export_article.php`)中也硬编码了域名。

**商城(shop.1dao.cc)** 基于 CRMEB——一个基于 ThinkPHP 的开源电商系统,功能涵盖商品管理、订单、支付、DIY 装修、营销活动等。CRMEB 是整个站群中最复杂、数据量最大的子系统,它的域名耦合点也最多:数据库 `crmeb` 中的 `eb_system_config` 表存储了 `site_url` 等配置,商品图片、分类图片、DIY 页面、主题配置等表中都大量存储了以 `1dao.cc` 开头的绝对 URL。据初步统计,仅 `crmeb` 一个库就有约 300+ 处包含域名的记录。

**禅道(zentao.1dao.cc)** 是项目管理和 Bug 跟踪系统,基于禅道开源版。禅道的域名耦合相对较轻——运行时支持自动检测当前访问域名,但数据库中的项目名(`zt_project`)、任务描述(`zt_task`)、文档内容(`zt_doccontent`)和历史记录(`zt_history`)等表中仍可能存在硬编码的域名引用。

**部署系统(walle.1dao.cc / deploy.1dao.cc)** 基于 walle——一个支持 Git 的代码部署工具,提供 Web 界面管理发布流程。walle 的域名耦合点主要在数据库 `project` 表中存储的 `repo_url`(代码仓库地址)和 `release_to`(发布目标路径),以及 `record`、`task` 表中的部署记录。值得注意的是,walle 有两个访问域名(`walle.1dao.cc` 和 `deploy.1dao.cc`),在 Nginx 配置和 hosts 映射中都需要同时处理。

整个站群共涉及 4 个数据库(crmeb、emlog、walle、zentao),经排查,数据库中含 `1dao.cc` 字样的记录总数超过 400 处,分布在上述各库的多张表中。代码层面,排除 `vendor`、`runtime`、`cache` 等依赖和运行时目录后,仍有 24 个文件包含硬编码域名。这些数字直观地说明了"域名耦合"的严重程度,也是后续设计环境切换工具链(`switch-env`、`change-domain`)的核心动因。

站群的整体拓扑关系如下图所示:

```
                          ┌──────────────────────────────────────┐
                          │           1dao.cc 站群拓扑            │
                          └──────────────────────────────────────┘

                          ┌─────────────┐
                          │  1dao.cc    │ (主站, 静态HTML+PHP)
                          │  公司门户    │
                          └──────┬──────┘
                                 │
            ┌────────────────────┼────────────────────┐
            │                    │                    │
            ▼                    ▼                    ▼
   ┌────────────────┐   ┌────────────────┐   ┌────────────────┐
   │ nav.1dao.cc    │   │blog.1dao.cc    │   │shop.1dao.cc   │
   │ 导航站(静态)    │   │ 博客(emlog)    │   │ 商城(CRMEB)   │
   └────────────────┘   └────────────────┘   └────────────────┘
            │                    │                    │
            └────────────────────┼────────────────────┘
                                 │
                    ┌────────────┴────────────┐
                    ▼                         ▼
            ┌────────────────┐       ┌────────────────────┐
            │zentao.1dao.cc  │       │walle.1dao.cc      │
            │ 禅道(项目管理) │       │deploy.1dao.cc     │
            └────────────────┘       │ walle(部署系统)    │
                                      └────────────────────┘

  共享基础设施: Nginx + PHP-FPM + MariaDB + Redis
  域名耦合: 数据库 400+ 处, 代码 24 个文件
```

### 1.2 线上环境

线上服务器是一台部署在阿里云的云服务器,以下是经过确认的环境信息:

| 项目 | 线上值 |
|---|---|
| IP 地址 | `47.113.148.3` |
| 操作系统 | Debian(Linux) |
| Nginx 版本 | `1.26.3` |
| PHP 版本 | `8.4.24`(PHP-FPM 方式运行) |
| 数据库 | MariaDB `11.8` |
| 缓存 | Redis |
| HTTPS 证书 | Let's Encrypt(有效期至 2026-11-07) |

这是一个相对现代且统一的技术栈:Nginx 作为反向代理和静态文件服务,PHP 8.4 以 FPM(FastCGI Process Manager)方式运行处理动态请求,MariaDB 11.8 作为关系型数据库(MySQL 的社区分支,兼容 MySQL 协议),Redis 负责缓存和会话存储。Let's Encrypt 提供免费 HTTPS 证书。

值得注意的是几个版本选择的技术含义:

**Nginx 1.26.3** 是 Nginx stable 分支的一个较新版本(1.26 系列于 2024 年发布)。在测试环境复刻时,必须保持版本一致,因为不同版本的 Nginx 在配置指令(如 `http2` 指令的写法在 1.25+ 改为 `http2 on;`)、SSL 行为、默认值上可能存在差异,版本不一致会导致配置不兼容或行为偏差。

**PHP 8.4.24** 是 PHP 8.4 系列的较新 patch 版本。PHP 8.4 相比 8.3 引入了一些新特性(如属性钩子、不对称可见性等),同时对一些旧行为做了废弃和移除。emlog、CRMEB、禅道、walle 这些应用是否完全兼容 PHP 8.4 是一个需要验证的风险点——尤其是一些老旧应用可能使用了被移除的函数或特性。版本必须严格对齐线上,否则测试环境验证通过的功能在线上可能因 PHP 版本差异而行为不同。

**MariaDB 11.8** 是 MariaDB 的较新 LTS 版本。MariaDB 与 MySQL 在大多数场景下兼容,但在某些 SQL 语法、字符集排序规则、函数行为上存在细微差异。测试环境必须使用 MariaDB 而非 MySQL,以保证数据导入导出、SQL 执行行为的一致性。

**Redis** 用于缓存和会话。CRMEB 等应用依赖 Redis 存储缓存数据和 session,在环境切换时需要清空 Redis(`FLUSHALL`),否则旧的缓存(包含旧域名)会干扰新环境的表现。

这些版本信息构成了测试环境搭建的"基准线"——所有软件版本必须与线上对齐,这是"环境一致性"原则的基本要求。环境一致性保证了"在测试环境验证通过的行为,在线上也会一致"这一推论的成立前提。

### 1.3 接手需求

本次工作的起因是用户(1dao 站群的所有者)需要将站群的开发和维护工作交接给新的开发人员(本机操作者)。交接涉及两个层面的需求:

**第一,代码访问权**。线上代码需要纳入版本控制,以便新开发人员获取、修改、提交代码。经了解,代码托管在 Gitee(码云)上,组织名为 `huaqidao`,仓库为私有仓库。新开发人员需要被加入该组织并获得仓库的读写权限,或者通过部署公钥的方式获得拉取权限。

**第二,服务器访问权**。新开发人员需要 SSH 登录线上服务器,以便直接查看线上运行状态、排查问题、执行部署。SSH 访问通常通过公钥认证实现——将开发人员的公钥加入服务器对应用户的 `~/.ssh/authorized_keys` 文件。

这两项授权都需要线上服务器管理员(或用户本人)操作,新开发人员自身无法完成。授权流程的依赖性是后续方案转变的重要诱因之一。

接手一个在运行中的生产站群,与传统新项目开发有本质区别:它要求在不影响线上服务的前提下,建立一套可以安全试错、快速迭代的本地工作环境。这就引出了"测试环境"的必要性。

### 1.4 授权问题

授权环节涉及两个目标:服务器 SSH 和 Gitee 仓库。新开发人员的 SSH 密钥对标识为 `zxy@openclaw.ai`(通常反映生成密钥时的 `-C` 注释字段),公钥需要分发到两处。

**SSH 公钥加到服务器**:

公钥需要加入线上服务器(`47.113.148.3`)目标用户的 `~/.ssh/authorized_keys`。这一步必须由已有服务器访问权限的人(管理员或用户)完成,操作方式通常是:

```bash

## 第二章:VMware 虚拟机创建

### 2.1 VMware Fusion 环境检查

macOS 上的虚拟化方案选择:主流选项有 VMware Fusion、Parallels Desktop、Oracle VirtualBox。本机已安装 VMware Fusion,因此直接采用。VMware Fusion 的优势在于与 macOS 集成良好、性能优秀、命令行工具(`vmrun`、`vmware-vdiskmanager`)完善,便于脚本化操作;Parallels 商业收费且命令行支持较弱;VirtualBox 免费但性能和稳定性略逊。

在创建虚机前,首先检查 VMware Fusion 提供的命令行工具是否可用,这是后续脚本化创建虚机的前提:

```bash

## 第三章:Debian 系统配置

虚机安装完成后,得到的是一个最小化的 Debian 系统。接下来需要进行一系列配置,使其成为可用的开发测试环境的基础底座。这些配置分为网络、SSH、系统基础、用户权限、防火墙五个方面。

### 3.1 网络配置

虚机的网络配置是主机能否访问虚机的前提。本次采用 Bridge(桥接)模式(原因详见 4.4 节分析),虚机直接接入物理局域网,获得一个独立的局域网 IP。

**确认虚机 IP**:

虚机启动后,通过控制台或 DHCP 分配的 IP 登录,查看网络配置:

```bash

## 第四章:macOS 主机网络配置

虚机就绪后,还需要在 macOS 主机端做一系列配置,才能方便地通过域名访问虚机、通过 SSH 连接虚机。这一章聚焦于主机侧的配置,涉及域名解析、DNS 缓存、SSH 别名、网络模式理解和 WiFi 行为等。

### 4.1 /etc/hosts 机制详解

`/etc/hosts` 是操作系统级的域名解析文件,它允许在不依赖 DNS 服务器的情况下,手工指定域名到 IP 的映射。这是实现"用线上同域名访问测试环境"的核心机制——把 `1dao.cc` 在本机解析到虚机 IP,而非真实的线上 IP。

**域名解析优先级(nsswitch.conf)**:

操作系统解析域名时,会按 `/etc/nsswitch.conf`(macOS 上功能等价的是 `mDNSResponder`)指定的顺序查询各个来源。典型的 `hosts` 行:

```

## 第五章:经验教训

### 5.1 安全隔离原则

本次工作最根本的经验,是确立了"测试环境与线上完全隔离"的原则,并以此指导整个方案设计。

**为何必须隔离**:

生产环境承载着真实业务和用户数据,任何直接操作都蕴含风险。一个常见的误区是"我小心点就行"——但失误并非源于"不小心",而是源于:
- **认知盲区**:不熟悉系统时,无法预见某个操作的连锁影响
- **疲劳失误**:长时间工作中,概率性失误不可避免
- **环境差异**:测试环境与线上的细微差异,导致本应安全的操作在线上引发问题
- **数据污染**:在真实数据上做测试,测试数据与真实数据混杂,难以分离

隔离原则要求:测试环境在物理(不同机器/虚机)、网络(不同 IP/网段)、数据(独立数据库副本)三个层面都与生产环境分离。任何变更先在测试环境验证,确认无误后才通过受控流程部署到线上。

**隔离的边界**:隔离不等于"完全不接触线上"。合理的隔离是:
- 日常开发、调试、测试 → 在测试环境
- 部署经过验证的变更 → 通过部署系统(walle)接触线上,且部署过程是脚本化、可回滚的
- 查看线上状态 → 只读访问,不做修改

这次方案从"直连线上"转向"本机虚拟机",正是把"日常开发"从生产环境中剥离,放到隔离的测试环境。线上服务器只在"部署"这个受控环节被触及。

**隔离的成本与收益**:

隔离有成本:搭建测试环境需要时间和资源、保持环境与线上一致需要持续维护、数据同步需要流程。但这些成本远低于一次线上事故的损失——线上事故影响真实用户、损害业务、可能造成数据无法恢复。隔离是用确定的小成本,规避不确定的大损失。

### 5.2 虚拟机方案的优势

选择 VMware 虚拟机作为测试环境的载体,在实践中体现了多重优势:

**零风险调试**:

虚机是独立于主机的完整系统,在虚机内的任何操作——删除文件、修改配置、甚至搞坏系统——都不会影响主机和线上。这种"怎么折腾都行"的自由度,是调试和学习所需要的。可以大胆尝试可能有问题的操作,观察结果,而不必担心后果。

**快速回滚(快照)**:

VMware 支持虚机快照——某一时刻的完整状态(内存+磁盘)冻结。搞砸了可以一键恢复到快照点:

```bash

## 附录:关键命令速查

### A.1 VMware 虚机管理

```bash

## 总结

本文档系统记录了 2026-08-21 当天围绕 1dao 站群开发测试环境搭建所做的前端基础工作,覆盖从项目背景调研到虚拟机创建再到系统与网络配置的完整链条。

**核心成果**:

1. **明确了项目背景**:梳理了 1dao 站群的六个子站点(主站、导航、博客、商城、禅道、walle)、线上环境信息(IP、OS、软件版本)、接手需求和授权流程,理清了域名耦合的规模(数据库 400+ 处、代码 24 个文件)。

2. **完成了方案决策**:基于安全隔离、风险控制、授权依赖等考量,从"直连线上"转向"本机虚拟机"方案,确立了"测试环境与生产完全隔离"的根本原则。

3. **创建了 VMware 虚拟机**:检查了 VMware Fusion 工具链,配置了 2GB 内存/40GB 磁盘/Bridge 网络的虚机,选用 Debian 13 trixie netinst ISO,编写了 preseed 自动应答文件并用 `pycdlib` 重新打包 ISO(解决了 boot info table 的坑),实现了全自动安装。

4. **完成了 Debian 系统配置**:配置了静态网络(虚机 IP `192.168.207.130`)、SSH 密钥认证、清华 apt 源、Asia/Shanghai 时区、英文 locale、dev 用户 sudo 权限、ufw 防火墙(开放 22/80/443)。

5. **打通了 macOS 主机网络**:深入理解了 `/etc/hosts` 机制和 macOS DNS 缓存(mDNSResponder),编写了 `dev-on`/`dev-off`/`which-env` 主机脚本实现 hosts 切换,配置了 SSH config 别名 `1dao-dev`,分析了 Bridge 网络模式的选择理由,解决了 WiFi 断开时 hosts 不解析的降级方案(IP+端口直连)。

**关键技术决策的回顾**:

| 决策点 | 选择 | 核心理由 |
|---|---|---|
| 虚拟化方案 | VMware Fusion(完整虚机) | 零风险、快照回滚、环境一致性 |
| 操作系统 | Debian 13 trixie | 与线上 Debian 一致,软件版本够新 |
| 安装方式 | preseed 自动安装 | 可重复、无人值守、配置即代码 |
| 网络模式 | Bridge 桥接 | 虚机独立 IP,无端口冲突,多域名访问清晰 |
| 软件源 | 清华 TUNA 镜像 | 国内速度快,同步及时 |
| 访问模型 | hosts 域名映射 + SSH 别名 | 业务命名与系统命名分层,各得其所 |
| 降级方案 | IP+端口直连 | 应对 WiFi 断开时 hosts 不解析 |

**遗留与后续**:

本文档覆盖的是环境搭建的"基础底座"部分。后续工作(将在本系列总结的第二、第三部分展开)包括:
- 在虚机内安装 LNMP 软件栈(nginx 1.26.3、php 8.4.24、mariadb 11.8、redis),版本与线上一一对齐
- 从线上迁移站点代码和数据库到虚机,恢复 letsencrypt 证书
- 设计和实现域名切换工具链(`switch-env`、`change-domain`),处理 400+ 处域名耦合,实现 prod/dev 模式无损切换
- 编写完整的技术文档(`1dao-dev-env-tech-doc.md`),沉淀整套环境的架构和使用方法

本次工作的整体架构,用一张图总结:

```
┌──────────────────────────────────────────────────────────────────┐
│                     1dao 开发测试环境 - 基础底座                   │
└──────────────────────────────────────────────────────────────────┘

  ┌────────────────────────────────────────────────────────────┐
  │                    macOS 宿主机                             │
  │                                                            │
  │  ┌──────────────┐  ┌──────────────┐  ┌─────────────────┐ │
  │  │ /etc/hosts   │  │ ~/.ssh/config│  │ dev-on/off 脚本  │ │
  │  │ 1dao.cc→虚机 │  │ 1dao-dev别名 │  │ which-env       │ │
  │  └──────┬───────┘  └──────┬───────┘  └────────┬────────┘ │
  │         │                 │                   │           │
  │         └─────────────────┼───────────────────┘           │
  │                           │                               │
  └───────────────────────────┼───────────────────────────────┘
                              │ Bridge 桥接网络
                              │ 192.168.207.130
  ┌───────────────────────────┼───────────────────────────────┐
  │                    Debian 13 虚机                          │
  │                           │                               │
  │  ┌────────────────────────┴────────────────────────────┐ │
  │  │ 系统配置: 清华源 / Asia/Shanghai / en_US.UTF-8      │ │
  │  │ 用户: dev (sudo NOPASSWD) / SSH 密钥认证            │ │
  │  │ 防火墙: ufw (22/80/443)                             │ │
  │  └─────────────────────────────────────────────────────┘ │
  │                                                          │
  │  ┌─────────────────────────────────────────────────────┐ │
  │  │ 待安装 (后续工作):                                   │ │
  │  │ Nginx 1.26.3 + PHP-FPM 8.4 + MariaDB 11.8 + Redis    │ │
  │  │ 站点代码 (1dao.cc/nav/blog/shop/zentao/walle)        │ │
  │  │ 环境切换工具链 (switch-env / change-domain)          │ │
  │  └─────────────────────────────────────────────────────┘ │
  └──────────────────────────────────────────────────────────┘
```

本次工作为后续的软件栈部署、代码迁移、工具链开发奠定了坚实的基础。虚机已就绪、网络已打通、访问通道已建立,可以进入下一阶段的具体环境搭建工作。

---

*文档版本:1.0 | 编写日期:2026-08-21 | 本系列第一部分,共三部分*




<div style="page-break-after: always;"></div>

<a id="chapter-2"></a>
# 第2卷: 1dao 开发测试环境搭建工作总结(二):软件栈安装与数据恢复

> **卷首语**: 本卷承接第一部分，聚焦软件栈安装、备份解密、数据库恢复、站点代码恢复、HTTPS证书恢复以及站点测试修复的全过程，并总结经验教训。

---




## 第一章 软件栈安装

### 1.1 总体策略与版本对齐原则

在开始软件栈安装前,需要明确一个核心原则:**开发测试环境的软件版本必须与线上一致**。这并非出于强迫症,而是为了规避"在我机器上能跑"的经典问题。PHP 8.4 与 PHP 8.1 在某些扩展接口上存在差异,例如 `mb_*` 函数的默认编码探测行为、`preg_*` 函数对 Unicode 属性转义的支持;MySQL 8.4 与 MariaDB 11.8 在 SQL_MODE 默认值、`json_table` 函数行为、`RETURNING` 子句支持上也有细微差别。如果开发环境用了 PHP 8.1 而线上是 PHP 8.4,某些 deprecation warning 在开发期不会暴露,到上线时才暴雷。

版本对齐的另一个隐性收益是排障效率。线上某天出现一个诡异的现象(例如某个 SQL 在 MariaDB 11.8 下走了全表扫描),如果测试环境也是 11.8,可以直接复现、调试、验证修复方案,而不必凭空猜测"是不是版本差异导致的"。

因此本节所有安装都以"与线上版本号完全一致"为目标,具体版本如下表:

| 软件组件      | 线上版本     | 目标版本     | 安装方式          | 状态 |
|---------------|--------------|--------------|-------------------|------|
| nginx         | 1.26.3       | 1.26.3       | 官方 apt 源       | 对齐 |
| PHP           | 8.4.24       | 8.4.24       | sury.org 源       | 对齐 |
| MariaDB       | 11.8.x       | 11.8.x       | MariaDB 官方源    | 对齐 |
| Redis         | 7.x          | 7.x          | Debian 仓库       | 对齐 |
| certbot       | 4.0.0        | 4.0.0        | pip 安装          | 对齐 |
| goaccess      | 1.x          | 1.x          | Debian 仓库       | 对齐 |
| fail2ban      | 1.x          | 1.x          | Debian 仓库       | 对齐 |

**备选方案说明**:也可以全部使用 Debian 13 默认仓库的版本(更省事),但 Debian trixie 默认 PHP 是 8.4,nginx 是 1.26,MariaDB 是 11.8,基本能满足要求。不过为了精确控制小版本号(如 PHP 8.4.24 而非 8.4.20),还是选择官方源更稳妥。源码编译也是一个选项,但维护成本高(每次安全更新都要重新编译),且容易遗漏编译参数导致扩展不兼容,故不采用。

### 1.2 nginx 1.26.3 安装

#### 1.2.1 添加官方源

Debian 13 默认仓库的 nginx 版本可能与线上小版本号不完全一致。为精确对齐 1.26.3,添加 nginx 官方仓库:

```bash

## 第二章 备份文件解密

### 2.1 备份文件清单

从线上服务器拷贝过来的备份包共 5 个,采用两种加密方式:

| 文件名                          | 大小  | 加密方式                | 用途                |
|---------------------------------|-------|-------------------------|---------------------|
| db-all-20261107.sql.gz.gpg      | 2.2M  | GPG 对称加密 (AES256)   | 全库 SQL 备份       |
| sys-config-20261107.tar.gz.gpg   | 1.4M  | GPG 对称加密 (AES256)   | /etc 系统配置       |
| site-data-20261107.tar.gz.gpg   | 30M   | GPG 对称加密 (AES256)   | 各站代码与上传文件  |
| openclaw-20261107.tar.gz.gpg    | 39M   | GPG 对称加密 (AES256)   | OpenClaw 状态数据   |
| secrets-20261107.enc            | 4K    | openssl AES-256-CBC     | 凭据/证书私钥       |

**为什么用两种加密方式**:GPG 对称加密适合大文件批处理,口令管理简单;而 secrets 包用 openssl AES-256-CBC,是历史原因——早期 secrets 加密脚本是 openssl 写的,后来其他备份统一迁移到 GPG,但 secrets 因为密钥轮换不便,沿用 openssl。这种混合策略虽然不优雅,但运行稳定,改造成本高,所以保留。

**为什么 secrets 只有 4K**:secrets 包里只有文本凭据(数据库密码、API key、letsencrypt 账户私钥的 .pem),没有大文件。压缩后 4K 合理。

### 2.2 GPG 加密原理详解

GPG (GNU Privacy Guard) 是 OpenPGP 标准(RFC 4880)的实现,支持两种加密模式:

#### 2.2.1 对称加密 (Symmetric)

发送方和接收方共享同一个口令。加密时:
1. 用户输入口令(passphrase)
2. GPG 用 S2K (String-to-Key) 函数从口令派生出密钥,涉及哈希迭代(默认 SHA1,可配置 SHA256)和盐(salt),防止彩虹表攻击
3. 用派生的密钥加密文件内容(对称加密算法,默认 AES256)
4. 加密后的文件包含 S2K 参数(哈希算法、迭代次数、盐)和密文

解密时输入同一口令,GPG 用相同的 S2K 参数重新派生密钥,解密文件。适合单人备份场景,无需管理密钥对。

#### 2.2.2 非对称加密 (Asymmetric)

用接收方的公钥加密,只有持有私钥的人能解密。流程:
1. 接收方生成密钥对(公钥+私钥),公钥可公开分发
2. 发送方用接收方公钥加密文件
3. 加密时还可以签名(用发送方私钥),接收方用发送方公钥验签
4. 接收方收到后用私钥解密

适合多方通信,但需要事先分发公钥,且私钥本身又需要加密保护(陷入"鸡生蛋"问题)。

本环境的备份用的是对称加密,因为备份脚本自动化运行,非对称加密需要管理私钥文件,而私钥文件本身又需要加密保护。对称加密只需把口令存在安全的 secrets 包里(用另一种加密),口令本身用 openssl 加密,形成两层防护。

#### 2.2.3 AES256 加密算法

AES (Advanced Encryption Standard) 是 NIST 标准的对称加密算法,密钥长度 128/192/256 位。AES256 用 256 位密钥,14 轮加密,理论安全强度 2^128( birthday attack 下限),目前无已知实际攻击方法。

GPG 对称加密默认用 AES256,可以通过 `--cipher-algo` 修改。本环境用默认值。

#### 2.2.4 gpg 命令详解

GPG 对称加密解密的关键参数:

- `--batch`:批处理模式,不进入交互菜单
- `--passphrase <口令>`:直接传入口令(脚本自动化必需)
- `--pinentry-mode loopback`:绕过 pinentry 弹窗(否则在 SSH 非交互终端会卡住)
- `-d` 或 `--decrypt`:解密
- `-c` 或 `--symmetric`:对称加密
- `-o <文件>`:输出到指定文件
- `--yes`:覆盖已存在文件不询问

**坑点**:在某些 GPG 版本中,即使指定 `--passphrase`,如果没有 `--pinentry-mode loopback`,仍会尝试调用 pinentry 弹窗,在 SSH 会话中表现为"hang 住不动"。所以脚本中必须同时指定这两个参数。

### 2.3 GPG 解密执行

```bash

## 第三章 数据库恢复

### 3.1 创建数据库

db-all.sql.gz 是全库备份,包含 CRMEB、emlog、Walle、禅道四个应用的数据库。但备份文件里的 `CREATE DATABASE` 语句可能用了不存在的字符集(如 utf8 而非 utf8mb4),或者没有 `IF NOT EXISTS`,直接导入可能报错或字符集不对。因此先手动创建数据库,确保字符集正确:

```bash
mysql -uroot -p <<'EOF'
CREATE DATABASE IF NOT EXISTS crmeb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS emlog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS walle CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS zentao CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 验证
SHOW DATABASES;
EOF
```

**为什么每个应用独立数据库**:应用隔离,避免表名冲突(虽然不同应用表名一般不冲突,但万一);便于按应用备份恢复;权限隔离更精细。备选方案是共用一个数据库加表前缀,但 CRMEB、emlog、禅道的表前缀约定不一致(CRMEB 用 `eb_`,emlog 用 `emlog_`,禅道用 `zt_`,Walle 用 `walle_`),管理混乱,所以还是独立数据库更清晰。

**关于 COLLATE 的选择**:`utf8mb4_unicode_ci` 和 `utf8mb4_general_ci` 都是 utf8mb4 的排序规则:
- `general_ci`:旧版,基于 MySQL 自定义规则,速度快但中文排序不准
- `unicode_ci`:基于 Unicode 标准,中文排序更准确,但稍慢

线上用 `unicode_ci`,跟随。如果用 `general_ci`,中文按拼音排序会乱(如"啊"排在"波"后面),影响禅道的任务列表展示。

### 3.2 导入数据

#### 3.2.1 解压并导入

db-all.sql.gz 是 gzip 压缩的 SQL 文本,不需要先解压到磁盘,可以用 `zcat` 管道直接喂给 mysql,节省磁盘空间:

```bash
zcat /root/restore/db/db-all.sql.gz | mysql -uroot -p

## 第四章 站点代码恢复

### 4.1 解压 site-data 包

```bash

## 第五章 HTTPS证书恢复

### 5.1 letsencrypt 证书目录结构

letsencrypt(certbot)的证书在 `/etc/letsencrypt/` 下,结构如下:

```
/etc/letsencrypt/
├── accounts/              # ACME 账户信息
│   └── acme-v02.api.letsencrypt.org/
│       └── directory/
│           └── <account_id>/
│               └── regr.json, meta.json, private_key.json
├── archive/               # 历史证书版本(每次续期保留旧版)
│   ├── 1dao.cc/
│   │   ├── cert1.pem, chain1.pem, fullchain1.pem, privkey1.pem
│   │   └── cert2.pem, ... (续期后递增)
│   └── ...
├── live/                  # 当前生效证书(软链接到 archive)
│   ├── 1dao.cc/
│   │   ├── cert.pem -> ../../archive/1dao.cc/cert2.pem
│   │   ├── chain.pem -> ../../archive/1dao.cc/chain2.pem
│   │   ├── fullchain.pem -> ../../archive/1dao.cc/fullchain2.pem
│   │   └── privkey.pem -> ../../archive/1dao.cc/privkey2.pem
│   └── ...
├── renewal/               # 续期配置
│   └── 1dao.cc.conf
└── renewal-hooks/         # 续期后执行的钩子
```

**关于 archive 和 live 的设计**:certbot 每次续期都会生成新版本(cer1, cert2, ...),保留旧版本便于回滚(新证书有问题时可以改软链接指回旧版)。`live/` 目录下的文件都是软链接,指向 `archive/` 里的最新版本,这样 nginx 配置里写 `live/1dao.cc/fullchain.pem` 永远指向当前生效证书,续期后无需改 nginx 配置。

### 5.2 恢复的 9 个域名证书

| 域名             | 用途              | 证书目录                    |
|------------------|-------------------|-----------------------------|
| 1dao.cc          | 主站              | /etc/letsencrypt/live/1dao.cc |
| www.1dao.cc      | 主站 www          | /etc/letsencrypt/live/www.1dao.cc |
| openclaw.1dao.cc | OpenClaw 服务     | /etc/letsencrypt/live/openclaw.1dao.cc |
| nav.1dao.cc      | 导航站            | /etc/letsencrypt/live/nav.1dao.cc |
| blog.1dao.cc     | 博客              | /etc/letsencrypt/live/blog.1dao.cc |
| shop.1dao.cc     | CRMEB 商城        | /etc/letsencrypt/live/shop.1dao.cc |
| zentao.1dao.cc   | 禅道              | /etc/letsencrypt/live/zentao.1dao.cc |
| walle.1dao.cc    | Walle 部署系统    | /etc/letsencrypt/live/walle.1dao.cc |
| deploy.1dao.cc   | 部署系统备用域名  | /etc/letsencrypt/live/deploy.1dao.cc |

恢复方式是从 sys-config 备份解压:

```bash
sudo tar -xzf /root/restore/sys-config/sys-config.tar.gz -C / etc/letsencrypt
ls /etc/letsencrypt/live/

## 第六章 站点测试与修复

### 6.1 测试结果总览

所有站点恢复后,逐个 curl 测试:

```bash
for domain in 1dao.cc www.1dao.cc nav.1dao.cc blog.1dao.cc shop.1dao.cc zentao.1dao.cc walle.1dao.cc deploy.1dao.cc; do
    code=$(curl -sk -o /dev/null -w "%{http_code}" https://$domain/)
    echo "$domain: $code"
done
```

结果:

| 域名             | HTTP 状态 | 状态说明       |
|------------------|-----------|----------------|
| 1dao.cc          | 200       | ✅ 主站正常     |
| www.1dao.cc      | 200       | ✅ 正常         |
| nav.1dao.cc      | 200       | ✅ 导航站正常  |
| blog.1dao.cc     | 500       | ❌ 博客报错     |
| shop.1dao.cc     | 500       | ❌ 商城报错     |
| zentao.1dao.cc   | 200       | ✅ 禅道正常     |
| walle.1dao.cc    | 500       | ❌ 部署系统报错|
| deploy.1dao.cc   | 500       | ❌ 部署备用报错|

5 个核心站点恢复成功,3 个返回 500。下面逐个排查。

### 6.2 blog 修复(emlog)

#### 6.2.1 现象

```bash
curl -sk https://blog.1dao.cc/

## 第七章 经验教训

### 7.1 版本对齐的重要性

#### 7.1.1 案例

线上 PHP 8.4.24 修复了某个 redis 扩展的内存泄漏 bug,如果开发环境用 PHP 8.4.20,在长时间运行压测时内存占用持续上涨,误以为是应用代码问题,排查很久才发现是扩展 bug 已在 8.4.24 修复。

另一个案例:MariaDB 11.4 和 11.8 的默认 `sql_mode` 不同,11.8 增加了 `ERROR_FOR_DIVISION_BY_ZERO`(除零报错而非返回 NULL),如果测试环境用 11.4,某些 SQL(如 `SELECT 1/0`)在测试时不报错,上线后才报错,导致生产事故。

#### 7.1.2 教训

- 软件版本号必须精确到小版本(8.4.24,不是 8.4)
- 扩展版本也要对齐(php-redis 6.x vs 5.x 行为有差异)
- 数据库 SQL_MODE 默认值不同版本不同,会导致相同 SQL 行为不同
- 备份时记录"环境快照"(软件版本表),恢复时逐项核对
- 版本对齐不是一次性的,线上升级时测试环境必须同步升级

### 7.2 备份加密策略

#### 7.2.1 双重加密的合理性

GPG 对称加密 + openssl AES-256-CBC 双重策略,表面看复杂,实际是分层防护:
- GPG 层:保护大文件批处理(SQL、代码),口令易管理
- openssl 层:保护 secrets 包(凭据),密钥是二进制文件,暴力破解难度高于口令

即使 GPG 口令泄露,secrets 包仍受 openssl 密钥保护,攻击者拿不到凭据。这是"defense in depth"(纵深防御)原则的体现:假设某一层被攻破,下一层仍能提供保护。

#### 7.2.2 教训

- 备份脚本要记录加密方式(GPG 还是 openssl),恢复时不会混淆
- `.backup-enc-key` 文件必须单独保护,不进备份包
- GPG 解密时务必加 `--pinentry-mode loopback`,否则 SSH 卡死
- openssl 加密务必加 `-pbkdf2`,否则安全警告
- 口令和密钥要定期轮换(虽然成本高,但长期不轮换风险累积)

### 7.3 文件权限陷阱

#### 7.3.1 案例

emlog 500 错误,根因是 `content/cache/` 目录不存在。这反映两个问题:
1. 备份脚本遗漏空目录
2. 应用启动时没有"目录不存在则创建"的容错

另一个权限陷阱:Redis socket 访问。即使 Redis 启动了、socket 存在了、PHP redis 扩展装了,如果 www-data 不在 redis 组,PHP 仍连不上 Redis,报 "Connection refused"(实际是 permission denied,但 PHP 的错误信息不准确)。而且 usermod 加组后必须重启 PHP-FPM,否则不生效,这一点容易遗漏。

#### 7.3.2 教训

- 备份用 `tar` 时不要排除空目录,或者恢复后跑一遍"目录自检"脚本
- 部署文档要列出所有必需的可写目录(emlog 的 cache、CRMEB 的 runtime、Walle 的 storage)
- `chown -R www-data:www-data` 是恢复后的标配操作,不能省
- Redis socket 访问权限:www-data 必须在 redis 组里,否则 PHP 连不上 Redis
- 用户组变更后必须重启依赖该组的服务(PHP-FPM),否则不生效
- 权限错误的表现可能误导排查方向(如 "Connection refused" 实际是权限问题)

### 7.4 证书恢复的限制

#### 7.4.1 letsencrypt 在内网的困境

letsencrypt 的 HTTP-01 验证需要公网可达,内网虚机无法自动续期。这是 letsencrypt 设计哲学(自动化、短期证书)与内网环境(隔离、无公网)的根本冲突。

letsencrypt 设计 90 天有效期是为了:(1) 限制证书泄露的影响窗口;(2) 推动 ACME 自动化(短证书逼迫用户实现自动续期)。这种设计在公网环境很好,但在内网环境就成了障碍。

#### 7.4.2 教训

- 内网环境优先用自签证书或内部 CA,而非 letsencrypt
- 如果必须用 letsencrypt,选 DNS-01 验证(需 DNS API)
- 从线上拷贝证书是过渡方案,需建立流程(到期前 30 天提醒)
- certbot.timer 在内网要禁用,避免刷失败日志
- 证书恢复后要立即验证有效期,避免用着用着过期

### 7.5 多应用共存的资源隔离

#### 7.5.1 案例

如果不给 CRMEB 独立 PHP-FPM pool,商城压测时会耗尽共享 pool 的 max_children,导致博客、禅道也响应缓慢。在共享 pool 模式下,某个应用的一个慢请求(如 CRMEB 商品列表查询慢)会占用一个 worker 进程,如果多个慢请求堆积,worker 被耗尽,其他应用的请求排队等待,表现为全站变慢。

#### 7.5.2 教训

- 高负载应用独立 pool(独立 socket、独立进程数、独立慢日志)
- 数据库用户隔离(每应用一个低权用户)
- Redis 不同应用用不同 db(SELECT 0/1/2...),或者独立 Redis 实例
- 日志分应用存放,便于排查
- nginx 站点配置用 `include` 复用公共片段,减少重复
- 资源隔离的边界要清晰:进程隔离(PHP-FPM pool)、连接隔离(数据库用户)、缓存隔离(Redis db)、日志隔离(分文件)

### 7.6 灾难恢复演练的价值

本次从备份恢复到可用环境,本质是一次灾难恢复(disaster recovery)演练。过程中暴露的多个问题(目录缺失、组权限未生效、密码字段映射、证书续期无解),如果不演练,平时根本发现不了,真到线上数据丢失时才慌乱排查,代价巨大。

**演练揭示的盲点**:
1. 备份完整性未校验——空目录被遗漏,直到恢复时才发现
2. 加密元数据未文档化——secrets 用 openssl 还是 GPG,恢复时靠记忆
3. 配置依赖未梳理——Walle 密码字段分散,secrets 与配置文件映射关系不明
4. 续期流程未覆盖内网场景——letsencrypt 自动续期在内网失效

**改进方向**:
- 每月做一次"从备份恢复到可用"的全流程演练,计时并记录遇到的问题
- 备份脚本增加完整性自检(目录清单、SHA256、加密方式元数据文件)
- 维护一份"恢复 checklist",列出每个应用的必需目录、配置文件、密码字段映射
- 证书续期建立日历提醒(到期前 30 天),而非依赖 certbot.timer

**关于"备份≠可恢复"的认知**:有备份不等于能恢复。备份只是把数据存起来,恢复需要完整的上下文(软件版本、配置、密钥、依赖关系)。本环境花大量篇幅对齐版本、解密、配权限,正是为了让备份"可恢复"。一次成功的恢复演练,比一百次成功的备份更有价值——它证明备份真的能用。定期演练还能发现备份脚本自身的退化(如依赖路径变更导致脚本失效),形成闭环改进。

**架构总图**:

```
┌─────────────────────────────────────────────────────────────────┐
│                    Debian 13 trixie 虚机                         │
│                    192.168.207.130                               │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │                    nginx 1.26.3                           │   │
│  │              (443 HTTPS + 80 HTTP)                        │   │
│  └────────────┬─────────────────────────────────────────────┘   │
│               │                                                  │
│   ┌───────────┼───────────┬──────────┬──────────┐             │
│   ▼           ▼           ▼          ▼          ▼             │
│ 1dao.cc    blog        shop       zentao     walle            │
│ (静态)    (emlog)    (CRMEB)                                  │
│   │         │           │          │          │              │
│   │    ┌────┴────┐  ┌───┴────┐     │          │              │
│   │    │ www pool│  │crmeb   │     │          │              │
│   │    │ default │  │pool    │     │          │              │
│   │    │ sock    │  │sock    │     │          │              │
│   │    └────┬────┘  └───┬────┘     │          │              │
│   │         │           │          │          │              │
│   │         ▼           ▼          ▼          ▼              │
│   │   ┌──────────────────────────────────────────┐           │
│   │   │      MariaDB 11.8 (utf8mb4)              │           │
│   │   │  crmeb | emlog | walle | zentao          │           │
│   │   └──────────────────────────────────────────┘           │
│   │                                                            │
│   │   ┌──────────────────────────────────────────┐           │
│   │   │      Redis 7.x (unix socket)             │           │
│   │   │      /run/redis/redis-server.sock         │           │
│   │   └──────────────────────────────────────────┘           │
│   │                                                            │
│   │   ┌──────────────────────────────────────────┐           │
│   │   │  /etc/letsencrypt (9 域名, 2026-11-07)   │           │
│   │   └──────────────────────────────────────────┘           │
│   │                                                            │
│   │   ┌──────────────────────────────────────────┐           │
│   │   │  OpenClaw /root/.openclaw (root 运行)    │           │
│   │   └──────────────────────────────────────────┘           │
│                                                                │
│  fail2ban | goaccess | certbot(仅查看)                        │
└────────────────────────────────────────────────────────────────┘
```

---

## 附录:操作命令速查

### A.1 软件栈安装

```bash

## 附录:版本信息表

| 组件          | 版本      | 安装命令             | 配置文件路径                          |
|---------------|-----------|----------------------|---------------------------------------|
| nginx         | 1.26.3    | apt install nginx    | /etc/nginx/nginx.conf, /etc/nginx/conf.d/*.conf |
| PHP-FPM       | 8.4.24    | apt install php8.4-fpm | /etc/php/8.4/fpm/php.ini, /etc/php/8.4/fpm/pool.d/*.conf |
| MariaDB      | 11.8.x    | apt install mariadb-server | /etc/mysql/mariadb.conf.d/50-server.cnf |
| Redis        | 7.x       | apt install redis-server | /etc/redis/redis.conf |
| certbot      | 4.0.0     | pip install certbot==4.0.0 | /etc/letsencrypt/ |
| goaccess     | 1.x       | apt install goaccess | (无配置文件,命令行参数) |
| fail2ban     | 1.x       | apt install fail2ban | /etc/fail2ban/jail.local |

---

## 附录:数据库用户与权限表

| 数据库 | 用户    | 主机      | 权限              | 用途         |
|--------|---------|-----------|-------------------|--------------|
| crmeb  | crmeb   | localhost | ALL ON crmeb.*    | CRMEB 商城   |
| emlog  | emlog   | localhost | ALL ON emlog.*    | emlog 博客  |
| walle  | walle   | localhost | ALL ON walle.*    | Walle 部署  |
| zentao | zentao  | localhost | ALL ON zentao.*   | 禅道         |

---

## 附录:站点与 PHP-FPM pool 映射

| 站点              | root                    | PHP-FPM socket                  | pool 名 |
|-------------------|-------------------------|---------------------------------|---------|
| 1dao.cc           | /var/www/1dao.cc        | (无,静态 HTML)                | -       |
| www.1dao.cc       | /var/www/1dao.cc        | (无,静态 HTML)                | -       |
| nav.1dao.cc       | /var/www/nav            | (无,静态 HTML)                | -       |
| blog.1dao.cc      | /var/www/blog           | /run/php/php8.4-fpm.sock       | www     |
| shop.1dao.cc      | /var/www/crmeb/public   | /run/php/crmeb.sock            | crmeb   |
| zentao.1dao.cc    | /var/www/zentao         | /run/php/php8.4-fpm.sock       | www     |
| walle.1dao.cc     | /var/www/walle          | /run/php/php8.4-fpm.sock       | www     |
| deploy.1dao.cc    | /var/www/walle          | /run/php/php8.4-fpm.sock       | www     |
| openclaw.1dao.cc  | (反代到 OpenClaw 服务)  | -                               | -       |

---

## 附录:备份文件清单与解密方式

| 文件                          | 加密方式         | 解密命令关键参数                                   |
|-------------------------------|------------------|----------------------------------------------------|
| db-all-20261107.sql.gz.gpg    | GPG AES256       | gpg --passphrase 'xxx' --pinentry-mode loopback    |
| sys-config-20261107.tar.gz.gpg| GPG AES256       | 同上                                               |
| site-data-20261107.tar.gz.gpg | GPG AES256       | 同上                                               |
| openclaw-20261107.tar.gz.gpg  | GPG AES256       | 同上                                               |
| secrets-20261107.enc          | openssl AES-256-CBC | openssl enc -d -aes-256-cbc -pass file:/root/.backup-enc-key -pbkdf2 -iter 10000 |

---

## 附录:常见问题排查速查

| 现象                      | 可能原因                        | 排查命令                                                |
|---------------------------|---------------------------------|---------------------------------------------------------|
| 站点 500                  | PHP 报错                        | tail /var/log/nginx/error.log                          |
| 站点 502                  | PHP-FPM 未启动或 socket 不存在  | systemctl status php8.4-fpm; ls /run/php/              |
| 站点 404                  | root 路径错误                   | nginx -T \| grep root                                  |
| 数据库连接失败            | 密码错/用户无权/服务未启动      | mysql -u<user> -p<db>; systemctl status mariadb       |
| Redis 连接失败            | 服务未启动/socket 权限          | redis-cli -s /run/redis/redis-server.sock ping; groups www-data |
| 证书过期                  | 忘记续期/拷贝                   | openssl x509 -enddate -in /etc/letsencrypt/live/<domain>/cert.pem |
| 静态资源 403              | 文件权限不对                    | ls -l <file>; chown www-data:www-data <path>          |

---

## 结语

本文档记录了从备份文件到可运行的开发测试环境的完整恢复过程,涵盖软件栈安装、备份解密、数据库恢复、站点代码恢复、HTTPS证书恢复、站点测试修复六大环节,并总结了五个方面的经验教训。

核心成果:
- 7 个软件组件版本与线上完全对齐
- 4 个数据库(crmeb/emlog/walle/zentao)数据恢复,表数量验证通过
- 6 个站点代码恢复到 /var/www/,文件权限正确
- 9 个域名 HTTPS 证书恢复,有效期至 2026-11-07
- 5 个核心站点恢复成功,2 个(walle/deploy)待修复密码配置后完成

未尽事项:
- walle/deploy 的数据库密码字段映射,需对照 Walle 配置文档逐项核对
- 证书续期流程建立(到期前 30 天从线上拷贝)
- 备份脚本改进:确保空目录打包、记录加密方式元数据

下一篇工作总结将聚焦于应用功能验证、性能基线测试与监控告警体系的搭建。

---

*文档版本: v1.0*
*完成日期: 2026-08-21*
*作者: 1dao 运维*




<div style="page-break-after: always;"></div>

<a id="chapter-3"></a>
# 第3卷: 1dao 开发测试环境搭建工作总结(三):域名管理方案演变与 dev-on/off 实现

> **卷首语**: 本卷聚焦整个工作中最曲折的一段——域名访问方案的反复演变，最终沉淀出一套 dev-on / dev-off / which-env 工具链。

---




## 第一章:域名问题的本质

### 1.1 多站点系统域名架构

1dao 站群并不是一个单体应用,而是由一个主域名 `1dao.cc` 加五个子域名横向扩展出的多站点系统。整个站群对外呈现为一个统一的品牌矩阵,但内部各站点独立部署、技术栈各异、共享同一个根域。这个"主域 + 子域"的结构,是后续所有域名方案演变的起点,理解它才能理解为什么域名切换如此复杂。

站群的域名结构如下:

```
                       1dao.cc (根域)
                            │
        ┌───────────────────┼───────────────────┐
        │                   │                   │
   主站 1dao.cc        nav.1dao.cc         blog.1dao.cc
   (静态HTML+PHP)      (导航, 静态)         (emlog 博客)
        │                                       │
        │                                       │
   shop.1dao.cc         zentao.1dao.cc      walle.1dao.cc
   (CRMEB 商城)         (禅道项目管理)      deploy.1dao.cc
                                            (walle 部署)
```

| 站点 | 域名 | 技术栈 | 业务职能 |
|---|---|---|---|
| 主站 | `1dao.cc` | 静态 HTML + 少量 PHP | 公司门户、产品展示 |
| 导航 | `nav.1dao.cc` | 静态 HTML | 站群导航 |
| 博客 | `blog.1dao.cc` | emlog | 技术文章 |
| 商城 | `shop.1dao.cc` | CRMEB | 在线销售 |
| 禅道 | `zentao.1dao.cc` | 禅道开源版 | 项目管理 |
| 部署 | `walle.1dao.cc` / `deploy.1dao.cc` | walle | 代码部署 |

需要特别强调的是,**所有子域名都是 `1dao.cc` 的真实子域**——它们在 DNS 层级上隶属于 `1dao.cc`,在证书 SAN(Subject Alternative Name)上通常需要被单独覆盖(除非用通配符证书),在浏览器同源策略下与 `1dao.cc` 主域同属一个 eTLD+1(有效顶级域 + 1)。这个"同根不同子"的关系,是后续浏览器数据隔离、Cookie 共享、HSTS 缓存等所有问题的共同根源。

### 1.2 测试环境访问的核心矛盾

当虚机环境搭建完成、站点代码和数据库都恢复到虚机上之后,一个看似简单却极其棘手的问题浮现出来:**用什么域名访问虚机上的站点?**

线上服务器的域名 `1dao.cc` 在公网 DNS 上解析到 `47.113.148.3`(线上服务器 IP)。而虚机的 IP 是 `192.168.207.130`——一个 VMware NAT 网段下的私有地址,公网 DNS 根本不知道它的存在。如果直接在浏览器输入 `http://1dao.cc`,浏览器会走公网 DNS 解析到线上服务器,访问的是线上而不是虚机。

要让浏览器访问虚机上的站点,有几条路可走:

1. **改 DNS**:在公网 DNS 上把 `1dao.cc` 解析到 `192.168.207.130`——绝不可能,会直接把线上站搞挂。
2. **改本地 hosts**:在本机 `/etc/hosts` 里把 `1dao.cc` 指向 `192.168.207.130`——可行,但影响整台 Mac 的所有应用。
3. **换一个测试域名**:给虚机配一个新域名(如 `dev.1dao.cc`、`1dao.test`),hosts 指向虚机——可行,但数据库里存的是 `1dao.cc`,域名不匹配。
4. **IP + 端口直连**:用 `http://192.168.207.130:81` 直接访问——可行,但绝对 URL 会失效,且不优雅。

这四条路每一条都有代价,构成了测试环境访问的**核心矛盾**:既要访问到虚机,又要域名与数据库一致,还要能同时访问线上做对比,还要浏览器数据不互相污染。这些诉求彼此冲突,不可能用一个方案同时满足,这也是后续五次迭代的根本驱动力。

### 1.3 域名耦合的深层问题

为什么"换一个测试域名"这么难?根本原因在于站群的代码和数据**深度耦合了 `1dao.cc` 这个域名**。这种耦合不是一处两处,而是渗透在数据库、代码、资源引用的每一个角落。

**数据库层面的耦合**:经过排查,4 个数据库(crmeb、emlog、walle、zentao)中共有 **400+ 处**记录包含 `1dao.cc` 字样。典型例子:

- CRMEB 的 `eb_system_config` 表存了 `site_url = https://shop.1dao.cc`,CRMEB 生成所有商品链接、图片路径、跳转地址时都以这个 `site_url` 为前缀。
- CRMEB 的 `eb_store_product` 表的商品图片字段存的是 `https://shop.1dao.cc/upload/2024/xxx.jpg` 这样的绝对 URL。
- CRMEB 的 DIY 装修页面 `eb_diy` 表存的是 JSON,里面的图片、链接全是 `https://shop.1dao.cc/...`。
- emlog 的 `emlog_options` 表存了 `blogurl = https://blog.1dao.cc/`,emlog 生成所有文章链接、RSS、分页都用它。
- emlog 的 `emlog_blog` 表的文章内容里直接嵌入了 `https://blog.1dao.cc/...` 的图片和链接。
- 禅道的 `zt_doccontent` 文档内容、`zt_history` 操作历史里也有硬编码域名。

如果测试环境改用 `dev.1dao.cc`,数据库里这些 `1dao.cc` 就全都不匹配了——商品图片显示不出、跳转链接 404、DIY 页面加载失败。要让 dev 域名可用,必须把数据库里所有 `1dao.cc` 替换成 `dev.1dao.cc`(这正是 `change-domain.sh` 工具存在的意义);而一旦测试环境改了,同步回线上时又得替换回来,数据同步的冲突由此产生。

**代码层面的耦合**:排除 `vendor`、`runtime`、`cache` 等依赖和运行时目录,仍有 **24 个文件**硬编码了域名:

```bash

## 第二章:域名方案演变历程(5 次迭代)

域名方案不是一开始就想清楚的,而是经过五次迭代,每次都是"为什么尝试 → 发现什么问题 → 为什么放弃/保留"的循环。这一章按时间顺序还原演变过程,把每一次决策的依据、踩的坑、最终去留讲清楚。

演变总览如下:

```
方案1 同域名+hosts   ──┐  优点: DB零修改        缺点: 不能同时访问
                       │  → 最终保留为 prod 模式
方案2 dev.1dao.cc     │  问题: .dev TLD HSTS强制HTTPS, 无证书
(.dev TLD)            │  → 放弃
方案3 1dao.test       │  问题: 非子域, DB需替换, 同步冲突
(.test TLD)           │  → 放弃
方案4 dev.1dao.cc      │  优点: 子域名无HSTS      问题: DB需替换
(dev前缀子域名)        │  → 保留为 dev 模式
方案5 回到同域名       │  两种方案都保留
(dev-on/off)         ──┘  prod模式 + dev模式 并存
```

### 2.1 方案 1:同域名 + hosts 覆盖(1dao.cc → 虚机 IP)

**为什么尝试**:这是最直觉的方案。既然数据库里存的是 `1dao.cc`,那就用 `1dao.cc` 访问,只是把 `1dao.cc` 在本机解析到虚机 IP 而非线上 IP。macOS 的 `/etc/hosts` 文件正是为此而生——它优先级高于 DNS,可以覆盖任意域名的解析。

**原理**:在 `/etc/hosts` 加入一行,把 `1dao.cc` 及其子域全部指向虚机 `192.168.207.130`:

```bash

## 第三章:HSTS 与 .dev TLD 的坑(深度解析)

方案 2 的放弃源于 HSTS 与 `.dev` TLD 的交互,这个坑值得单独深度解析,因为它是整个演变过程中最隐蔽、最难排查的一个问题——浏览器没有明显的错误提示,只是"连不上",排查到根因花了不少时间。

### 3.1 HSTS 机制

HSTS(HTTP Strict Transport Security)是 RFC 6797 定义的安全机制,目的是防止 SSL 剥离攻击(SSL stripping)。原理是:服务器通过 HTTP 响应头 `Strict-Transport-Security` 告诉浏览器"在接下来 max-age 秒内,所有到本域的请求都必须用 HTTPS"。

```http
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
```

- `max-age=31536000`:有效期一年(单位秒)。
- `includeSubDomains`:对所有子域生效。
- `preload`:允许浏览器把这个域加入 preload list(需要单独申请)。

浏览器收到这个头后,会在本地缓存这条策略。在 max-age 期内,即使用户输入 `http://example.com`,浏览器也会在发请求前内部重写为 `https://example.com`,不发出明文 HTTP 请求。这是为了防止中间人在明文 HTTP 阶段劫持请求。

HSTS 的关键特性是**不可逆性**:一旦浏览器缓存了 HSTS 策略,在 max-age 期内,用户无法通过"输入 http://"绕过——浏览器会强制升级到 HTTPS。这意味着,如果一个域名的 HSTS 被浏览器缓存了,而你又没有有效的 HTTPS 证书,浏览器就会卡在"连接不是私密连接"的警告页,用户必须手动点"继续前往"才能访问,体验极差。

### 3.2 HSTS preload list

HSTS 的一个局限是"首次访问"问题:用户第一次访问 `example.com` 时,浏览器还没收到 HSTS 头,如果这次访问被中间人剥离到 HTTP,HSTS 就不生效。为解决这个问题,浏览器厂商维护了 **HSTS preload list**——一个内置在浏览器源码里的域名清单,这些域名的 HSTS 策略在浏览器出厂时就已生效,不需要等服务器返回 HSTS 头。

各大浏览器的 preload list 是共享的,维护在 https://hstspreload.org ,源头是 Chromium 项目的 `transport_security_state_static.json`。Firefox、Safari、Edge 都会同步这个列表。

preload list 不仅包含具体域名(如 `example.com`),还包含**整个 TLD**。Google 把自己拥有的若干 TLD 加入了 preload list,这些 TLD 下**所有**域名默认启用 HSTS,强制 HTTPS:

- `.dev`(Google 拥有)
- `.app`(Google 拥有)
- `.foo`(Google 拥有)
- `.page`(Google 拥有)
- `.chrome`(Google 拥有)
- `.google`(Google 拥有)
- `.android`(Google 拥有)
- 等等

这意味着,任何 `.dev` 结尾的域名——`myproject.dev`、`test.dev`、`dev.1dao.dev`——在主流浏览器里**都默认强制 HTTPS**,无论服务器有没有配 HSTS 头、无论用户输入的是 http 还是 https。

### 3.3 为什么 .dev 会触发 HSTS

方案 2 用的 `dev.1dao.cc` **不是** `.dev` TLD——它的 TLD 是 `.cc`,`1dao.cc` 是 eTLD+1,`dev.1dao.cc` 是 `1dao.cc` 的子域。所以严格说,方案 2 最初设想的"dev 子域名"如果写成 `dev.1dao.cc`,其实不会触发 `.dev` 的 HSTS(详见 3.5 节)。

但方案 2 在最初命名时,考虑过用 `1dao.dev` 这种形式(把 `.dev` 作为 TLD),这才是真正触发 HSTS 的写法。实测:

```bash

## 第四章:dev-on / dev-off / which-env 命令实现

方案 5 确定后,需要把"切换 hosts"这个操作封装成易用的命令,避免每次都手动编辑 `/etc/hosts`。最终实现了三个脚本:`dev-on`(开)、`dev-off`(关)、`which-env`(判断当前状态),安装到 `/usr/local/bin/` 全局可用。

### 4.1 dev-on 脚本

**原理**:`dev-on` 向 `/etc/hosts` 追加两类条目:

1. `1dao.cc` 系列(主域 + 子域)→ 虚机 IP,这是 prod 模式的访问入口。
2. `dev.*` 系列(`dev.1dao.cc`、`shop.dev.1dao.cc` 等)→ 虚机 IP,这是 dev 模式的访问入口,**永久指向虚机**,`dev-off` 不移除。

为了防止重复追加(多次运行 `dev-on` 会让 hosts 越来越长),用标记注释 `# 1dao-dev-vm` 和 `# 1dao-dev-domains` 来标识这两段。`grep` 检查标记存在则跳过。

**完整实现**(`/usr/local/bin/dev-on`):

```bash
#!/bin/bash

## 第五章:blog / shop 访问问题排查

域名方案定了,`dev-on` 也开了,本以为 `http://1dao.cc` 能顺利打开。实测发现:`1dao.cc` 主站正常,但 `blog.1dao.cc` 和 `shop.1dao.cc` 浏览器报"连接被重置"(ERR_CONNECTION_RESET)。这一章复盘这个排查过程,它揭示了 nginx 444 兜底与 HSTS 缓存的交互,是整个工作中最隐蔽的一个坑。

### 5.1 问题现象

`dev-on` 后,浏览器访问:

| 域名 | 现象 |
|---|---|
| `http://1dao.cc` | 正常(主站静态页) |
| `http://nav.1dao.cc` | 正常(导航静态页) |
| `http://blog.1dao.cc` | **连接被重置** (ERR_CONNECTION_RESET) |
| `http://shop.1dao.cc` | **连接被重置** (ERR_CONNECTION_RESET) |
| `http://zentao.1dao.cc` | 正常 |

奇怪的是,都是子域名,有的通有的不通,且不通的没有任何 HTTP 状态码——浏览器直接报"连接被重置",意味着 TCP 连接在收到 HTTP 响应前就被对端断开了。

### 5.2 排查过程

**第一步:curl 检查 HTTP 80**:

```bash
curl -sI http://blog.1dao.cc/ --max-time 5

## 第六章:浏览器数据隔离问题

方案 1(prod 模式,同域名)有一个绕不开的副作用:**线上和测试环境同域名,浏览器数据共享**。这一章深度解析浏览器存储的隔离机制,以及为什么这是个问题,如何缓解。

### 6.1 问题背景

`dev-on` 后,浏览器访问 `https://1dao.cc` 指向虚机(测试环境);`dev-off` 后,访问同一个 `https://1dao.cc` 指向线上。**域名没变,只是背后的 IP 变了**。

浏览器的存储机制(Cookie、localStorage、Service Worker、HSTS 缓存)都是**按域名**索引的,不分端口、不分 IP。这意味着:

- 在测试环境(`dev-on`)登录了 shop,浏览器存下 `shop.1dao.cc` 的登录 Cookie。
- `dev-off` 切到线上,浏览器访问线上 `shop.1dao.cc`,会把刚才测试环境的登录 Cookie 带上去。
- 线上服务器收到一个"测试环境签发的 Cookie",校验失败(因为 session 数据不同),要么报错要么登录态异常。
- 反过来,在线上登录后,`dev-on` 切到测试环境,线上的 Cookie 又被带到测试环境,可能误触发线上身份的操作。

这就是"浏览器数据共享"的痛点——**同域名无法隔离 Cookie**。

### 6.2 Cookie 机制详解

Cookie 是浏览器存储的键值对,通过 HTTP 头 `Set-Cookie` 下发,后续请求通过 `Cookie` 头回传。Cookie 的关键属性:

**domain 属性**:Cookie 按 domain 索引。`Set-Cookie: foo=bar; domain=1dao.cc` 下发的 Cookie,**所有 `1dao.cc` 的子域**(包括 `shop.1dao.cc`、`blog.1dao.cc`、`1dao.cc` 本身)都会在请求时带上。这是"根域 Cookie"。

```
Set-Cookie: session=abc; domain=1dao.cc; path=/; HttpOnly; Secure

→ 浏览器存储后, 以下请求都会带上这个Cookie:
  https://1dao.cc/         ✓
  https://shop.1dao.cc/    ✓
  https://blog.1dao.cc/    ✓
  https://dev.1dao.cc/     ✓ (dev.1dao.cc 也是1dao.cc子域!)
```

注意最后一条——`dev.1dao.cc` 也是 `1dao.cc` 的子域,所以 `domain=1dao.cc` 的 Cookie 在 dev 模式下也会被带上。这是方案 4(dev 子域名)的一个隐性副作用:虽然 Cookie 在 `1dao.cc` 和 `dev.1dao.cc` 之间隔离(因为 eTLD+1 不同),但 `domain=1dao.cc` 的根域 Cookie 会覆盖所有子域包括 `dev.1dao.cc`。要真正隔离,必须让应用下发 `domain=shop.1dao.cc`(具体子域)而不是 `domain=1dao.cc`(根域)。1dao 的应用是否这么做,取决于各应用代码,不一定统一。

**Cookie 不分端口**:Cookie 的 domain 只看域名,不看端口。`http://1dao.cc:80` 和 `http://1dao.cc:81` 共享 Cookie。所以"用不同端口隔离 Cookie"不可行。

**HttpOnly**:设置后 Cookie 不能被 JavaScript(`document.cookie`)访问,防止 XSS 窃取。但不影响"同域名共享"。

**Secure**:设置后 Cookie 只在 HTTPS 请求时回传。但同域名 HTTPS 还是共享。

**SameSite**:控制跨站请求是否带 Cookie(`Strict`/`Lax`/`None`)。防 CSRF,但不影响同站(同 eTLD+1)的共享。

综上,**同域名的 Cookie 无法通过任何属性配置隔离**——这是浏览器同源策略的固有行为。要隔离 Cookie,只能用不同的 eTLD+1(即不同根域或不同子域作为 eTLD+1)。

### 6.3 localStorage 机制

localStorage 是 HTML5 引入的本地存储,按 **origin** 索引。origin = 协议 + 域名 + 端口。

```
origin 1: https://shop.1dao.cc        (443)
origin 2: http://shop.1dao.cc         (80)   ← 与origin 1不同(协议不同)
origin 3: https://shop.1dao.cc:8443          ← 与origin 1不同(端口不同)
```

prod 模式下,线上 `https://shop.1dao.cc` 和测试 `https://shop.1dao.cc` 是**同一个 origin**(协议域名端口全同),localStorage 完全共享。测试环境写入的购物车数据,`dev-off` 后线上访问能看到——数据串台。

dev 模式下,`https://shop.1dao.cc`(线上)和 `http://shop.dev.1dao.cc`(测试)是不同 origin(eTLD+1 不同、协议可能不同),localStorage 天然隔离。

### 6.4 Service Worker

Service Worker 是浏览器后台运行的脚本,用于离线缓存、推送等。它按 **origin** 注册,一个 origin 只能有一个 Service Worker。

prod 模式下,线上和测试同 origin,Service Worker 共享。如果测试环境注册了一个 Service Worker 缓存了测试数据,`dev-off` 后线上访问可能命中测试环境的缓存,显示测试内容——非常难排查的"幽灵缓存"。排查时要在浏览器 `chrome://serviceworker-internals/` 或 `about:debugging` 手动注销。

dev 模式下不同 origin,Service Worker 各自独立,无此问题。

### 6.5 HSTS 缓存

如第三章所述,HSTS 缓存按域名,跨端口、跨协议共享。prod 模式下,线上 nginx 返回的 HSTS 头会被浏览器缓存,即使 `dev-on` 切到测试环境(测试 nginx 可能没配 HSTS),浏览器仍会强制 HTTPS——这通常不是坏事(测试也有 HTTPS),但要注意"线上配的 HSTS 在测试环境也生效"这个副作用。

### 6.6 隔离方案对比

既然 prod 模式有浏览器数据共享问题,有几种缓解方案:

**方案 A:双浏览器**

用一个浏览器访问线上(如 Chrome),另一个访问测试(如 Firefox)。两个浏览器的存储完全独立,Cookie/localStorage/SW/HSTS 都不串。

```
Chrome  → 线上 (https://1dao.cc)
Firefox → 测试 (https://1dao.cc, dev-on)
```

优点:彻底隔离,无任何串台。缺点:要维护两个浏览器的书签、插件、登录态,日常用哪个浏览器就要取舍。

**方案 B:同浏览器独立 Profile**

一个浏览器开多个 Profile,每个 Profile 的存储独立。Firefox 用 `-P` 启动 Profile 管理器,Chrome 用 `--user-data-dir` 指定独立数据目录:

```bash

## 第七章:WiFi 断开时的域名访问问题

在移动办公场景(咖啡馆、高铁、无 WiFi 的会议室),出现了一个新问题:**WiFi 断开后,`dev.1dao.cc` 等域名访问不了**。这一章解析根因和应对方案。

### 7.1 问题现象

WiFi 断开(或连了一个无 Internet 的热点)后:

- `http://1dao.cc`(prod 模式,hosts 指向虚机)→ **无法访问**。
- `http://dev.1dao.cc`(dev 模式,hosts 指向虚机)→ **无法访问**。
- `ssh 1dao-dev`(SSH 到虚机)→ **正常**(因为 SSH config 用的是 IP 直连)。
- `ping 192.168.207.130`(虚机 IP)→ **正常**。

虚机明明能 ping 通,SSH 能连,为什么域名访问不了?

### 7.2 根因:mDNSResponder 行为

根因在于 macOS 的 DNS 解析守护进程 `mDNSResponder` 的行为:**当没有活动网络接口(无 WiFi/以太网连接)时,`mDNSResponder` 不响应 `/etc/hosts` 的查询**。

正常情况(WiFi 连接):

```
应用请求 dev.1dao.cc
    │
    ▼
mDNSResponder 查 /etc/hosts → 命中 → 返回 192.168.207.130
    │
    ▼
应用连接 192.168.207.130 ✓
```

WiFi 断开:

```
应用请求 dev.1dao.cc
    │
    ▼
mDNSResponder 检测到无活动网络 → 拒绝查询 → 返回"无解析"
    │
    ▼
应用报错: 服务器DNS解析失败
```

`mDNSResponder` 的逻辑是"没网络就没必要解析域名"——它不知道 `/etc/hosts` 里有静态映射,也不区分"hosts 解析"和"DNS 服务器解析"。WiFi 断开后,它直接拒绝所有域名查询,包括本应走 hosts 的。

这是 macOS 的一个历史行为(至少到 macOS 14 仍如此)。Linux 的 `nsswitch.conf` 机制会优先查 hosts 再查 DNS,不受网络状态影响;但 macOS 的解析栈不同,`mDNSResponder` 是统一入口,行为更"激进"。

### 7.3 虚机网络独立性

讽刺的是,虚机本身网络是通的——VMware 的 bridge/NAT 网络是虚拟接口,不依赖宿主机的 WiFi:

```
宿主机 (macOS)
├── en0 (WiFi)         ← WiFi 断开, 此接口 down
├── vmnet8 (NAT)       ← VMware 虚拟接口, 仍 up
│   └── 192.168.207.x 网段
└── 虚机 192.168.207.130 通过 vmnet8 可达
```

`ping 192.168.207.130` 通,证明虚机网络接口和路由都正常。问题只在"域名→IP"这一步,被 `mDNSResponder` 卡住了。

### 7.4 解决方案:IP + 端口直连

绕过 DNS 的办法是**用 IP + 端口直连**,不经过域名解析:

```
http://192.168.207.130       (主站)
http://192.168.207.130:81    (导航)
http://192.168.207.130:82    (博客)
http://192.168.207.130:83    (商城)
http://192.168.207.130:84    (禅道)
```

但默认 nginx 只监听 80/443,且 `server_name` 是按域名匹配的——直接 IP 访问会落到 `default_server`(可能 444)。要让 IP+端口可用,需要 nginx 为每个站配一个端口监听。

### 7.5 nginx 端口配置

在 nginx 配置里给每个站加一个基于端口的 server 块(或修改现有 server 加 `listen 81` 等):

```nginx

## 第八章:经验教训

回顾整个域名方案演变过程,有若干经验教训值得沉淀,供后续类似工作参考。

### 8.1 .dev TLD 的坑:永远不要用 .dev 做开发域名

这是最具体也最容易被忽视的一条。`.dev` 看起来语义清晰("dev = development"),但它是 Google 拥有的真实 TLD,在 HSTS preload list 里,所有主流浏览器对 `.dev` 下所有域名强制 HTTPS。开发环境往往没有有效证书(letsencrypt 签不下来),浏览器报"连接不是私密连接",体验崩溃。

**替代选择**:

- 用 RFC 2606 保留的 `.test`、`.example`、`.localhost`(不触发 HSTS,但可能引入独立根域的同步问题)。
- 用真实根域的子域名,如 `dev.example.com`(eTLD+1 是 example.com,不触发任何 TLD 的 HSTS)。这是 1dao 最终采用的方案。

**通用原则**:做开发域名前,先查 https://hstspreload.org 看该 TLD 是否在 preload list。在的话,绕道。

### 8.2 HSTS 的不可逆性

HSTS 一旦被浏览器缓存,在 max-age 期内持续生效,无法通过"输入 http://"绕过。即使服务器关掉 HSTS 头,缓存仍生效到过期。这对开发调试是隐患——如果某次测试让浏览器缓存了 HSTS,后续没法用 HTTP 访问。

**应对**:

- 开发环境 nginx **不要**配 `Strict-Transport-Security` 头,避免给浏览器缓存 HSTS。
- 如果已经缓存,用 `chrome://net-internals/#hsts` 的 Delete 清除(对 preload TLD 无效)。
- preload list 的 TLD(如 `.dev`)无法清除,只能换域名。

### 8.3 浏览器 DNS 缓存独立于系统

改了 `/etc/hosts` 并刷新 `mDNSResponder` 后,**浏览器仍有自己的 DNS 缓存**,可能继续用旧 IP。Chrome 的 DNS 缓存在 `chrome://net-internals/#dns`,可手动 "Clear host cache"。Firefox 类似。

**实践建议**:`dev-on/off` 后,用 `which-env`(curl 命令行,不走浏览器缓存)验证状态,而不是直接看浏览器。浏览器验证时用无痕窗口(每次新开无缓存)。

### 8.4 444 兜底的副作用

nginx 的 `return 444`(或 `ssl_reject_handshake on`)作为 default_server 兜底,能挡扫描器,但**会误伤未配 443 的子域名 + HSTS 强制 HTTPS 的组合**。线上遗留的 `000-default-444.conf` 在测试环境就触发了 blog/shop 的"连接被重置"。

**应对**:

- 兜底配置要审计:确认所有需要访问的 server_name 都有对应的 `listen 443` server 块。
- `ssl_reject_handshake on` 比 `return 444` 更明确(TLS 握手阶段就拒绝),但效果类似。
- 排查"连接被重置"时,优先检查是否有 default_server 兜底未匹配的请求。

### 8.5 证书 SAN 的重要性

一张证书只覆盖 SAN 里列出的域名(或通配符 `*.example.com` 覆盖的子域)。`1dao.cc` 证书的 SAN 只有 `1dao.cc`、`www.1dao.cc`、`openclaw.1dao.cc`,给 `blog.1dao.cc` 用会报"证书域名不匹配"。

**应对**:

- 多子域场景,优先用**通配符证书**(`*.1dao.cc`),一张证书覆盖所有子域,省心。
- 没有通配符时,每个子域独立签证书(letsencrypt 免费,`certbot certonly -d blog.1dao.cc`),nginx 各自指向。
- 迁移/恢复证书时,核对 SAN 与 nginx `server_name` 一一对应,避免"用错证书"。

### 8.6 Cookie 隔离的局限性

同域名(eTLD+1 相同)无法隔离 Cookie——这是浏览器同源策略的固有行为,任何 Cookie 属性(domain/path/HttpOnly/Secure/SameSite)都不能绕过。要隔离 Cookie,只能用不同的 eTLD+1。

**应对**:

- 需要浏览器数据隔离的场景(同时访问线上+测试、避免登录态串台),用不同 eTLD+1(dev 模式子域名)或不同浏览器/Profile。
- 不要试图通过 Cookie 属性"在同域名下隔离",行不通。

### 8.7 DNS 解析依赖活动网络

macOS 的 `mDNSResponder` 在无活动网络时拒绝响应 hosts 查询,导致 WiFi 断开时域名访问失效(尽管虚机本身可达)。这是 macOS 特有的行为,Linux 的 `nsswitch` 不受影响。

**应对**:

- 应急方案:IP+端口直连,绕过 DNS。
- 长期方案:给虚机配不依赖宿主 WiFi 的网络(Host-Only),或接受"WiFi 断开时只能 SSH,不能域名访问"的限制。

### 8.8 双模式并存的价值

最终采用"prod 模式 + dev 模式并存"的方案,看似复杂,实则各司其职:

- prod 模式(同域名):数据零修改、数据同步零冲突,适合排查线上 bug。
- dev 模式(子域名):浏览器数据隔离、可同时访问,适合功能开发。

两者通过 `dev-on/off`(本机 hosts)和 `switch-env`(虚机数据库)分别控制,互不干扰。这种"按场景选择方案"的思路,比强求一个"万能方案"更务实——没有方案能同时满足所有诉求,双模式让每个场景都有最优解。

### 8.9 工具化的必要性

`dev-on`/`dev-off`/`which-env` 三个脚本看似简单,却是日常工作的倍增器。没有它们,每次切换要手动编辑 hosts、手动刷新缓存、手动 curl 验证——流程长、易出错。封装成命令后,一条 `dev-on` 完成所有操作,`which-env` 一眼看清状态。

**通用经验**:任何需要反复执行的多步操作,都值得封装成脚本/命令,即使只有三五步。封装的收益不只是省时间,更是"减少出错面"——脚本每次执行一致,不会像手动操作那样漏掉一步(比如忘了 `killall -HUP mDNSResponder` 导致缓存残留)。

### 8.10 排查复合根因的耐心

blog/shop 的"连接被重置"问题,根因是"444 兜底 + 子域 443 未配 + HSTS 强制 HTTPS"三者叠加。任一单独看都"没问题",组合才出问题。这种复合根因排查最耗时,因为容易盯着单一环节死磕(比如一直改证书),却看不到全局。

**应对策略**:

- 分层验证:网络层(curl 80/443)、TLS 层(openssl s_client)、应用层(curl 响应头),逐层缩小。
- 用最简工具(curl/openssl)而非浏览器排查——浏览器有缓存、有 HSTS、有重定向,干扰多。
- 画出请求流程图,标出每一步可能的失败点,逐一排除。

---

## 结语

域名管理是整个 1dao 开发测试环境搭建中最曲折的部分。从最初"用 1dao.cc 访问"的直觉,到 `.dev` 的 HSTS 坑、`.test` 的同步冲突、`dev.1dao.cc` 的子域折衷,最终沉淀出"prod + dev 双模式"的方案,配套 `dev-on`/`dev-off`/`which-env`/`switch-env`/`change-domain` 工具链。每一版方案都不是白费——它们各自排除了一个错误方向,最终合力逼近了"兼顾数据一致、浏览器隔离、可同时访问、无证书警告"的综合最优解。

这个过程也是对"环境搭建"这类工作本质的印证:它不是一次性工程,而是反复试错、逐步逼近的过程。提前规划重要,但更重要的是遇到问题时能快速定位根因、调整方案、不固执于单一思路。HSTS、Cookie、证书 SAN、nginx 兜底、DNS 解析这些底层机制,平时隐于幕后,一旦交互出问题就是最隐蔽的坑。理解它们的运作,是高效排障的前提。

下一部分(工作总结四)将聚焦工具链的工程化实现——`switch-env` 的快照法、`change-domain` 的占位符法、`env.conf` 的配置管理,以及如何把这些脚本组织成可维护、可扩展的工具集。




<div style="page-break-after: always;"></div>

<a id="chapter-4"></a>
# 第4卷: 1dao 开发测试环境搭建工作总结(四):换域名工具链开发

> **卷首语**: 本卷聚焦 change-domain.sh 与 switch-env.sh 两个核心工具的设计、开发与调试全过程。

---




## 第一章 域名耦合点调查

### 1.1 调查方法:数据库全量扫描 + 代码 grep 扫描

1dao 这套站点经过多年累积,域名 `1dao.cc`(以及 `shop.1dao.cc`、`blog.1dao.cc`、`nav.1dao.cc` 等子域)散落在数据库表的内容字段、PHP 文件、HTML 文件、JSON 配置、CSS 文件等多个位置。要做一次"无损、可逆、可预览"的整站换域名,第一步必须把所有耦合点摸清楚,否则后续替换必然遗漏,导致站点静态资源 404、接口跨域、DIY 页错乱等问题。

调查分两条腿走:

1. **数据库全量扫描**:利用 MySQL 的 `information_schema.columns` 元数据,把所有可能的字符串类型列(`char` / `varchar` / `text` / `mediumtext` / `longtext` / `tinytext` / `json`)枚举出来,再逐列用 `LIKE '%1dao.cc%'` 判断是否含目标域名。
2. **代码 grep 扫描**:用 `grep -rl` 在站点根目录下递归搜索 `1dao\.cc`,通过 `--include` 白名单限定文件扩展名,通过 `--exclude-dir` 黑名单剔除依赖目录。

两条腿缺一不可:数据库侧负责"用户内容"中的耦合(文章正文、DIY 页配置、商品图、主题、配置项值等),代码侧负责"应用代码"中的耦合(模板硬编码、CSS 背景图、PHP 中的绝对 URL、HTML 中的 `<a href>` 等)。

### 1.2 数据库调查结果

四个业务库中均存在 `1dao.cc` 字样,具体分布如下:

#### 1.2.1 crmeb 库(CRMEB 商城)

CRMEB 的耦合点最多,主要集中在以下几个表:

| 表名 | 关键列 | 说明 |
|------|--------|------|
| `eb_system_config` | `value` | `site_url` 配置项,整站根 URL,影响支付回调、商品分享、H5 跳转 |
| `eb_store_product` | `image`、`slider_image`、`description` | 商品主图、轮播图、详情图通常存的是完整 URL |
| `eb_store_category` | `pic` | 分类图标 |
| `eb_diy` | `value` | DIY 可视化页面的 JSON 配置,内部含图片、跳转链接 |
| `eb_theme` | `config` | 主题模板的配置 JSON |
| `eb_system_group_data` | `value` | 组合数据(广告位、首页 banner) |
| `eb_article` | `content`、`image_input` | 文章正文与封面 |

#### 1.2.2 emlog 库(博客)

emlog 的耦合点相对集中:

- `emlog_options`:`blogurl` 选项,博客整站根 URL,影响 RSS、sitemap、文章分享
- `emlog_blog`:`content` 字段中存在相对链接被历史编辑器自动转成的绝对 URL
- `emlog_comment`:`url` 字段,评论者填写的个人站点(部分含 `1dao.cc` 的自评)
- `emlog_twitter`:碎语内容中可能的内部链接

#### 1.2.3 walle 库(上线部署)

walle 是上线部署系统,耦合点在项目配置:

- `project` 表:`level`、`server_id` 之外的配置列中含目标机器上的部署路径,历史项目记录中绝对路径含 `1dao.cc`
- `record`:发布记录中的注释
- `task`:任务执行上下文

#### 1.2.4 zentao 库(禅道)

zentao 是项目管理系统,耦合点比较多但量级小:

- `zt_project`:项目描述中的链接
- `zt_task`:任务描述
- `zt_doccontent`:文档正文(富文本,含绝对 URL)
- `zt_history`:操作历史中的变更前后值

#### 1.2.5 汇总

四个库合并扫描后,含 `1dao.cc` 的列行数总计约 **400+ 处**。这个量级决定了不可能手工逐条 UPDATE,必须脚本化。其中 CRMEB 占六成以上,emlog 与 zentao 各占一成多,walle 较少。

### 1.3 代码调查结果

在 `/var/www` 下递归 grep `1dao\.cc`,排除 `node_modules`、`vendor`、`runtime`、`cache`、`.git` 等依赖与缓存目录后,共在 **24 个文件**中发现硬编码。按站点归档:

- **主站** `/var/www/1dao.cc/`:
  - `index.html`:首页中的导航、footer 链接
  - `counter.php`:计数器脚本中的回调 URL
  - `about-site/index.html`:关于页
  - `releases/index.html`:发布日志页
- **导航站** `/var/www/nav/`:
  - `index.html`:所有导航项的 `href`
- **博客** `/var/www/blog/`:
  - `header.php`:博客头部模板中的 RSS、logo 链接
  - `style.css`:CSS 中 `background-image` 的绝对 URL
  - `showcase.php`:作品展示页
  - `export_article.php`:导出文章时的链接处理
- **walle** `/var/www/walle/`:
  - `Project.php`:项目控制器中的硬编码示例
  - `help.php`:帮助文档
- **其他**:crmeb 的 `.env`、zentao 的 `config/my.php` 等配置文件

### 1.4 应用配置层面的耦合

除了"看得见的字符串",还有几处"运行时才生效"的耦合点必须单独处理:

1. **CRMEB `site_url`**:写在 `eb_system_config` 表中,CRMEB 启动时会读入并拼装到支付回调、商品分享等位置。换域名时必须先改它,否则即使其他位置都改了,支付回调依然指向旧域名。
2. **emlog `blogurl`**:同上,写在 `emlog_options` 表中,emlog 在渲染 RSS / sitemap / 文章链接时用它做 base。
3. **zentao 运行时检测**:zentao 不把域名写在配置文件,而是从 HTTP 请求头 `Host` 中实时推断,所以只要 nginx 把对应 server_name 配好、Host 头正确,zentao 会自动适配。这一项不需要在数据库或代码中改,但**必须保证 nginx 配置先于 zentao 生效**。
4. **walle 数据库 `project` 表**:walle 把项目部署路径写入 `project` 表,这条路径是服务器本机路径而非 URL,但如果在 walle 的"项目设置"中填写过完整回调 URL,就需要替换。

### 1.5 调查脚本

数据库扫描的核心脚本是借助 `information_schema.columns` 获取所有字符串类型列,再逐列 LIKE 检查:

```bash
#!/usr/bin/env bash

## 第二章 `change-domain.sh` 设计

### 2.1 需求

`change-domain.sh` 的核心需求只有一句话:**整站域名替换**,覆盖数据库、代码、缓存三大块。但这一句话拆开看,要求极其严苛:

1. **整站**:不能只改某一站,必须 4 个库 + 24 个代码文件全覆盖。
2. **原子性**:要么全改,要么全不改。不能改到一半挂掉留下"半残站点"。
3. **可预览**:`--dry-run` 必须先告诉用户"我将要动这些表、这些文件",用户确认后再执行。
4. **可细分**:`--db-only` / `--files-only` 让用户能分步操作,先改代码再改数据库,反之亦可。
5. **可逆**:配合 `switch-env.sh` 的快照机制,改完能回滚。
6. **不误伤**:这是最难的,详见 2.2。

### 2.2 核心问题:短域名误伤长域名

这是整条工具链最难的一个问题,需要专门讲清楚。

#### 2.2.1 错误示例

假设要执行 prod → dev 切换,映射关系如下:

| 旧域名 | 新域名 |
|--------|--------|
| `shop.1dao.cc` | `dev.shop.1dao.cc` |
| `1dao.cc` | `dev.1dao.cc` |

如果直接顺序执行 `REPLACE`:

1. 先把数据库中所有 `shop.1dao.cc` 替换成 `dev.shop.1dao.cc`。
2. 再把所有 `1dao.cc` 替换成 `dev.1dao.cc`。

第 2 步会**误伤**第 1 步已经替换好的 `dev.shop.1dao.cc`:因为它内部仍然包含 `1dao.cc` 这个子串,会被替换成 `dev.shop.dev.1dao.cc`,变成了一坨垃圾。

#### 2.2.2 误伤结果

最终结果:

| 原值 | 错误结果 | 正确结果 |
|------|----------|----------|
| `shop.1dao.cc` | `dev.shop.dev.1dao.cc` ❌ | `dev.shop.1dao.cc` ✓ |
| `1dao.cc` | `dev.1dao.cc` ✓ | `dev.1dao.cc` ✓ |

短域名永远会误伤长域名,因为短域名是长子域名的子串。这不是排序能解决的——无论先做长还是先做短,总会出问题:

- 先长后短:长替换的结果里含短,被短再次命中。
- 先短后长:短替换的结果里不含长(因为短还没把长的前缀补上),但短替换之后所有出现 `1dao.cc` 的地方都变成 `dev.1dao.cc` 了,等再做长替换 `shop.1dao.cc → dev.shop.1dao.cc` 时,文本里已经没有 `shop.1dao.cc` 了,长替换完全失效,得到 `shop.dev.1dao.cc` 这种错乱结果。

### 2.3 占位符法

解决上述误伤的标准技术是**占位符法**(placeholder pass),分两阶段:

#### 2.3.1 阶段 1:所有旧域名 → 唯一占位符

为每个旧域名分配一个**不可能出现在任何真实文本中**的占位符串,本工具用 `@@DMNCHG_<idx>_@@` 的格式:

| 旧域名 | 占位符 |
|--------|--------|
| `shop.1dao.cc` | `@@DMNCHG_0_@@` |
| `1dao.cc` | `@@DMNCHG_1_@@` |

**关键点**:占位符不能含任何域名片段,也不能含任何被替换域名的子串。这样后续替换绝不会命中它自己。

阶段 1 按**旧域名长度降序**执行(长的先替换):

```sql
UPDATE table SET col = REPLACE(col, 'shop.1dao.cc', '@@DMNCHG_0_@@');
UPDATE table SET col = REPLACE(col, '1dao.cc',     '@@DMNCHG_1_@@');
```

- 第一条把 `shop.1dao.cc` 换成 `@@DMNCHG_0_@@`,此时 `1dao.cc` 这个子串**仅**出现在不是 `shop.1dao.cc` 的位置。
- 第二条把剩下的 `1dao.cc` 换成 `@@DMNCHG_1_@@`。**注意**:已经被换成 `@@DMNCHG_0_@@` 的位置不含 `1dao.cc`,不会被误命中。

阶段 1 结束后,数据库里没有任何 `1dao.cc` 字样了,全部变成了占位符。

#### 2.3.2 阶段 2:占位符 → 新域名

占位符不含任何域名,所以阶段 2 的替换互不影响:

```sql
UPDATE table SET col = REPLACE(col, '@@DMNCHG_0_@@', 'dev.shop.1dao.cc');
UPDATE table SET col = REPLACE(col, '@@DMNCHG_1_@@', 'dev.1dao.cc');
```

无论执行顺序如何,结果都正确。

#### 2.3.3 优势

占位符法的本质是**中转一层**,把"多对多替换"降为"多次一对一替换":

- 多对多:`{shop.1dao.cc, 1dao.cc}` → `{dev.shop.1dao.cc, dev.1dao.cc}`,元素互为子串,无法顺序执行。
- 中转后:`{shop.1dao.cc, 1dao.cc}` → `{占位符0, 占位符1}` → `{dev.shop.1dao.cc, dev.1dao.cc}`,每个箭头都是"原值不含目标值的子串"的安全替换。

这个思路同样适用于代码文件、JSON 配置等所有文本替换场景。

### 2.4 多对映射排序

占位符法阶段 1 必须按旧域名长度降序排序,否则短域名先命中长域名的子串。在 shell 中实现:

```bash

## 第三章 `change-domain.sh` 实现与调试

### 3.1 第一版实现

第一版使用经典的 `find + xargs + grep` 组合搜索文件:

```bash
find "${WWW_ROOT}" -type f \
     -not -path '*/node_modules/*' \
     -not -path '*/vendor/*' \
     -not -path '*/runtime/*' \
     -not -path '*/.git/*' \
     -print0 | xargs -0 grep -l '1dao\.cc'
```

#### bug1:搜索范围错误

**现象**:dry-run 输出的命中文件清单里出现了若干路径异常:

```
/root/backup/www-2024-01/index.html
/root/.openclaw/session/xxxx/context.txt
/var/www/...
```

dry-run 阶段还好,但实际执行时,脚本会按这些路径去 `sed -i` 修改文件,意味着会改到 `/root/backup/` 下的备份文件、改到 agent session 上下文记录文件,留下脏数据。

**排查**:在脚本里 `set -x` 跟踪 `find` 的起始目录参数,发现 `WWW_ROOT` 在某次手误中被设置为 `/`(根目录),`find` 就从根开始扫,自然把所有用户家目录、agent 上下文全扫了一遍。

**根因**:`find` 接受任意路径作为起点,且**对路径无任何安全校验**,即使给 `/` 也照扫不误。这违反了"严格限定在站点目录内"的安全前提。

**修复**:改用 `grep -rl --include --exclude-dir` 直接在 `WWW_ROOT` 下搜索,把"搜索根目录"参数固定为 `WWW_ROOT`,且强制校验非空:

```bash
WWW_ROOT="${WWW_ROOT:-/var/www}"
[ -d "${WWW_ROOT}" ] || { echo "WWW_ROOT not a dir: ${WWW_ROOT}"; exit 1; }

grep -rl --include='*.php' --include='*.html' --include='*.js' \
        --include='*.css' --include='*.json' --include='*.md' \
        --include='*.tpl' --include='*.env' --include='*.env.*' \
        --exclude-dir=node_modules --exclude-dir=vendor \
        --exclude-dir=runtime --exclude-dir=cache --exclude-dir=.git \
        "${OLD}" "${WWW_ROOT}" 2>/dev/null
```

`grep` 只在第一个非选项参数(此处是 `${WWW_ROOT}`)下递归,绝不会"越界"。同时 `2>/dev/null` 吞掉权限报错(如某些 root-only 文件),避免误报。

### 3.2 数据库替换循环

数据库侧的替换循环骨架:

```bash
for DB in ${DB_LIST}; do
  # 取出该库下所有字符串列
  mysql -sN -e "SELECT table_name, column_name FROM information_schema.columns
                WHERE table_schema='${DB}'
                  AND data_type IN ('char','varchar','text','mediumtext','longtext','tinytext','json')" \
    | while read -r tbl col; do
        # 阶段1: old -> placeholder (按长度降序)
        for i in "${!OLDS[@]}"; do
          mysql "${DB}" -e "UPDATE \`${tbl}\` SET \`${col}\`=REPLACE(\`${col}\`, '${OLDS[i]}', '${PHS[i]}') WHERE \`${col}\` LIKE '%${OLDS[i]}%'"
        done
        # 阶段2: placeholder -> new
        for i in "${!OLDS[@]}"; do
          mysql "${DB}" -e "UPDATE \`${tbl}\` SET \`${col}\`=REPLACE(\`${col}\`, '${PHS[i]}', '${NEWS[i]}') WHERE \`${col}\` LIKE '%${PHS[i]}%'"
        done
      done
done
```

#### bug2:`CONCAT(table_name,"\t",column_name)` 输出字面量 `\t` 而非制表符

**现象**:第一版中,我把两列拼成一列输出,期望 `\t` 是制表符,再用 `read tbl col` 解析:

```bash
mysql -sN -e "SELECT CONCAT(table_name,'\t',column_name) FROM information_schema.columns WHERE ..."
```

执行后,`while read -r tbl col` 循环里 `tbl` 的值变成了类似 `eb_system_config\tvalue` 的整串,`col` 永远为空。后续的 `UPDATE` 语句拼出来的表名是 `eb_system_config\tvalue`,MySQL 报"表不存在",但被外层 `|| true` 吞掉,导致替换实际上从未执行。

**排查**:在循环里 `echo "tbl=[${tbl}] col=[${col}]"`,输出:

```
tbl=[eb_system_config\tvalue] col=[]
```

确认 `\t` 没被解析成制表符,而是字面两个字符 `\` 和 `t`。

**根因**:MySQL 的 SQL 字符串中,转义序列 `\t` **不是**制表符。在 MySQL 默认 SQL 模式下,字符串里的 `\t` 是两个字符:反斜杠 + `t`。要让 MySQL 输出真正的制表符,需要 `CHAR(9)`:

```sql
SELECT CONCAT(table_name, CHAR(9), column_name) ...
```

这与 Bash、C、Python 等语言不同——Bash 的 `echo -e` 和 C 的 `"\t"` 都把 `\t` 解释成制表符,但 SQL 字面量字符串中 `\` 只是字面反斜杠(`NO_BACKSLASH_ESCAPES` 模式下尤其明显)。

**修复**:不拼接,直接 `SELECT` 两列,让 `mysql -sN` 的默认列分隔符(制表符)来分隔:

```bash
mysql -sN -e "SELECT table_name, column_name FROM information_schema.columns WHERE ..."
```

`mysql` 客户端在 `-s`(silent,无表头框线)和 `-N`(naked,无列名)模式下,默认列分隔符就是 `\t` 制表符,完美适配 `read -r tbl col` 的 IFS 分割。同时省去一个 `CONCAT` 调用,性能略好。

### 3.3 dry-run 测试验证

`--dry-run` 模式下的核心代码:

```bash
if [ "${DRY_RUN}" = "1" ]; then
  echo "=== 将受影响的数据库表/列 ==="
  for DB in ${DB_LIST}; do
    mysql -sN -e "SELECT table_name, column_name FROM information_schema.columns
                  WHERE table_schema='${DB}'
                    AND data_type IN ('char','varchar','text','mediumtext','longtext','tinytext','json')" \
      | while read -r tbl col; do
          cnt=$(mysql -sN "${DB}" -e "SELECT COUNT(*) FROM \`${tbl}\` WHERE \`${col}\` LIKE '%${OLD}%'" 2>/dev/null)
          [ -n "${cnt}" ] && [ "${cnt}" -gt 0 ] 2>/dev/null && \
            printf '  %s.%s.%s : %s rows\n' "${DB}" "${tbl}" "${col}" "${cnt}"
        done
  done

  echo "=== 将受影响的代码文件 ==="
  grep -rl --include="${INCLUDES[@]}" --exclude-dir=node_modules \
          --exclude-dir=vendor --exclude-dir=runtime --exclude-dir=cache \
          "${OLD}" "${WWW_ROOT}" 2>/dev/null | sed 's/^/  /'
fi
```

测试用例:把 `1dao.cc` → `dev.1dao.cc`(单对映射,不含子域,只验证流程)。

输出:

```
=== 将受影响的数据库表/列 ===
  crmeb.eb_system_config.value : 1 rows
  crmeb.eb_store_product.image : 18 rows
  crmeb.eb_store_product.slider_image : 18 rows
  crmeb.eb_store_product.description : 7 rows
  crmeb.eb_diy.value : 4 rows
  ...
=== 将受影响的代码文件 ===
  /var/www/1dao.cc/index.html
  /var/www/1dao.cc/counter.php
  /var/www/nav/index.html
  ...
```

清单数量与第一章调查结果一致,dry-run 通过。

### 3.4 实际执行测试

去掉 `--dry-run`,加 `--apply`,执行 `1dao.cc → dev.1dao.cc` 单对替换:

```bash
./change-domain.sh --apply mappings/single.txt
```

执行后,验证三处:

1. `eb_system_config` 表中 `menu`=`site_url` 的 `value`:
   ```sql
   SELECT value FROM eb_system_config WHERE menu='site_url';
   -- 改前:https://shop.1dao.cc
   -- 改后:https://dev.1dao.cc  ← 这里因为只映射 1dao.cc,shop.1dao.cc 中的 1dao.cc 也被换了,得到 dev.shop → dev.1dao.cc,验证占位符法的必要性
   ```
   (实际生产用例会同时映射 `shop.1dao.cc`,此处单对测试目的是验证主流程能跑通。)
2. 代码文件:`grep -l dev.1dao.cc /var/www/1dao.cc/index.html` 命中。
3. Redis 中 site_url 缓存:`FLUSHALL` 后再次访问,返回新域名。

主流程验证通过。

### 3.5 `set -euo pipefail` 问题

#### bug3:数据库循环中 mysql 对视图表 UPDATE 失败导致脚本退出

**现象**:脚本头部加了 `set -euo pipefail`(Bash 严格模式的标准做法, supposedly 防御性编程)。执行到数据库循环时,脚本在某条 `UPDATE` 后突然退出,后续库与文件均未处理。退出码非零。

**排查**:`set -x` 跟踪,发现是某个 `UPDATE` 语句报错:

```
ERROR 1356 (HY000): View 'crmeb.V_xxx' references invalid table(s) or column(s) or function(s) or definer/invoker of view lack privilege to use them
```

原来 `information_schema.columns` 把**视图**的列也列出来了。视图本身不能 `UPDATE`,MySQL 直接报错返回非零退出码。`pipefail` 把这个非零通过管道传给外层 `while`,触发 `set -e` 让整个脚本退出。

**根因**:`set -euo pipefail` 是"防御性编程"的常用组合,但在**循环体内可能合理失败**的场景下会过度敏感。本场景下,视图不能 UPDATE 是**预期内的非错误**,应该被容忍。

**修复**:

1. 把 `set -euo pipefail` 改为 `set -u`:`-u` 保留(防变量未定义,这是真错误);`-e` 去掉(循环中允许失败);`-o pipefail` 去掉(同理)。
2. 在每个可能失败的 `mysql` 命令后加 `|| true`,显式声明"这里允许失败":

```bash
set -u  # 注意:不再用 -e 和 -o pipefail

mysql "${DB}" -e "UPDATE \`${tbl}\` ..." || true
```

这是"防御性编程"与"容错性编程"的权衡:`-e` 适合线性脚本(每步都关键),循环 + 数据库这种"可能合理失败"的场景必须显式容错。

修改后,脚本能跑完所有库、所有表,视图报错被 `|| true` 吞掉,不影响真实表的替换。

---

## 第四章 `switch-env.sh` 设计

### 4.1 需求

`switch-env.sh` 是在 `change-domain.sh` 之上构建的"模式管理层",需求:

1. **配置驱动**:域名、库列表、快照目录等参数都写在配置文件,不在脚本里硬编码。
2. **prod / dev 双向无损切换**:从 prod 切到 dev 后,prod 的业务数据(订单、文章、评论)要能保留;切回来时这些数据回来。
3. **首次自动生成**:第一次切到 dev 时没有 dev 快照,要用 prod 快照 + 域名替换自动生成。
4. **状态可查**:`switch-env status` 显示当前模式、各快照大小与时间。
5. **快照可更新**:`switch-env snapshot` 把当前模式的最新业务数据固化到快照,避免每次切回都丢失变更。

### 4.2 设计思路:数据库快照法

#### 4.2.1 为什么不能直接"原地替换"

`change-domain.sh` 是"原地替换":直接修改当前数据库。它解决的是"换域名",但不解决"切换后如何保留上一模式的数据"。如果 prod → dev 直接原地替换,prod 的业务数据(此时已经被改成了 dev 域名)就**丢了 prod 的形态**;再切回 prod 时,要么再次原地替换(业务数据已是 dev 期间的新数据,无法回到 prod 形态),要么需要外存。

#### 4.2.2 快照法

为每个模式保存一份数据库快照:

- `prod.sql.gz`:prod 模式下的数据库全量导出
- `dev.sql.gz`:dev 模式下的数据库全量导出

切换流程:

```
保存当前模式快照 → 恢复目标模式快照 → 代码文件替换 → 清缓存 → 更新配置
```

#### 4.2.3 优势

- **业务数据不丢失**:每次切走前都把当前数据存盘,切回来时从盘恢复。
- **双向可逆**:从 prod 切到 dev 后,dev 期间产生的数据进入 `dev.sql.gz`;再切回 prod 时,这些 dev 数据被存盘,prod 数据从 `prod.sql.gz` 恢复,dev 数据不丢。
- **首次自适应**:第一次切到 dev 没有 `dev.sql.gz`,自动用 `prod.sql.gz` + 域名替换生成。

### 4.3 配置文件 `env.conf`

`env.conf` 是整个双模式系统的"事实源"(single source of truth):

```ini

## 第五章 `switch-env.sh` 实现与调试

### 5.1 第一版实现

第一版直接复制了 `change-domain.sh` 的代码作为内部函数,在 `switch-env dev` 流程中调用。结果首跑就崩了。

#### bug1:`set -euo pipefail` 导致数据库替换循环提前退出

**现象**:`switch-env dev` 执行到数据库替换阶段(从 prod 快照生成 dev 时)直接退出,后续代码替换、清缓存均未执行,留下"数据库已切到 dev 但代码仍是 prod 域名"的半残状态。

**排查**:在脚本头 `set -x` 跟踪,发现退出点在某条 `UPDATE`:

```
+ mysql crmeb -e 'UPDATE `v_xxx` SET ...'
ERROR 1356 (HY000): View 'crmeb.v_xxx' references invalid table(s) or column(s)...
+ ...  ← 此处脚本退出
```

**根因**:`set -euo pipefail` 中,`pipefail` 把管道中任一命令的非零退出码传给最后,`-e` 又捕获这个非零让整个脚本退出。视图 UPDATE 失败是预期内的,但被 `pipefail` 当作真错误。

这跟第三章 3.5 的 bug3 同根,但因为 `switch-env.sh` 直接复用 `change-domain.sh` 内部代码,问题再次出现。

**修复**:同 3.5,把脚本头改为 `set -u`,在 `mysql` 调用后加 `|| true`:

```bash
set -u  # 不用 -e -o pipefail

mysql "${DB}" -e "UPDATE ..." || true
```

修复后,`switch-env dev` 能跑完整流程。

### 5.2 数据库替换的 CONCAT 问题

#### bug2:同 3.2 的 CONCAT 问题在 switch-env 内复发

`switch-env.sh` 内部那段"取所有字符串列"的 SQL,最初也写成了 `CONCAT(table_name,"\t",column_name)`,同样输出字面量 `\t`。

**现象**:循环里 `tbl` 拿到 `eb_system_config\tvalue`,`col` 永远空。

**根因**:同 3.2,SQL 字符串中 `\t` 不是制表符。

**修复**:同 3.2,改为 `SELECT table_name, column_name` 两列,让 `mysql -sN` 的默认 tab 分隔符工作。

此处复现完全是因为代码复用时没把 `change-domain.sh` 已修复的版本同步过来。教训:**复制粘贴是 bug 传播的最快路径**。后续把共享的数据库替换逻辑抽成 `lib-domain.sh` 库,两边都 `source` 它,避免再次出现"修了一边忘另一边"。

### 5.3 首次切换测试

测试流程:

1. 在 prod 模式下执行 `switch-env init`。
2. 验证 `prod.sql.gz` 已生成。
3. 执行 `switch-env dev`。

#### 5.3.1 `switch-env init`

```bash

## 第六章 nginx dev 域名配置

### 6.1 `dev-domains.conf`

为所有 `dev.*` 子域配置 nginx server block,文件位置 `/etc/nginx/conf.d/dev-domains.conf`。这个文件与 prod 的 server block 互斥,不能同时启用同一 `server_name`,否则 nginx 会随机挑一个 server block 响应。

设计上,prod server block 用 `1dao.cc` 及其子域 `shop.1dao.cc` 等,dev server block 用 `dev.1dao.cc` 及其子域 `shop.dev.1dao.cc` 等,子域前缀加 `dev.`,完全分离。

### 6.2 端口选择:HTTP 80

dev 是测试环境,不需要 HTTPS,直接用 80 端口。这避免了证书申请、续期的运维负担。如果后续 dev 也需要 HTTPS(如测试支付回调),可以加 `listen 443 ssl` + 自签证书或 let's encrypt staging。

### 6.3 `X-Environment` 头

所有 dev server block 加:

```nginx
add_header X-Environment dev always;
```

`always` 关键字让 nginx 在错误响应(4xx、5xx)时也加上这个头,方便从浏览器开发者工具一眼分辨当前访问的是 prod 还是 dev。

### 6.4 各站 root 配置

完整 `dev-domains.conf`:

```nginx

## 第七章 双模式架构总结

### 7.1 prod 模式

- **域名**:`1dao.cc` 及其子域 `shop.1dao.cc`、`blog.1dao.cc`、`nav.1dao.cc`、`zentao.1dao.cc`。
- **HTTP**:`dev-on/off` 机制(本系列第三部分详述)+ letsencrypt 证书,443 端口。
- **业务数据**:真实用户访问的真实数据,严禁破坏。

### 7.2 dev 模式

- **域名**:`dev.1dao.cc` 及其子域 `shop.dev.1dao.cc`、`blog.dev.1dao.cc`、`nav.dev.1dao.cc`、`zentao.dev.1dao.cc`。
- **HTTP**:`switch-env` 切换 + 纯 HTTP 80 端口,无证书。
- **业务数据**:从 prod 快照生成,可在 dev 模式下任意修改测试,不影响 prod。

### 7.3 两种模式的关系

**互斥**:同一份数据库(同一份业务数据)在同一时刻只能用一种模式。因为业务数据中的 URL 字段已经被替换为对应模式的域名,无法"同时支持两种模式"。这是数据库快照法的固有约束。

### 7.4 切换:`switch-env prod/dev`

切换命令:

```
switch-env dev   # prod → dev
switch-env prod  # dev → prod
```

切换耗时主要在 `mysqldump` 与 `mysql` 导入,4 个库共 1.8M 压缩数据,本机 SSD 上总耗时约 5-8 秒,可接受。

### 7.5 文件位置

| 文件 | 路径 | 作用 |
|------|------|------|
| 配置文件 | `/etc/1dao/env.conf` | 模式、域名、库列表、快照目录 |
| 快照目录 | `/var/lib/1dao-env/` | 存放 `prod.sql.gz`、`dev.sql.gz` |
| 工具脚本 | `/usr/local/bin/change-domain.sh` | 单次域名替换 |
| 工具脚本 | `/usr/local/bin/switch-env.sh` | 模式切换封装 |
| nginx 配置 | `/etc/nginx/conf.d/dev-domains.conf` | dev 子域 server block |
| 共享库 | `/usr/local/lib/lib-domain.sh` | 数据库替换的共享函数(待重构) |

完整架构图:

```
+--------------------------------------------------------+
|                    用户命令                              |
|        switch-env dev/prod/status/init                  |
+----------------------------+---------------------------+
                             |
                             v
+--------------------------------------------------------+
|                  switch-env.sh                          |
|  - 读 env.conf                                          |
|  - snapshot_save / snapshot_restore                     |
|  - 调 change-domain.sh --files-only                     |
|  - clear_cache_and_reload                               |
|  - update env.conf CURRENT                              |
+----------------------------+---------------------------+
                             |
         +-------------------+-------------------+
         |                                       |
         v                                       v
+--------------------+              +--------------------------+
| /var/lib/1dao-env/ |              | change-domain.sh         |
|  prod.sql.gz       | <----恢复--- |  - 占位符法              |
|  dev.sql.gz        | <----保存--- |  - 数据库全类型覆盖     |
+--------------------+              |  - 文件扩展名白名单      |
                                    |  - 排除目录黑名单        |
                                    +-----------+--------------+
                                                |
                                                v
                                    +--------------------------+
                                    | /var/www/*               |
                                    |  代码文件 (24 个)        |
                                    +--------------------------+
                                                |
                                                v
                                    +--------------------------+
                                    | 缓存清理                 |
                                    |  redis FLUSHALL          |
                                    |  rm runtime/* cache/*    |
                                    |  reload php-fpm nginx    |
                                    +--------------------------+
                                                |
                                                v
                                    +--------------------------+
                                    | nginx dev-domains.conf   |
                                    |  listen 80               |
                                    |  X-Environment: dev      |
                                    +--------------------------+
```

---

## 第八章 经验教训

### 8.1 占位符法是批量替换的核心技术

字符串批量替换的核心难题是"互为子串的多对映射"。占位符法通过"中转一层"把多对多降为多次一对一,从根本上消除了误伤。这个思路不仅适用于域名替换,还适用于:

- API 版本号替换:`v1 → v2`
- 数据库表前缀替换:`eb_ → mall_`
- 文件路径迁移:`/var/www/old → /var/data/new`

只要满足"原值集合与目标值集合存在子串关系",就该用占位符法。**直接顺序替换必翻车**,这是工程经验,不是理论推导。

### 8.2 `CONCAT` 的 `\t` 字面量陷阱

SQL 字符串中 `\t` 是字面量两个字符,不是制表符。这与 Bash、C、Python 截然不同:

| 语言 | `"\t"` 的含义 |
|------|---------------|
| Bash | 制表符(经 `echo -e` 或 `$'...'`) |
| C | 制表符 |
| Python | 制表符 |
| **MySQL SQL 字符串** | **字面量 `\` + `t`**(默认模式) |

要在 MySQL 中输出真正的制表符:

- `CHAR(9)`:显式字符函数,无歧义。
- 让 `mysql -sN` 客户端用默认列分隔符:直接 `SELECT col1, col2`,客户端自动用 `\t` 分隔多列输出。

后者更简洁,且省一次 `CONCAT` 调用。本工具采用后者。

延伸陷阱:同理,`\n`、`\r` 在 SQL 字符串里也是字面量,不是换行。要从 SQL 输出换行,用 `CHAR(10)`、`CHAR(13)`。

### 8.3 `set -euo pipefail` 在循环中的陷阱

`set -euo pipefail` 是"严格模式"标准做法,适合**线性脚本**:

- `-u`:变量未定义即报错。绝大多数场景都该开,防止拼错变量名静默通过。
- `-e`:命令失败即退出。适合"每步都关键"的线性流程,失败应该立刻终止避免后续在错误状态上继续。
- `-o pipefail`:管道中任一非零退出码传到最后。配合 `-e`,让 `cmd1 | cmd2` 中 cmd1 失败也能被捕获。

但在**循环体内可能合理失败**的场景,这套组合会过度敏感:

```bash
set -euo pipefail
for x in $items; do
  cmd $x  # 某些 x 会让 cmd 失败,但这是预期的
done

## 附录 A:`change-domain.sh` 关键函数完整代码

```bash
#!/usr/bin/env bash
set -u

CONF_FILE="${CONF_FILE:-/etc/1dao/env.conf}"
WWW_ROOT="${WWW_ROOT:-/var/www}"
DB_LIST="${DB_LIST:-crmeb emlog walle zentao}"

DRY_RUN=0
DB_ONLY=0
FILES_ONLY=0
APPLY=0
MAP_FILE=""

parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --dry-run) DRY_RUN=1; shift ;;
      --db-only) DB_ONLY=1; shift ;;
      --files-only) FILES_ONLY=1; shift ;;
      --apply) APPLY=1; shift ;;
      --www-root) WWW_ROOT="$2"; shift 2 ;;
      --db-list) DB_LIST="$2"; shift 2 ;;
      -h|--help) usage; exit 0 ;;
      *) MAP_FILE="$1"; shift ;;
    esac
  done
  [ -n "${MAP_FILE:-}" ] || { echo "need MAPPING_FILE"; exit 1; }
}


## 附录 C:常用命令速查

```bash

## 附录 D:故障排查

### D.1 切换后访问 dev 域名仍跳到 prod

**原因**:nginx 没把 dev server block 加载,或 prod server block 的 `server_name` 与 dev 冲突。

**排查**:
```bash
nginx -T 2>&1 | grep -A2 server_name
curl -sI dev.1dao.cc | grep -i 'x-environment\|server:'
```

**修复**:`nginx -t` 验证配置,`systemctl reload nginx` 重载。

### D.2 切换后站点访问 500

**原因**:数据库切换成功但代码文件没切,或反之。

**排查**:
```bash
switch-env status  # 看 CURRENT
grep -l dev.1dao.cc /var/www/1dao.cc/index.html  # 看代码是否切
mysql crmeb -e "SELECT value FROM eb_system_config WHERE menu='site_url'"  # 看数据库是否切
```

**修复**:用 `change-domain.sh --apply` 把对应方向补一次。

### D.3 切换后 Redis 数据仍指向旧域名

**原因**:`clear_cache` 没执行或 Redis 没连上。

**排查**:
```bash
redis-cli ping  # 应返回 PONG
redis-cli keys '*site_url*'  # 看是否还有旧值
```

**修复**:`redis-cli FLUSHALL`,然后 `systemctl reload php-fpm`。

---

## 结语

`change-domain.sh` 与 `switch-env.sh` 是整个开发测试环境方案的执行核心。前者解决"如何做单次无损替换",后者解决"如何在两种模式间无损切换"。两者结合,加上 `dev-on/off`(prod 模式下临时开 dev 入口)与 nginx 配置,构成了完整的"prod / dev 双模式架构"。

关键设计决策:

1. **占位符法**:消除多对映射的子串误伤。
2. **数据库快照法**:实现业务数据的双向无损切换。
3. **配置驱动**:`env.conf` 让方案可迁移、可审计、可测试。
4. **`grep -rl --include/--exclude-dir`**:比 `find + xargs` 更可靠的代码扫描。

关键 bug 教训:

1. SQL `CONCAT` 的 `\t` 是字面量,不是制表符。
2. `set -euo pipefail` 在循环中过度敏感,需 `set -u` + `|| true`。
3. 复制粘贴传播 bug,共享逻辑应抽库 `source`。

本系列后续部分将围绕这个双模式架构展开日常运维、监控、CI/CD 集成等内容。




<div style="page-break-after: always;"></div>

<a id="chapter-5"></a>
# 第5卷: 1dao 开发测试环境搭建工作总结(五):经验教训与最佳实践

> **卷首语**: 本卷聚焦于从整个工作过程中提炼出来的经验教训与最佳实践，把每一次踩坑都转译成可复用的工程准则。

---



## 第一章:环境隔离的教训

环境隔离不是"锦上添花"的工程规范,而是"非做不可"的生存底线。1dao 的域名迁移涉及数据库、配置文件、Nginx 站点等多处耦合点,任何一个误操作都可能污染线上数据。本章从线上操作的风险出发,推演到虚拟机隔离的价值,再到 VMware/Docker/Vagrant 三种隔离手段的取舍,最后给出"测试先行"的硬性纪律。

### 1.1 线上服务器操作的风险

**现象描述**:在最初评估域名迁移可行性时,曾短暂考虑过直接在生产服务器上跑 `change-domain.sh`。理由看似合理——"反正改完就能立即生效,免去环境切换的麻烦"。但一旦把 `REPLACE` 语句、`sed -i`、Nginx `default_server` 改动叠加起来,任何一步出错都会立即落到真实业务数据上。

**原因分析**:线上服务器是"单点且不可逆"的环境。具体风险包括:
- 数据库批量 `REPLACE` 一旦方向写反(例如把 `1dao.cc` 替换为 `dev.1dao.cc`,然后又把刚替换出来的 `dev.dev.1dao.cc` 再替换一遍),会引发雪崩式脏数据;
- `mysqldump` 备份如果只做了表结构没做数据,回滚时业务数据彻底丢失;
- Nginx 配置改错后 `systemctl reload` 失败,整个站点 502,影响真实用户;
- SSH 会话中断时,正在执行的脚本可能留下半完成的中间状态(例如部分表已替换、部分未替换),且无快照可回滚。

**解决方案**:线上服务器只允许"读"操作和经过测试环境验证过的"写"操作。任何修改类动作,先在镜像测试环境跑一遍完整流程,确认无误后再以"最小变更集"的形式推到线上。

**最佳实践**:把线上服务器视为只读副本,任何破坏性操作都必须先在隔离环境完成 dry-run 与真实 run 两轮验证。线上操作的每一条命令都应能在测试环境的 shell history 中找到对应记录,确保"已验证"。

### 1.2 虚拟机隔离的价值

**现象描述**:在 VMware 虚拟机中搭建的镜像测试环境,在整个迁移过程中被反复 save/restore 了二十多次。每一次"改坏了"都能瞬间回滚到上一个已知良好状态,而宿主机和线上服务器毫发无损。

**原因分析**:虚拟机隔离提供了四重保障:
1. **零风险**:虚机的磁盘是宿主机上的一个文件,任何破坏都局限在该文件内,不会触及宿主系统;
2. **快照回滚**:VMware 的 Snapshot 功能可在秒级恢复到任意时间点,等同"游戏存档";
3. **环境一致性**:虚机的 OS、PHP、MySQL、Nginx 版本可与线上严格对齐,避免"在我机器上能跑"的悖论;
4. **可移植**:虚机文件可整体复制到另一台宿主机或交付给同事,环境随文件迁移。

**解决方案**:为每一类高风险操作(域名迁移、版本升级、依赖变更)建立独立虚机,操作前打快照,操作失败即回滚。

**最佳实践**:虚机不是"备选方案",而是高风险操作的"默认载体"。把"先打快照再动手"写成肌肉记忆,成本几乎为零,收益则是规避了不可估量的回滚成本。

### 1.3 VMware vs Docker vs Vagrant 的选择

**现象描述**:在选型阶段,曾对比过三种隔离方案,各自的取舍如下表:

| 维度 | VMware Fusion/Workstation | Docker | Vagrant (VirtualBox) |
| --- | --- | --- | --- |
| 隔离层级 | 完整硬件级虚拟化 | 进程级容器 | 完整虚拟化(封装 VirtualBox) |
| 资源开销 | 高(独立内核+完整OS) | 低(共享宿主内核) | 高(同 VMware) |
| 网络模式 | NAT/Bridge/Host-only 灵活 | bridge/overlay 较复杂 | 同 VirtualBox |
| 与物理机相似度 | 最高 | 最低(无独立内核) | 中 |
| 快照能力 | 强(秒级) | 弱(需 commit/image) | 中(VirtualBox snapshot) |
| 学习曲线 | 低(GUI 友好) | 中(需懂 Dockerfile) | 中(需写 Vagrantfile) |
| 适合场景 | 完整镜像测试环境 | 微服务/单一进程隔离 | 团队统一环境分发 |

**原因分析**:1dao 是一台运行 Nginx+PHP-FPM+MySQL 的传统 LEMP 单机,迁移操作涉及系统级服务(systemd)、网络配置(bridge IP)、多端口监听。Docker 的容器模型在"系统服务编排"上反而不如完整虚机直观;Vagrant 本质是 VirtualBox 的封装,性能与 VMware 相当但 GUI 体验弱;VMware 在"最接近物理机"这一点上无可替代。

**解决方案**:主测试环境用 VMware(bridge 模式固定 IP);轻量级的脚本调试(如 `change-domain.sh` 的纯逻辑测试)可用 Docker 起一个 MySQL 容器快速验证 SQL;Vagrant 仅在需要向团队分发统一环境时考虑。

**最佳实践**:隔离手段的选择服从于"被隔离对象"的形态——整机关联操作用完整虚机(VMware),单一服务用容器(Docker),团队分发用 Vagrant。不要用一种方案硬套所有场景。

### 1.4 最佳实践:永远先在测试环境验证,再推线上

把前三节凝结成一条铁律:**任何对线上有潜在影响的变更,必须经过"测试环境 dry-run → 测试环境真实执行 → 线上 dry-run → 线上真实执行"四阶段**。其中前三个阶段任一失败都不得进入下一阶段。这条规则看似冗长,实则把"不可逆"压缩成了"可逆",是整个迁移工作零事故的根本原因。

---

## 第二章:域名管理的教训

域名是 1dao 迁移的核心对象,也是踩坑最密集的区域。从 TLD 选择到 HSTS 不可逆性,再到证书 SAN 的覆盖范围,每一条教训都来自真实的故障现场。

### 2.1 .dev TLD 的陷阱

**现象描述**:最初计划把开发域名定为 `1dao.dev`,理由是"`.dev` 一看就是开发环境"。结果 Chrome 一访问就强制跳转到 `https://1dao.dev/`,并报证书错误——而本地根本没配 `.dev` 的证书。

**原因分析**:`.dev` 是 Google 在 2015 年购买的 gTLD,并于 2017 年起将其加入 Chrome 的 HSTS Preload List,强制所有 `.dev` 域名走 HTTPS。这意味着:
- 即使在 `/etc/hosts` 里把 `1dao.dev` 指向 `127.0.0.1`,浏览器仍会强制 HTTPS;
- 本地自签证书无法通过 Chrome 的验证(除非手动导入并信任);
- 这个强制行为在浏览器层面,无法通过服务器配置绕过。

```text

## 第三章:浏览器行为的教训

浏览器是最后一个"坑",因为它在用户和服务器之间插入了多层缓存:DNS 缓存、HSTS 缓存、Socket 连接池、Cookie 存储。这些缓存各自独立,任何一层没清干净都会让 hosts 切换"看似失效"。

### 3.1 浏览器 DNS 缓存独立于系统

**现象描述**:改完 `/etc/hosts` 后 `ping dev.1dao.cc` 已经指向新 IP,但 Chrome 仍然访问旧 IP。`dscacheutil -flushcache`、`sudo killall -HUP mDNSResponder` 都试过,无效。

**原因分析**:Chrome 和 Firefox 都有**独立的**应用级 DNS 缓存,与系统 DNS 缓存互不相干。Chrome 的 DNS 缓存默认 TTL 约 1 分钟,但在某些场景下会保留更久(例如已建立的 keep-alive 连接)。

**解决方案**:在 Chrome 地址栏访问 `chrome://net-internals/#dns`,点击 "Clear host cache"。

**最佳实践**:改完 hosts 后,**系统级**和**浏览器级** DNS 缓存都要清,二者缺一不可。

### 3.2 清除浏览器 DNS 缓存

```text
Chrome:
  地址栏 → chrome://net-internals/#dns → Clear host cache

Firefox:
  地址栏 → about:networking#dns → Clear DNS Cache

Safari:
  无独立入口,需重启浏览器或清空全部缓存
```

**最佳实践**:把 `chrome://net-internals/#dns` 加入书签,作为域名切换后的标准动作之一。

### 3.3 HSTS 缓存

**现象描述**:在清理完 DNS 缓存后,Chrome 仍然把 `http://dev.1dao.cc/` 307 重定向到 `https://`。这是 HSTS 缓存在起作用。

**原因分析**:HSTS 策略存储在浏览器本地的"传输安全状态"表中,与 DNS 缓存是两套独立机制。清 DNS 不会清 HSTS。

**解决方案**:`chrome://net-internals/#hsts` → 在 "Delete domain security policies" 输入 `1dao.cc`(注意是根域名,会连带清除子域名的策略)→ Delete。

**最佳实践**:凡是涉及 HTTPS 切换、域名迁移的场景,清理顺序应是:**DNS 缓存 → HSTS 缓存 → Socket 连接池**,三者依次执行。

### 3.4 Socket 连接池

**现象描述**:DNS 和 HSTS 都清完,Chrome 偶尔还是会"间歇性"访问到旧 IP。抓包发现是某些 keep-alive 连接仍保持着旧的 TCP 会话。

**原因分析**:Chrome 为提升性能,会复用已建立的 TCP/TLS 连接(连接池),这些连接在空闲一段时间内不会关闭。即使 DNS 已经解析到新 IP,旧连接仍走旧 IP。

**解决方案**:`chrome://net-internals/#sockets` → "Flush socket pools"。

**最佳实践**:完整的浏览器缓存清理三件套:`#dns`(Clear host cache)→ `#hsts`(Delete domain security policies)→ `#sockets`(Flush socket pools)。

### 3.5 无痕窗口的价值

**现象描述**:在反复清理缓存的疲惫中,发现一个更省事的方法——直接开无痕窗口验证 hosts 切换。

**原因分析**:无痕窗口启动时使用一个全新的、临时的 profile,DNS/HSTS/Socket/Cookie 缓存均为空。它对 hosts 的解析完全依赖系统当前状态,是"最干净"的验证环境。

**解决方案**:hosts 切换后,优先用无痕窗口验证;确认无误后再处理常规窗口的缓存。

**最佳实践**:**无痕窗口是 hosts 验证的首选工具**。它把"清理缓存"这一易忘步骤直接绕过,是最低成本的验证手段。

### 3.6 Cookie 共享机制

**现象描述**:在 `dev.1dao.cc`(测试)和 `1dao.cc`(线上)同时登录后,发现测试环境的登录态偶尔"串"到线上,或反之。退出一个,另一个也跟着退出。

**原因分析**:Cookie 的作用域由 `Domain` 属性决定,且**不区分端口**:
- 若 Cookie 的 `Domain=.1dao.cc`,则 `1dao.cc`、`dev.1dao.cc`、`1dao.cc:8080`、`1dao.cc:443` **全部共享**;
- 端口不同不会隔离 Cookie(这是 RFC 6265 的设计,出于历史兼容);
- 测试环境与线上同根域名,必然共享 `.1dao.cc` 作用域的 Cookie。

```http
Set-Cookie: session=abc123; Domain=.1dao.cc; Path=/; HttpOnly

## 第四章:Shell 脚本编程的教训

迁移过程中写了大量 shell 脚本(`dev-on.sh`、`dev-off.sh`、`switch-env.sh`、`change-domain.sh`),每一个坑都来自真实的调试现场。

### 4.1 set -euo pipefail 的陷阱

**现象描述**:`change-domain.sh` 早期版本头部写着 `set -euo pipefail`,在循环遍历数据库表做 `UPDATE` 时,某张视图表报错,整个脚本立即退出,导致后半部分表未替换,数据半残。

**原因分析**:
- `-e`:任一命令非零退出即终止脚本。在 `for` 循环中,一个表的 `UPDATE` 失败(例如视图不可写)会终止整个循环,后续表全部跳过;
- `-u`:引用未定义变量报错。有助于发现拼写错误,但在某些动态构造变量名的场景会误伤;
- `-o pipefail`:管道中任一环节非零则整个管道非零。`mysql ... | grep ... | awk ...` 中 mysql 对视图 UPDATE 失败会被 pipefail 传播,导致 grep/awk 误判。

```bash

## 第五章:数据库操作的教训

数据库是 1dao 域名迁移的主战场,所有教训都围绕"如何在不丢数据、不脏数据的前提下完成批量替换"。

### 5.1 mysqldump --databases 的区别

**现象描述**:迁移前用 `mysqldump db1 db2 > backup.sql` 备份,恢复时 `mysql < backup.sql` 报错 `Unknown database 'db1'`。

**原因分析**:`mysqldump` 是否加 `--databases` 选项,导出内容差异巨大:

| 选项 | 导出内容 | 恢复方式 |
| --- | --- | --- |
| `mysqldump db1 db2 > x.sql` | 仅表结构和数据 | 需手动 `CREATE DATABASE` 后再恢复 |
| `mysqldump --databases db1 db2 > x.sql` | 含 `CREATE DATABASE` 和 `USE` | 恢复时自动建库 |
| `mysqldump --all-databases > x.sql` | 所有库 + 建库语句 | 自动恢复所有库 |

```bash

## 第六章:网络配置的教训

网络是虚机与宿主、测试与线上之间的桥梁,任何一处配置不当都会让"明明改对了"的方案"怎么都不生效"。

### 6.1 VMware 网络模式

**现象描述**:虚机最初用 NAT 模式,IP 是 `192.168.x.x` 动态分配,每次重启可能变化,导致 `/etc/hosts` 和 Nginx 配置经常失效。

**原因分析**:VMware 三种网络模式的本质差异:

| 模式 | 虚机 IP | 与外部网络 | 与宿主机 | 适用场景 |
| --- | --- | --- | --- | --- |
| NAT | 动态(vmnet8 DHCP) | 经宿主 NAT 出网 | 可互访 | 隔离开发,不需固定 IP |
| Bridge | 与宿主同网段独立 IP | 直接在局域网 | 可互访 | 需固定 IP、需被其他设备访问 |
| Host-only | 动态(vmnet1 DHCP) | 不可上网 | 仅宿主可访 | 纯本地隔离测试 |

**解决方案**:开发环境改用 Bridge 模式,在路由器 DHCP 里给虚机 MAC 绑定固定 IP(如 `192.168.1.100`),从此 IP 永不变。

**最佳实践**:开发环境用 **Bridge 模式 + DHCP 静态绑定**,获得固定 IP;NAT 适合临时调试;Host-only 适合纯隔离的安全测试。

### 6.2 /etc/hosts 与 DNS 解析

**现象描述**:改完 `/etc/hosts` 后,`ping` 立即生效,但 `curl` 偶尔不生效。

**原因分析**:域名解析顺序由 `/etc/nsswitch.conf`(Linux)或 `/etc/hosts` 优先级(macOS 内置)决定:

```text

## 第七章:版本控制与协作的教训

1dao 的迁移脚本通过 Gitee 组织仓库管理,涉及多账号、权限、敏感信息等典型协作问题。

### 7.1 SSH key 与 Gitee 账号的绑定

**现象描述**:用个人 Gitee 账号的 SSH key push 组织仓库,报 `Permission denied (publickey)`。

**原因分析**:Gitee 的 SSH key 是**账号级**绑定的,一个 key 只能绑定到一个账号。当个人账号未被添加为组织仓库协作者时,push 会被拒。

**解决方案**:多账号场景,用 `~/.ssh/config` 管理多个 IdentityFile:

```ssh-config

## 第八章:文档化的重要性

迁移工作能顺利完成并复用,关键在于每一步都留有文档。本章阐述文档化的具体形态与价值。

### 8.1 技术文档的价值

**现象描述**:半年后再次需要切换环境时,凭记忆已经记不清 `switch-env.sh` 的参数顺序,只能翻 shell history。

**原因分析**:人脑对操作细节的记忆衰减极快,而迁移类操作低频但高风险,正是"最需要文档"的场景。

**解决方案**:为每个方案写技术文档,包含三大要素:
- **架构图**:整体结构与数据流;
- **使用步骤**:从零到可用的可复现命令序列;
- **故障排查**:常见错误的诊断与解决。

**最佳实践**:文档不是"写完就完",而是"有人能用它独立复现方案"——以这个标准衡量文档的完备度。

### 8.2 文档结构

经过多次迭代,沉淀出以下固定结构:

```text
1. 概述        ← 这个方案解决什么问题
2. 架构        ← ASCII 架构图 + 组件说明
3. 使用步骤     ← 从零到可用的命令序列
4. 命令速查     ← 常用命令一行说明
5. 故障排查     ← 现象 → 原因 → 解决 三段式
6. 维护事项     ← 定期清理、证书续期等
```

**最佳实践**:固定文档结构,让读者形成"去第几节找什么"的预期,降低检索成本。

### 8.3 ASCII 架构图的优势

**现象描述**:早期文档用图片画架构图,结果图片丢失、版本不一致、无法 diff。

**原因分析**:图片是二进制文件,版本控制无法 diff,且依赖外部工具渲染。

**解决方案**:用 ASCII 画架构图:

```text
                    +-----------------+
                    |   宿主机(macOS) |
                    |                 |
   浏览器 ──────────►|  /etc/hosts:    |
   (Chrome)         |  192.168.1.100  |
                    |    dev.1dao.cc  |
                    +--------+--------+
                             │ bridge
                    +--------▼--------+
                    |   VMware 虚机   |
                    |  192.168.1.100  |
                    |                 |
                    |  Nginx:443 ─────► PHP-FPM
                    |     │           │
                    |     ▼           ▼
                    |  MySQL ←── 业务数据
                    +-----------------+
```

**优势**:
- 纯文本,版本可控,可 diff;
- 无需任何工具渲染,任何编辑器可读;
- 修改成本极低。

**最佳实践**:架构图优先用 ASCII;仅在需要表达复杂视觉关系(如时序图)时才用专业工具,并导出文本版本一并入库。

### 8.4 脚本即文档

**现象描述**:某些脚本写完后,使用者仍需翻 README 才知道参数。

**原因分析**:脚本本身没有 `--help`,所有信息都在外部文档,二者容易脱节。

**解决方案**:每个脚本内置 `--help`:

```bash
#!/usr/bin/env bash

## 第九章:工作方法论

前八章是"具体教训",本章提炼为"通用方法论",可迁移到任何类似的系统工程任务。

### 9.1 先调查后动手

**教训**:域名替换前若不先全量扫描耦合点,必然漏改。

**方法论**:任何"批量修改"类任务,第一步是**穷举式调查**——用 `information_schema` 扫所有字符串列、用 `grep -rl` 扫所有配置文件、用 `find` 扫所有脚本,把"影响面"先摸清,再动手。

```bash

## 第十章:最佳实践速查表

以下是全书所有最佳实践的分类汇总,可作为日常工作的 checklist。

### 10.1 环境隔离

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 1 | 线上服务器视为只读副本,破坏性操作先在隔离环境验证 | 1.1 |
| 2 | 高风险操作默认在虚机进行,先打快照再动手 | 1.2 |
| 3 | 整机关联操作用 VMware,单一服务用 Docker,团队分发用 Vagrant | 1.3 |
| 4 | 变更走"测试 dry-run → 测试执行 → 线上 dry-run → 线上执行"四阶段 | 1.4 |

### 10.2 域名管理

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 5 | 永远不用 `.dev/.app/.page` 等已 preload HSTS 的 TLD 做开发域名 | 2.1 |
| 6 | 开发域名安全池:RFC 2606 保留 TLD(`.test/.example/.invalid/.localhost`)或真实域名子域名 | 2.2 |
| 7 | 优先用"现有域名 + 子域名前缀"(`dev./test./local.`),复用通配符证书 | 2.3 |
| 8 | 开发环境绝不下发 HSTS 头,或 `max-age=0` 主动撤销 | 2.4 |
| 9 | 证书务必把所有需要的域名写入 SAN,优先用通配符证书覆盖子域名 | 2.5 |

### 10.3 浏览器

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 10 | 改完 hosts 清两遍缓存:系统级(`dscacheutil`/`mDNSResponder`)+ 浏览器级(`chrome://net-internals/#dns`) | 3.1/3.2 |
| 11 | HSTS 缓存独立于 DNS,需在 `#hsts` 单独清除 | 3.3 |
| 12 | 完整清理三件套:`#dns` → `#hsts` → `#sockets` | 3.4 |
| 13 | hosts 验证首选无痕窗口,绕过所有缓存 | 3.5 |
| 14 | 测试与线上同根域名时,用双浏览器或独立 Profile 隔离 Cookie | 3.6 |

### 10.4 Shell 脚本

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 15 | 顶层脚本用 `set -euo pipefail`,容错循环用 `set -u` + `\|\| true` | 4.1 |
| 16 | 不要用 `CONCAT` 拼 tab,`mysql -sN` 多列输出天然 tab 分隔 | 4.2 |
| 17 | 跨平台文件内替换用 `perl -pi -e`,避开 `sed -i` 差异 | 4.3 |
| 18 | 文件内容搜索优先用 `grep -r --include --exclude-dir` | 4.4 |
| 19 | 数组按属性排序:`printf \| awk 加前缀 \| sort \| cut 去前缀` | 4.5 |

### 10.5 数据库

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 20 | 多库备份必须加 `--databases`,恢复时自动建库 | 5.1 |
| 21 | 频繁切换的多环境用"快照 save/restore"代替 dump/restore | 5.2 |
| 22 | 批量替换始终用占位符法,即使单向替换也要用 | 5.3 |
| 23 | 不要手动列举表,用 `information_schema.columns` 自动发现字符串列 | 5.4 |

### 10.6 网络

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 24 | 开发环境用 Bridge 模式 + DHCP 静态绑定,获得固定 IP | 6.1 |
| 25 | hosts 修改后 flush 系统 DNS 缓存,再清浏览器缓存 | 6.2 |
| 26 | 断网时 hosts 解析可能失效,用 IP + `Host` 头直连 | 6.3 |
| 27 | `default_server` 影响所有未匹配请求,排查"莫名 444"时优先检查 | 6.4 |

### 10.7 版本控制

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 28 | 多账号用 `~/.ssh/config` 的 Host 别名 + `IdentityFile` 隔离 | 7.1 |
| 29 | push 失败先确认:① SSH key 绑定的账号;② 该账号是否在协作者列表 | 7.2 |
| 30 | 组织仓库用 `git config user.email` 覆盖全局身份 | 7.3 |
| 31 | `.gitignore` 第一条是 `.env*`;token 泄露先撤销再清理历史 | 7.4 |

### 10.8 文档

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 32 | 每个方案写技术文档,标准结构:概述→架构→步骤→速查→排障→维护 | 8.1/8.2 |
| 33 | 架构图优先用 ASCII,纯文本可 diff | 8.3 |
| 34 | 每个脚本内置 `--help`,注释解释"为什么"而非"是什么" | 8.4 |

### 10.9 方法论

| # | 最佳实践 | 来源章节 |
| --- | --- | --- |
| 35 | 先调查后动手:穷举式扫描影响面,产出"影响面清单"再修改 | 9.1 |
| 36 | 先 dry-run 后执行:破坏性脚本必须支持 `--dry-run` | 9.2 |
| 37 | 先备份后修改:`mysqldump --databases` + 虚机快照双保险 | 9.3 |
| 38 | 小步快跑:改一处→验证→存档,二分定位出错点 | 9.4 |
| 39 | 重复三次以上的操作写成脚本 | 9.5 |
| 40 | 配置驱动:可变参数抽到 `env.conf`,脚本读配置 | 9.6 |
| 41 | 双模式共存:不强求统一,按场景选择 | 9.7 |

---

## 结语

1dao 的开发测试环境搭建,表面是"搭个虚机、改个域名"的工程任务,实质是一次完整的"系统工程方法论"演练。从线上风险的敬畏,到缓存层次的清剿,从 shell 的边界陷阱,到数据库的占位符哲学,每一条教训都来自真实的故障现场,每一条最佳实践都经过反复验证。

这份总结的价值不在于"1dao 这一次怎么做的",而在于**它提炼出的 41 条最佳实践可以迁移到任何类似的系统工程任务**——无论是下一次域名迁移、版本升级,还是全新项目的环境搭建。把它们作为 checklist,把"先调查、先 dry-run、先备份、小步快跑、工具化、配置驱动"作为肌肉记忆,工程风险就能被压到最低,工程效率就能被拉到最高。

工程能力的本质,不是知道多少命令,而是知道每个命令背后有多少坑,以及如何在动手之前就把这些坑填平。这正是本系列总结想要传达的核心。




<div style="page-break-after: always;"></div>

<a id="chapter-6"></a>
# 第6卷: 1dao 开发测试环境搭建工作总结(六):技术原理解析与故障排查手册

> **卷首语**: 本卷聚焦底层技术原理的深度解析，以及实际遇到和可能遇到的故障的排查手册。

---



## 第一章:DNS 解析机制深度解析

### 1.1 DNS 解析层级

当用户在浏览器地址栏输入 `dev.1dao.cc` 并按下回车,浏览器并不会直接发起 DNS 查询,而是按以下层级顺序逐级查找。理解这一顺序,是排查"为什么改了 hosts 还是不生效"这类问题的前提。

```
浏览器自身 DNS 缓存
        ↓ (未命中)
操作系统级 DNS 缓存 (macOS 上为 mDNSResponder 维护的缓存)
        ↓ (未命中)
/etc/hosts 文件 (本地静态映射)
        ↓ (未命中)
系统配置的 DNS 服务器 (路由器下发 / 手动设置)
        ↓ (未命中)
递归向上查询根 → 顶级域 → 权威服务器
```

各层级的特性:

| 层级 | 位置 | TTL 控制方 | 清除方式 |
|------|------|-----------|---------|
| 浏览器 DNS 缓存 | 进程内存 | 浏览器策略 | `chrome://net-internals/#dns` 清除 |
| 系统 DNS 缓存 | mDNSResponder 进程 | mDNSResponder 内部 | `sudo dscacheutil -flushcache` |
| `/etc/hosts` | 文件 | 手动 | 直接编辑文件 |
| DNS 服务器 | 网络侧 | zone 文件 TTL | 由 DNS 管理员修改 |

一个常见误区是认为"修改了 `/etc/hosts` 立刻生效"。事实上,如果系统 DNS 缓存中仍有该域名的旧记录,且 mDNSResponder 没有主动失效该条目,那么 `/etc/hosts` 的修改可能不会被立即查询到。这与 `nsswitch.conf` 的解析顺序共同决定了实际行为。

### 1.2 nsswitch.conf:host 配置行决定解析顺序

macOS 的名字解析服务配置文件位于 `/etc/nsswitch.conf`(部分 macOS 版本没有该文件,此时采用系统默认顺序)。其中 `hosts` 行决定了名字解析的来源顺序:

```
hosts: files dns mdns
```

含义逐项解析:

- `files`:优先读取 `/etc/hosts` 文件。如果命中,直接返回结果,不再向 DNS 服务器查询。
- `dns`:在 `files` 未命中后,向 `/etc/resolv.conf` 中配置的 DNS 服务器发起递归查询。
- `mdns`:Multicast DNS,主要用于 `.local` 域名和局域网服务发现(如打印机、AirPlay 设备)。

将顺序改为 `dns files mdns` 会导致 DNS 服务器响应优先于本地 hosts 文件。这在某些场景下是灾难性的:当你想在本地把 `1dao.cc` 指向虚机 IP,但公网 DNS 中 `1dao.cc` 指向线上服务器,且 DNS 服务器返回的 TTL 较长,那么你的 hosts 修改将完全失效。

macOS 默认没有 `/etc/nsswitch.conf` 文件,但其内部解析模块遵循 `files dns` 的默认顺序。可通过 `scutil --dns` 查看实际生效的 DNS 配置上下文:

```bash
scutil --dns

## 第二章:HSTS 与 HTTPS 深度解析

### 2.1 HSTS 机制:HTTP Strict Transport Security(RFC 6797)

HSTS 是浏览器端的 HTTPS 强制策略,通过 HTTP 响应头声明:

```
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
```

各字段含义:

- **max-age**:策略有效期(秒)。浏览器在此期间内,对该域名的所有 HTTP 请求会被自动重写为 HTTPS。本项目中典型值为 31536000(1 年)。
- **includeSubDomains**:策略扩展到当前域名的所有子域名。例如对 `1dao.cc` 设置,则 `dev.1dao.cc`、`admin.1dao.cc`、`api.1dao.cc` 都会受影响。
- **preload**:声明该域名愿意被加入浏览器的 preload list(详见 2.2)。这不是立即生效的指令,需要站长主动到 [hstspreload.org](https://hstspreload.org/) 提交申请。

HSTS 的核心陷阱:**首次访问不生效**。浏览器必须先以 HTTPS 访问过该域名、并收到了带 HSTS 头的响应,才会缓存策略。这就是为什么 HSTS 不能完全替代 301 重定向——首次 HTTP 请求仍可能被中间人劫持。

工作机制时序:

```
用户访问 https://1dao.cc
        ↓
服务器返回 200 OK + Strict-Transport-Security: max-age=31536000
        ↓
浏览器记录该策略,存储在 profile 目录下
        ↓
后续 1 年内,任何 http://1dao.cc 链接
        ↓
浏览器内部直接改为 https://1dao.cc (不发起 HTTP 请求)
        ↓
服务器收到 HTTPS 请求
```

清除浏览器 HSTS 策略的方法(Chrome):

1. 打开 `chrome://net-internals/#hsts`
2. 在 "Delete domain security policies for domain" 输入框中填入域名(如 `1dao.cc`)
3. 点击 "Delete" 按钮
4. 在 "Query HSTS/PKP domain" 输入框验证是否已清除

### 2.2 HSTS preload list

HSTS preload list 是浏览器厂商维护的、内置在浏览器二进制文件中的永久 HSTS 域名列表。一旦被加入,即使站长删除了 HSTS 响应头,浏览器仍会强制 HTTPS。

**Google 维护的 preload list** 包含两类域名:

1. **站长主动提交的域名**:如 `github.com`、`paypal.com` 等,站长向 hstspreload.org 提交并满足条件(有效 HTTPS、所有子域名支持 HTTPS、max-age ≥ 18 周)。
2. **Google 自有的 TLD**:如 `.dev`、`.app`、`.foo`、`.page`、`.google` 等。这些 TLD 由 Google 注册,默认全部在 preload list 中,强制 HTTPS。

**关键行为**:

- `.dev` 域名在所有主流浏览器中被永久强制 HTTPS。
- 即使用户访问 `http://1dao.dev`,浏览器也会内部跳转为 `https://1dao.dev`。
- 如果该域名没有 HTTPS 服务,连接失败,无法用任何方式绕过。
- **退出 preload list 极其困难**:需要站长主动提交移除申请,Google 审核期可能长达数月,且需等到下次浏览器版本发布后,才会从 list 中移除。

这就是为什么本项目要避免使用 `.dev` 作为测试域名:`dev.1dao.cc`(eTLD 是 `.cc`)不会触发 `.dev` 的 HSTS,可以自由使用 HTTP。

### 2.3 eTLD+1(Effective Top-Level Domain plus one)

eTLD+1 是 Cookie 和 HSTS 策略计算的基础概念,定义如下:

- **eTLD(Effective Top-Level Domain)**:由 [publicsuffix list](https://publicsuffix.org/) 定义的有效顶级域名。例如 `.com`、`.org`、`.cc`、`.dev` 都是 eTLD。
- **eTLD+1**:eTLD 加上一级,通常是"可注册的域名"。例如 `1dao.cc` 是 `.cc` 的 eTLD+1,`1dao.dev` 是 `.dev` 的 eTLD+1。

**特殊情形**:有些"看起来像 TLD"的实际上是 eTLD+1。例如:

- `1dao.cc` → eTLD 是 `.cc`,eTLD+1 是 `1dao.cc`
- `1dao.dev` → eTLD 是 `.dev`,eTLD+1 是 `1dao.dev`
- `dev.1dao.cc` → eTLD 是 `.cc`,eTLD+1 是 `1dao.cc`(因为 `dev.1dao.cc` 是 `1dao.cc` 的子域名)

**HSTS includeSubDomains 的边界**:HSTS 头中的 `includeSubDomains` 只会扩展到与设置域名的 **eTLD+1 相同** 的子域名。例如对 `1dao.cc` 设置 `includeSubDomains`,扩展到 `dev.1dao.cc`、`admin.1dao.cc`,但**不会**扩展到 `1dao.cc` 之外的其他 eTLD+1(如 `1dao.dev`)。

### 2.4 为什么 dev.1dao.cc 不触发 .dev 的 HSTS

这是本项目中一个核心的设计决策点。许多人会问:"既然 `.dev` 在 preload list 中,那 `dev.1dao.cc` 这个域名中包含 `.dev`,会不会被强制 HTTPS?"

答案是**不会**。原因:

1. **eTLD 计算规则**:publicsuffix list 中,`.dev` 是一个 eTLD(顶级域),而 `.cc` 是另一个 eTLD。
2. **域名解析结构**:`dev.1dao.cc` 的层次是 `cc` → `1dao` → `dev`,eTLD 是 `cc`,eTLD+1 是 `1dao.cc`,`dev` 是 `1dao.cc` 的子域名。
3. **HSTS 触发条件**:浏览器判断一个域名是否在 preload list 时,是按"完整 eTLD+1"匹配的。`dev.1dao.cc` 的 eTLD+1 是 `1dao.cc`,不在 preload list 中;preload list 中的 `dev` 是作为 eTLD 存在的,影响的是 `xxx.dev` 这类域名(其 eTLD+1 形如 `1dao.dev`),而不是子串 `dev` 出现在其他域名中。

**对比表**:

| 域名 | eTLD | eTLD+1 | 是否受 .dev preload 影响 |
|------|------|--------|------------------------|
| `dev.1dao.cc` | `.cc` | `1dao.cc` | 否 |
| `1dao.dev` | `.dev` | `1dao.dev` | 是,强制 HTTPS |
| `admin.1dao.dev` | `.dev` | `1dao.dev` | 是,通过 includeSubDomains |
| `1dao.cc` | `.cc` | `1dao.cc` | 否 |

所以本项目中使用 `dev.1dao.cc` 作为测试域名是安全的,可以使用 HTTP;而如果改用 `1dao.dev` 作为测试域名,就必须提供有效 HTTPS 证书。

**eTLD+1 在 Cookie 域设置中的作用**:publicsuffix list 的存在意义之一,是防止"恶意 Cookie 注入"。考虑场景:某个公网站点 `evil.com` 试图设置 Domain 为 `.com` 的 Cookie。如果浏览器允许,这个 Cookie 会被发送到所有 `.com` 域名(包括 `bank.com`、`google.com` 等),造成严重的隐私泄露和身份伪造风险。publicsuffix 限制了"可注册的 eTLD+1 边界",让 Cookie 只能在同一个 eTLD+1 内共享,跨 eTLD+1 不可传播。

同理,HSTS 的 includeSubDomains 也受 eTLD+1 边界限制:对 `1dao.cc` 设置 includeSubDomains,只影响 `1dao.cc` 自己的子域名(`dev.1dao.cc`、`admin.1dao.cc`),不会影响 `2dao.cc` 或其他 eTLD+1。这保证了 HSTS 策略的隔离性,防止恶意站点通过设置 HSTS 影响其他站点的访问。

### 2.5 TLS 握手与 SNI

TLS 握手是 HTTPS 建立加密通道的过程。简化流程:

```
客户端                          服务器
  |                               |
  |--- ClientHello -------------->|   (含 SNI:期望访问的域名)
  |                               |
  |<-- ServerHello ----------------|   (选定 TLS 版本、密码套件)
  |<-- Certificate ----------------|   (服务器据此域名选证书返回)
  |<-- ServerHelloDone ------------|
  |                               |
  |--- ClientKeyExchange --------->|
  |--- ChangeCipherSpec ---------->|
  |--- Finished ------------------>|
  |                               |
  |<-- ChangeCipherSpec -----------|
  |<-- Finished -------------------|
  |                               |
  |=== 加密应用数据传输 =========|
```

**SNI(Server Name Indication)**:在 ClientHello 阶段,客户端通过 TLS 扩展字段 `server_name` 明文告知服务器"我想访问哪个域名"。服务器收到后,从配置的多个证书中,选择与该域名匹配的证书返回。

SNI 的必要性:同一台服务器(同一 IP)的 443 端口可能服务多个域名(如 `1dao.cc` 和 `dev.1dao.cc`),每个域名有自己的证书。如果没有 SNI,服务器无法在 TLS 握手前知道客户端想访问哪个域名,只能返回默认证书,导致浏览器报证书不匹配错误。

**SNI 不匹配时的行为**:

- 服务器返回 `default_server` 配置的证书(nginx 概念)。
- 如果该证书的 SAN 中不包含客户端访问的域名,浏览器报"证书不受信任"。
- 如果 `default_server` 返回 nginx 的 444 状态码(详见 2.7),连接会被直接关闭。

诊断 SNI 的命令:

```bash

## 第三章:Cookie 与浏览器存储深度解析

### 3.1 Cookie 机制

Cookie 是 HTTP 协议中客户端保存少量状态数据的机制。服务器通过 `Set-Cookie` 响应头下发,浏览器在后续请求中通过 `Cookie` 请求头回传。

**Set-Cookie 头的属性**:

```
Set-Cookie: sessionid=abc123; Domain=.1dao.cc; Path=/; Max-Age=86400; Secure; HttpOnly; SameSite=Lax
```

| 属性 | 作用 | 默认行为 |
|------|------|---------|
| `Domain` | Cookie 所属域名 | 默认为响应服务器域名,不带前导点 |
| `Path` | 生效路径 | 默认为响应的 URL 路径 |
| `Expires` | 过期时间(绝对时间) | 不设则为 Session Cookie,关闭浏览器即失效 |
| `Max-Age` | 过期时间(相对秒数) | 优先级高于 Expires |
| `Secure` | 仅 HTTPS 传输 | 不设则 HTTP 也会发送 |
| `HttpOnly` | JS 不可访问 | 防止 XSS 读取 Cookie |
| `SameSite` | 跨站发送策略 | 现代浏览器默认 Lax |

**Domain 属性的关键行为**:

- 不写 Domain:Cookie 只属于当前精确域名。`dev.1dao.cc` 设置的 Cookie,不会发送到 `1dao.cc` 或 `admin.1dao.cc`。
- 写 Domain=`.1dao.cc`(注意前导点,现代浏览器忽略前导点,但语义是"覆盖所有 1dao.cc 的子域名和 1dao.cc 本身"):Cookie 会发送到 `1dao.cc`、`dev.1dao.cc`、`admin.1dao.cc` 等所有 eTLD+1 一致的域名。

**Cookie 不分端口**:`http://1dao.cc:8080` 和 `http://1dao.cc:80` 共享同一份 Cookie。这是 Cookie 的设计——端口不参与 Cookie 的隔离。

**HttpOnly 的意义**:设置了 HttpOnly 的 Cookie,`document.cookie` 取不到,也修改不了。这能防止 XSS 攻击者通过 JS 偷取登录态。本项目登录 Cookie 必须设 HttpOnly。

**SameSite 三种取值**:

- `Strict`:跨站请求完全不发送 Cookie。例如从 `google.com` 点链接到 `1dao.cc`,1dao.cc 不会收到 Cookie,用户需要重新登录。这是最严格的策略,但用户体验差(从外站进入总是未登录状态)。
- `Lax`:跨站导航(顶层 GET)发送,其他跨站请求(子资源、POST)不发送。这是 Chrome 80+ 默认值。Lax 模式在安全性和易用性之间取得平衡,大多数普通链接跳转可携带登录态,但 POST 请求(典型如 CSRF 攻击)不会带上 Cookie,从根本上缓解了 CSRF 攻击。
- `None`:完全发送,但必须配合 `Secure`(即仅 HTTPS)。旧版本默认 None。在 Chrome 80 之后,如果不显式声明 SameSite=None,会被当作 Lax 处理,这给许多旧系统带来兼容性问题——特别是依赖跨站 Cookie 的第三方嵌入式应用(如支付回调、SSO 跳转)。

**SameSite 在跨站判定上的逻辑**:浏览器判断"是否跨站",看的是顶级域名是否变化。`dev.1dao.cc` 跳转到 `1dao.cc` 算"同站"(eTLD+1 都是 1dao.cc),Cookie 会发送;而 `dev.1dao.cc` 跳转到 `crmeb.example.com` 算"跨站",在 Strict 模式下不发送 Cookie。这也提醒我们:Cookie 的同站判定基于 eTLD+1,而 localStorage 的同源判定基于完整 origin,两者不是一回事。

### 3.2 同域名 Cookie 共享问题

本项目最初的错误设计是 dev 和线上环境共用域名 `1dao.cc`(仅通过不同路径区分),导致了严重的 Cookie 串扰:

```
访问 dev.1dao.cc → 写入 Cookie: domain=.1dao.cc, name=login_token, value=dev_xxx
访问 1dao.cc     → 浏览器自动附带 login_token=dev_xxx
                 → 服务器以为是 dev 环境的登录态
                 → 线上环境收到 dev 的 Cookie,数据混乱
```

**解决方案**:

1. **使用不同子域名**:dev 用 `dev.1dao.cc`,线上用 `1dao.cc`,admin 用 `admin.1dao.cc`。Cookie 设 Domain 时只用精确域名,不用 `.1dao.cc`:
   ```php
   setcookie('login_token', $token, [
       'domain' => 'dev.1dao.cc',  // 注意:不带前导点
       'secure' => true,
       'httponly' => true,
       'samesite' => 'Lax',
   ]);
   ```
2. **使用不同浏览器**:开发用 Chrome,线上用 Firefox,各自独立的 Cookie 存储。这是临时方案,治标不治本。
3. **使用不同浏览器 Profile**:Chrome 支持多 Profile,各 Profile 独立 Cookie。比"不同浏览器"更优雅。

最终本项目采用方案 1,通过 `dev.1dao.cc` / `1dao.cc` 子域名隔离,彻底消除 Cookie 串扰。

### 3.3 localStorage

localStorage 是 HTML5 引入的客户端持久化存储,5-10MB 容量,按 **origin** 隔离:

- origin = scheme + host + port
- `http://1dao.cc` 和 `https://1dao.cc` 是不同 origin,localStorage 不共享
- `http://1dao.cc:80` 和 `http://1dao.cc:8080` 是不同 origin,localStorage 不共享
- `http://1dao.cc` 和 `http://dev.1dao.cc` 是不同 origin,localStorage 不共享

**关键差异(对比 Cookie)**:Cookie 按"domain"匹配,不分端口;localStorage 按"origin"隔离,分端口。这意味着:

- 如果 dev 和线上环境共用域名(仅不同端口),Cookie 会串扰,但 localStorage 不会。
- 如果 dev 和线上环境共用域名同端口(仅不同路径),Cookie 和 localStorage 都会串扰。

**localStorage API**:

```javascript
localStorage.setItem('user', JSON.stringify({id: 1, name: 'admin'}));
const user = JSON.parse(localStorage.getItem('user'));
localStorage.removeItem('user');
localStorage.clear();  // 清空所有
```

localStorage 不能跨 origin 访问,即使是父域名的 JS 也无法读取子域名的 localStorage,反之亦然。这是与 Cookie 的关键区别:Cookie 可以"父域名写,子域名读",localStorage 严格按 origin 隔离。

### 3.4 Service Worker

Service Worker(SW)是浏览器在页面之外运行的 JavaScript 工作线程,可拦截其注册 origin 下的所有请求。典型用途:PWA 离线缓存、推送通知、后台同步。

**关键陷阱**:SW 一旦注册,会持续存在,即使关闭浏览器、重启电脑,只要在 SW 的有效期(默认 24 小时检查更新)和存储清理前未被注销,就会继续拦截请求。

测试环境中的具体问题:

```
1. 开发者在 dev 环境注册了 SW(用于测试 PWA)
2. SW 缓存了 dev 环境的 index.html
3. 开发者切换到线上环境访问
4. SW 仍存在,拦截请求,返回 dev 的缓存页面
5. 表现:明明 hosts 已切到线上,但页面内容还是 dev 的
```

排查方法:

```bash

## 第四章:Shell 脚本技术原理

### 4.1 set 命令详解

`set` 命令用于修改 shell 的运行时选项。最常用的三个是:

```bash
set -e          # errexit:命令失败立即退出
set -u          # nounset:未定义变量报错
set -o pipefail # 管道中任一环节失败即报错
```

**`set -u` 的实际作用**:任何对未定义变量的引用都会触发错误并退出脚本。这是防止"幽灵变量"导致逻辑错误的重要保障。例如:

```bash
#!/bin/bash
set -u
echo "Hello, $NAME!"   # 如果 NAME 未定义,直接报错退出
```

如果不加 `set -u`,脚本会继续执行,把 `$NAME` 当作空字符串,导致输出"Hello, !",看似无害,但在生产场景中可能引发数据写入错误(例如把空值写入数据库)。`set -u` 的代价是脚本中需要确保所有变量都已定义,有时需要预先赋默认值:`NAME=${NAME:-default}`。

**`set -e` 的"陷阱清单"**:

1. **命令在 `if`/`while`/`&&`/`||` 条件中失败,不触发退出**。这是 bash 的设计——条件判断的失败是"预期行为",不视为错误。
2. **管道中除最后一条命令失败,默认不触发退出**。需要 `pipefail` 才能让中间命令的失败也生效。
3. **函数内部失败,默认会传播到调用点**。但如果函数被用在 `if` 中,失败同样被忽略。
4. **子 shell 中的失败不传播**。`(false; echo "still alive")` 会打印 "still alive"。
5. **`! cmd` 反转退出码后,失败也不触发退出**。这是逻辑取反的副作用。

这些特性使得 `set -e` 的实际行为经常与直觉相悖。因此,在生产脚本中,关键操作往往手动检查退出码,而不是完全依赖 `set -e`:

```bash
if ! mysql -uroot 1dao_db -e "REPLACE INTO ..."; then
    echo "ERROR: 替换失败" >&2
    exit 1
fi
```

组合使用 `set -euo pipefail` 是 shell 脚本的"最佳实践",但在循环和管道场景中有陷阱:

**陷阱示例**:本项目早期版本中,switch-env 脚本循环处理多张表:

```bash
#!/bin/bash
set -euo pipefail

while IFS=$'\t' read -r table col; do
    mysql -uroot 1dao_db -e "REPLACE INTO $table ($col) VALUES ('dev_value')"
done < <(mysql -uroot 1dao_db -sN -e "SELECT table_name, column_name FROM ...")
```

如果某张表暂时不存在(例如某次循环中 mysql 报错),`set -e` 会让整个脚本立即退出,**剩余的表不会被处理**,但前面的表已经修改,导致数据库处于"半切换"状态。

**替代方案**:在循环中临时关闭 errexit:

```bash
while IFS=$'\t' read -r table col; do
    set +e
    mysql -uroot 1dao_db -e "REPLACE INTO $table ($col) VALUES ('dev_value')"
    set -e
done < <(...)
```

或更简洁,使用 `|| true`:

```bash
while IFS=$'\t' read -r table col; do
    mysql -uroot 1dao_db -e "REPLACE INTO ... " || true
done < <(...)
```

`|| true` 的语义是:无论 mysql 成功还是失败,该复合命令的退出码都是 0,因此不会触发 `set -e`。

**pipefail 的陷阱**:

```bash
mysql -uroot 1dao_db -e "SELECT ..." | grep "something"
```

如果 mysql 失败但 grep 成功(因为 grep 找到了匹配),那么 `pipefail` 让整个管道的退出码反映 mysql 的失败,触发 `set -e`。如果这是在循环中,会终止整个循环。

### 4.2 shell 数组与排序

bash 的数组语法:

```bash

## 第五章:数据库快照原理

### 5.1 mysqldump 机制

`mysqldump` 是 MySQL 自带的逻辑备份工具,通过执行 SQL 语句重建数据库。它与 `mysqlbinlog`(基于 binlog 的物理备份)、Percona XtraBackup(物理热备份)相比,优势在于通用性强、跨版本兼容性好;劣势是备份和恢复较慢,因为需要执行大量 SQL 语句重建数据。

**逻辑备份 vs 物理备份对比**:

| 维度 | 逻辑备份(mysqldump) | 物理备份(XtraBackup) |
|------|----------------------|---------------------|
| 实现方式 | 执行 SELECT 拿数据 + 生成 SQL | 直接拷贝数据文件 |
| 速度 | 慢(受 SQL 解析开销) | 快(直接 IO) |
| 恢复速度 | 慢(逐条执行 SQL) | 快(直接拷贝) |
| 跨版本兼容 | 好 | 差(文件格式可能不兼容) |
| 锁表影响 | InnoDB 用一致性读不锁表 | 几乎无锁 |
| 备份文件大小 | 较大(纯文本 SQL) | 较小(二进制) |
| 单表恢复 | 支持(从备份文件提取) | 不支持(整体恢复) |

**关键参数**:

```bash
mysqldump -uroot \
  --single-transaction \
  --databases 1dao_db \
  --routines \
  --triggers \
  --default-character-set=utf8mb4 \
  | gzip > /var/lib/1dao-env/prod.sql.gz
```

各参数含义:

- `--single-transaction`:使用 InnoDB 的 MVCC 机制,在一致性读视图中导出。**不锁表**,但要求所有表是 InnoDB,且导出期间不能执行 DDL(ALTER/ DROP 等)。
- `--databases`:导出内容包含 `CREATE DATABASE` 和 `USE` 语句。恢复时 `mysql < dump.sql` 即可自动创建库。如果不加此参数,导出不含建库语句,需手动 `CREATE DATABASE`。
- `--routines`:导出存储过程和函数。
- `--triggers`:导出触发器(默认开启,显式声明便于阅读)。
- `--default-character-set=utf8mb4`:确保 UTF-8 4 字节字符(如表情符号)正确导出。

**管道的应用**:

```bash

## 第六章:nginx 配置原理

### 6.1 server_name 匹配优先级

nginx 收到 HTTP 请求后,按以下顺序匹配 `server` 块:

1. **精确匹配**:`server_name 1dao.cc;` 严格等于 Host 头。
2. **通配符开头**:`server_name *.1dao.cc;` 匹配 `dev.1dao.cc`、`admin.1dao.cc` 等。
3. **通配符结尾**:`server_name 1dao.*;` 匹配 `1dao.com`、`1dao.net` 等。
4. **正则匹配**:`server_name ~^dev\d+\.1dao\.cc$;` 匹配 `dev1.1dao.cc`、`dev2.1dao.cc` 等。正则需以 `~` 开头。
5. **default_server**:`listen 80 default_server;` 标记的服务器,处理所有未匹配的请求。

**示例**:

```nginx

## 第七章:故障排查手册

### 7.1 "您的连接不是私密连接" 排查流程

浏览器报"您的连接不是私密连接"(NET::ERR_CERT_AUTHORITY_INVALID 等),意味着 TLS 证书校验失败。

**常见的错误代码及其含义**:

| 错误代码 | 含义 | 常见原因 |
|---------|------|---------|
| `NET::ERR_CERT_AUTHORITY_INVALID` | 证书颁发机构不受信任 | 自签名证书未导入信任 |
| `NET::ERR_CERT_COMMON_NAME_INVALID` | 证书 CN/SAN 不匹配 | 访问的域名不在证书 SAN 中 |
| `NET::ERR_CERT_DATE_INVALID` | 证书过期或未生效 | 系统时间错误,或证书到期 |
| `NET::ERR_CERT_AUTHORITY_INVALID` | 证书链不完整 | 中间证书未配置 |
| `NET::ERR_SSL_PROTOCOL_ERROR` | SSL 握手失败 | TLS 版本不匹配,或 SNI 配置错误 |

**排查步骤**:

1. **检查 URL 是 HTTP 还是 HTTPS**
   - 如果地址栏显示 `http://` 但页面错误,检查是否被 HSTS 强制改写。
   - 在 `chrome://net-internals/#hsts` 查询该域名是否在 HSTS 缓存中。

2. **检查证书是否匹配域名**
   ```bash
   openssl s_client -connect 192.168.207.130:443 -servername dev.1dao.cc < /dev/null 2>/dev/null \
     | openssl x509 -noout -subject -ext subjectAltName
   ```
   输出示例:
   ```
   subject=CN = 1dao.cc
   X509v3 Subject Alternative Name:
       DNS:1dao.cc, DNS:*.1dao.cc
   ```
   - 如果访问的 `dev.1dao.cc` 不在 SAN 中,浏览器报错。
   - 如果是自签名证书,需要手动添加到系统钥匙串信任。

3. **检查是否在 HSTS preload list**
   - 访问 [hstspreload.org](https://hstspreload.org/) 查询域名。
   - 如果用了 `.dev` / `.app` 等 Google TLD,会被强制 HTTPS。即使提供 HTTP,浏览器仍会改写为 HTTPS。

4. **检查浏览器 HSTS 缓存**
   - `chrome://net-internals/#hsts`
   - 在 "Query HSTS/PKP domain" 输入域名,查询是否被缓存。
   - 在 "Delete domain security policies for domain" 删除该域名。

5. **检查系统时间**
   - 证书有效期是绝对时间。如果系统时间不对(如 1970 年或 2030 年),所有证书都会被认为过期。
   - `date` 命令检查时间是否正确。

**完整流程图**:

```
"您的连接不是私密连接"
        |
        v
是 HTTP 还是 HTTPS?
   |              |
HTTP            HTTPS
   |              |
   v              v
检查是否被 HSTS   检查证书 SAN 是否匹配
强制改写为 HTTPS   |
   |              v
   v              证书匹配?
在 HSTS 缓存?     |        |
   |           是        否
   v            |        |
是            v        v
删除 HSTS     完成    证书是否自签名?
缓存                  |        |
   |                是        否
   v                |        |
重新访问          v        v
                 添加信任  检查证书
                 到钥匙串  链是否完整
```

### 7.2 "站点无法访问" 排查流程

浏览器报"站点无法访问"(ERR_CONNECTION_REFUSED、ERR_CONNECTION_TIMED_OUT 等)。

**排查步骤**:

1. **ping 域名,检查 DNS 解析**
   ```bash
   ping dev.1dao.cc
   # 如果 ping 不通域名,但 ping IP 通,是 DNS 问题
   # 如果 ping IP 也不通,是网络层问题
   ```

2. **curl -v 检查连接**
   ```bash
   curl -v http://dev.1dao.cc/
   # 输出:
   # *   Trying 192.168.207.130:80...
   # * connect to 192.168.207.130 port 80 failed: Connection refused
   ```
   - `Connection refused`:虚机在线但 80 端口未监听(nginx 未启动)。
   - `Connection timed out`:虚机不在线或防火墙拦截。
   - `Trying 1.2.3.4`(非虚机 IP):DNS 解析错误,检查 hosts。

3. **ssh 虚机,检查服务状态**
   ```bash
   ssh vagrant@192.168.207.130
   sudo systemctl status nginx
   sudo systemctl status php8.1-fpm
   ```

4. **检查 nginx error.log**
   ```bash
   sudo tail -50 /var/log/nginx/error.log
   # 看是否有 "host not found in upstream"(后端域名解析失败)
   # 或 "permission denied"(文件权限)
   # 或 "no live upstreams"(后端全挂)
   ```

5. **检查虚机防火墙**
   ```bash
   sudo ufw status
   # 如果 80/443 未开放:
   sudo ufw allow 80
   sudo ufw allow 443
   ```

6. **检查 macOS hosts 文件**
   ```bash
   grep 1dao.cc /etc/hosts
   # 应输出:192.168.207.130 1dao.cc dev.1dao.cc ...
   ```

### 7.3 "页面空白/500" 排查流程

页面打开但空白,或返回 500(Internal Server Error)。

**排查步骤**:

1. **curl -I 检查状态码**
   ```bash
   curl -I http://dev.1dao.cc/
   # HTTP/1.1 500 Internal Server Error
   # HTTP/1.1 200 OK 但内容空白
   ```

2. **查 nginx error.log**
   ```bash
   sudo tail -50 /var/log/nginx/error.log
   # PHP message: PHP Fatal error:  Uncaught ...
   # 或:FastCGI sent in stderr: "Primary script unknown"
   ```

3. **查 PHP-FPM 错误日志**
   ```bash
   sudo tail -50 /var/log/php8.1-fpm.log
   ```

4. **查应用日志**
   ```bash
   sudo tail -50 /var/www/1dao/storage/logs/laravel.log
   # 或 crmeb:
   sudo tail -50 /var/www/crmeb/runtime/log/$(date +%Y%m)/$(date +%d).log
   ```

5. **检查文件权限**
   ```bash
   ls -la /var/www/1dao/storage/
   # storage/ 和 bootstrap/cache/ 必须 www-data 可写
   sudo chown -R www-data:www-data /var/www/1dao/storage
   sudo chown -R www-data:www-data /var/www/1dao/bootstrap/cache
   ```

6. **检查数据库连接**
   ```bash
   # 在虚机上测试 mysql 连接
   mysql -u1dao -p$(grep DB_PASSWORD /var/www/1dao/.env | cut -d= -f2) 1dao_db -e "SELECT 1"
   # 如果连接失败:检查 .env 中 DB_HOST/DB_PORT/DB_PASSWORD
   ```

7. **清缓存**
   ```bash
   # Laravel
   cd /var/www/1dao
   php artisan cache:clear
   php artisan config:clear
   php artisan route:clear
   php artisan view:clear

   # Redis 缓存
   redis-cli FLUSHALL

   # crmeb runtime
   rm -rf /var/www/crmeb/runtime/cache/*
   rm -rf /var/www/crmeb/runtime/temp/*
   ```

### 7.4 "切换 hosts 后浏览器仍访问旧环境" 排查

明明 `which-env` 显示当前是 dev 模式,但浏览器仍访问线上环境。

**排查步骤**:

1. **which-env 确认系统层面**
   ```bash
   which-env
   # 应输出: Current environment: dev (pointing to 192.168.207.130)
   # 同时 ping 1dao.cc 应解析到 192.168.207.130
   ping -c 1 1dao.cc
   ```

2. **清浏览器 DNS 缓存**
   - Chrome:`chrome://net-internals/#dns` → "Clear host resolver cache"
   - Firefox:`about:networking#dns` → "Clear DNS Cache"

3. **清 Socket 连接池**
   - 浏览器会复用已建立的 TCP 连接(keepalive)。如果旧连接还在,即使 DNS 已切换,仍走旧 IP。
   - Chrome:`chrome://net-internals/#sockets` → "Flush socket pools"
   - 或关闭所有浏览器窗口重开。

4. **用无痕窗口验证**
   - 无痕窗口有独立的 DNS 缓存和 socket 池。
   - 如果无痕窗口正常而普通窗口异常,确认是浏览器缓存问题。

5. **检查 Service Worker**
   - SW 拦截请求可能返回旧环境的缓存。
   - `chrome://serviceworker-internals/` 注销所有 SW。

6. **检查 HSTS 缓存**
   - 如果之前访问过 https://1dao.cc,可能被 HSTS 强制 HTTPS,导致访问 dev 也走 HTTPS,但 dev 环境只有 HTTP,连接失败或回退到线上。
   - `chrome://net-internals/#hsts` 删除 1dao.cc 的 HSTS 策略。

### 7.5 "switch-env 切换后数据没变" 排查

执行了 switch-env dev,但访问页面发现域名、数据仍是 prod 状态。

**排查步骤**:

1. **检查 env.conf 的 CURRENT**
   ```bash
   cat /var/lib/1dao-env/env.conf
   # 应输出: CURRENT=dev
   # 如果仍是 CURRENT=prod,说明脚本未执行到最后一步
   ```

2. **检查快照是否存在**
   ```bash
   ls -la /var/lib/1dao-env/
   # dev.sql.gz 应存在,且修改时间是最近
   # 如果 dev.sql.gz 不存在或时间过早,restore 失败但未报错
   ```

3. **检查数据库实际值**
   ```bash
   mysql -uroot 1dao_db -e "SELECT value FROM tb_config WHERE name='site_url'"
   # 应输出 dev.1dao.cc
   # 如果仍是 1dao.cc,数据库未切换成功
   ```

4. **检查 set -euo pipefail 是否导致脚本提前退出**
   ```bash
   # 临时修改脚本,在每步后加 echo 调试
   bash -x /usr/local/bin/switch-env dev
   # -x 选项打印每条命令,查看哪一步退出
   ```

5. **检查 CONCAT 的 \t 问题**
   ```bash
   # 如果脚本中有:
   # mysql -e "SELECT CONCAT(a, '\t', b) FROM ..."
   # 实际输出不是 a<TAB>b,而是 a\tb
   # read 命令拿不到正确的两个字段
   # 改为 SELECT a, b 解决
   ```

6. **检查 nginx 配置是否 reload**
   ```bash
   sudo nginx -t
   sudo systemctl reload nginx
   # nginx 配置改了但不 reload,旧配置仍生效
   ```

7. **检查 .env 文件**
   ```bash
   grep -E 'APP_URL|DOMAIN' /var/www/1dao/.env
   # 应为 dev.1dao.cc
   ```

### 7.6 "WiFi 断开时域名无法访问" 排查

关闭 WiFi 后,即使 hosts 配置正确,虚机在线,仍无法访问 dev.1dao.cc。

**排查步骤**:

1. **确认虚机 IP 可达**
   ```bash
   ping 192.168.207.130
   # 应能 ping 通(虚机在线)
   ```

2. **用 IP+端口直连绕过 DNS**
   ```bash
   curl -H "Host: dev.1dao.cc" http://192.168.207.130/
   # 如果能返回页面,说明 nginx 和 PHP 都正常
   # 问题在 DNS 解析层
   ```

3. **确认 hosts 条目存在**
   ```bash
   grep 1dao /etc/hosts
   # 应输出:192.168.207.130  1dao.cc dev.1dao.cc ...
   ```

4. **理解 macOS 无网络时不解析 hosts**
   - 这是 macOS 的已知行为。WiFi 断开时,mDNSResponder 没有活动网络接口,即使 hosts 文件有配置也不解析。
   - 验证:`ping 1dao.cc` 会报 `Unknown host`,但 `ping 192.168.207.130` 正常。

5. **解决方案**
   - 重新连接 WiFi(哪怕 WiFi 不通外网,只要接口 active 即可)。
   - 创建虚拟网络接口:
     ```bash
     sudo ifconfig lo0 alias 127.0.0.2 up
     # 这会让系统认为有活动接口,mDNSResponder 开始工作
     ```
   - 用 IP+Host 头直连,绕过 DNS。

---

## 第八章:完整命令速查手册

### 8.1 macOS 主机命令

```bash

## 第九章:项目完整时间线

以下是本次开发测试环境搭建的关键操作时间线,按顺序记录每个决策点和遇到的坑。

### 阶段一:环境规划(上午 09:00-10:00)

- 09:00 评估需求:dev 环境与 prod 环境共用数据库,导致数据串扰严重,需物理隔离。
- 09:15 决定方案:用 VirtualBox + Vagrant 搭建独立虚机,在虚机上运行 dev 环境。
- 09:30 选定虚机配置:Ubuntu 22.04 LTS、2GB 内存、2 CPU、IP 192.168.207.130。
- 09:45 决定域名策略:dev 环境用 `dev.1dao.cc` 子域名,避免与 `1dao.cc` 共用 Cookie。

### 阶段二:虚机初始化(上午 10:00-11:00)

- 10:00 `vagrant init ubuntu/jammy64` 创建 Vagrantfile。
- 10:15 配置 Vagrantfile:私有网络 IP、 forwarded_port、synced_folder。
- 10:30 `vagrant up` 启动虚机,遇到 VirtualBox 内核模块加载失败。
  - 排查:`kextstat | grep -v com.apple` 查看扩展加载情况。
  - 解决:macOS 系统设置允许 Oracle 加载扩展。
- 10:45 `vagrant ssh` 进入虚机,基础环境就绪。

### 阶段三:服务安装(上午 11:00-下午 13:00)

- 11:00 安装 nginx:`apt install nginx`。
- 11:15 安装 PHP-FPM 8.1 + 常用扩展:`apt install php8.1-fpm php8.1-mysql php8.1-redis php8.1-gd php8.1-mbstring`。
- 11:30 安装 MySQL 8.0:`apt install mysql-server`,运行 `mysql_secure_installation`。
- 11:45 安装 Redis:`apt install redis-server`。
- 12:00 配置 PHP-FPM:修改 `www.conf`,调整 `pm.max_children = 30`。
- 12:15 配置 MySQL:修改 `mysqld.cnf`,设置 `character-set-server = utf8mb4`。
- 12:30 测试各服务:`systemctl status nginx/php8.1-fpm/mysql/redis`。

### 阶段四:应用部署(下午 13:00-15:00)

- 13:00 拉取代码:`git clone` 到 `/var/www/1dao`。
- 13:15 `composer install` 安装 PHP 依赖。
- 13:30 配置 .env 文件:数据库连接、Redis 连接、APP_URL 设为 `http://dev.1dao.cc`。
- 13:45 `php artisan key:generate` 生成应用密钥。
- 14:00 `php artisan migrate` 执行数据库迁移。
- 14:15 `php artisan db:seed` 填充测试数据。
- 14:30 导入 prod 数据库快照:`gunzip -c prod.sql.gz | mysql 1dao_db`。
- 14:45 测试访问 `curl http://localhost/`,确认应用可访问。

### 阶段五:nginx 配置(下午 15:00-16:00)

- 15:00 创建 `/etc/nginx/conf.d/1dao.conf`:配置 server_name `dev.1dao.cc`、root 指向 `/var/www/1dao/public`、try_files 重写、fastcgi_pass 到 PHP-FPM socket。
- 15:15 创建 `/etc/nginx/conf.d/000-default-444.conf`:default_server + return 444,拦截未配置的请求。
- 15:30 `nginx -t` 测试配置通过,`systemctl reload nginx`。
- 15:45 配置 X-Environment 头:`add_header X-Environment "dev" always;`。
- 16:00 在 macOS hosts 中写入 `192.168.207.130 dev.1dao.cc`,测试访问成功。

### 阶段六:DNS 与 HTTPS(下午 16:00-17:00)

- 16:00 尝试访问 `https://dev.1dao.cc`,报"您的连接不是私密连接"。
  - 排查:浏览器 HSTS 缓存了 1dao.cc 的 HTTPS 策略。
  - 解决:`chrome://net-internals/#hsts` 删除 1dao.cc 的 HSTS 缓存。
- 16:15 测试 `http://1dao.dev`(误用),报错强制 HTTPS。
  - 排查:`.dev` 在 HSTS preload list,无法用 HTTP。
  - 解决:改用 `dev.1dao.cc`(eTLD 是 `.cc`,不受 `.dev` 影响)。
- 16:30 生成自签名证书:`openssl req -x509 -newkey rsa:2048 -nodes ... -config openssl.cnf`。
- 16:45 配置 HTTPS server,但测试发现不需要 HTTPS 也能用,最终决定 dev 用 HTTP。

### 阶段七:Cookie 串扰(下午 17:00-18:00)

- 17:00 切换 dev-on 后,访问 `1dao.cc` 发现登录态是 dev 的。
- 17:15 排查:Cookie 的 Domain 设为 `.1dao.cc`,导致 dev 和 prod 共享 Cookie。
- 17:30 修改 PHP 代码:setcookie 的 domain 改为精确域名 `dev.1dao.cc`。
- 17:45 测试:dev 和 prod 的 Cookie 隔离,登录态独立。

### 阶段八:Shell 脚本(下午 18:00-19:00)

- 18:00 编写 `dev-on`、`dev-off`、`which-env` 脚本。
- 18:15 编写 `switch-env`、`change-domain` 脚本。
- 18:30 测试 switch-env,发现数据库没切换。
  - 排查:`set -euo pipefail` 导致循环中 mysql 失败即终止。
  - 解决:循环中加 `|| true`。
- 18:45 测试 change-domain,发现输出格式错误。
  - 排查:`CONCAT(table, '\t', col)` 输出的不是制表符。
  - 解决:改用 `SELECT table, col`(mysql -sN 自动用 tab 分隔)。
- 19:00 所有脚本测试通过。

### 阶段九:故障排查(下午 19:00-20:00)

- 19:00 浏览器仍访问旧环境。
  - 排查:Chrome DNS 缓存未清。
  - 解决:`chrome://net-internals/#dns` 清缓存。
- 19:15 WiFi 断开后无法访问 dev 域名。
  - 排查:macOS 无活动接口时,mDNSResponder 不解析 hosts。
  - 解决:重新连接 WiFi。
- 19:30 Service Worker 拦截请求。
  - 排查:`chrome://serviceworker-internals/` 看到 dev 注册的 SW。
  - 解决:Unregister 所有 SW。
- 19:45 完整流程跑通,提交代码。

### 阶段十:文档编写(晚上 20:00-22:00)

- 编写本系列工作总结文档,共六部分。
- 本部分(第六部分)聚焦技术原理与故障排查。

---

## 第十章:总结与展望

### 10.1 完成的工作总结

本次开发测试环境搭建工作,完成了以下核心成果。整个工作从最初的"数据串扰问题"识别,到最终的"完整工具链交付",历时约一整个工作日,过程中既验证了已有的网络、DNS、TLS、浏览器存储等知识,也通过踩坑补齐了 Shell 脚本陷阱、SQL 转义、macOS 特殊行为等实战盲区。

**架构层面**:
- 在 macOS 主机上搭建独立的 VirtualBox 虚机作为 dev 环境,与线上 prod 环境物理隔离。
- 通过子域名(`dev.1dao.cc`、`admin.1dao.cc`、`api.1dao.cc`)实现 dev/prod 的 Cookie 隔离。
- 通过 nginx 的 default_server + return 444 拦截寄生域名和未配置请求。

**工具链层面**:
- 在 macOS 主机上提供 `dev-on`、`dev-off`、`which-env` 三个命令,一键切换开发态/线上态。
- 在虚机上提供 `switch-env`、`change-domain` 命令,支持数据库快照切换和域名批量替换。
- 建立了完整的环境标识体系(env.conf、X-Environment 头、应用 .env 文件)。

**数据层面**:
- 实现了 dev 和 prod 模式的双快照备份/恢复,数据相互隔离。
- 在切换环境时保留业务数据,确保开发进度不丢失。
- 通过 mysqldump --single-transaction 实现一致性快照,避免锁表。

**文档层面**:
- 本系列工作总结六部分,覆盖背景、架构、脚本、nginx、数据库、原理排错。
- 提供完整的命令速查手册和故障排查流程。
- 记录项目时间线和决策点,便于后续工程师理解。

**遇到并解决的关键问题**:
1. **HSTS 误用 .dev 域名**:认识到 `.dev` 在 preload list,改用 `dev.1dao.cc`。
2. **Cookie 跨子域共享**:理解 eTLD+1 和 Cookie Domain 的关系,改用精确域名。
3. **macOS DNS 缓存**:理解 mDNSResponder 的工作机制,正确刷新缓存。
4. **Shell 脚本陷阱**:理解 `set -euo pipefail` 在循环中的副作用,用 `|| true` 规避。
5. **SQL 转义陷阱**:理解 MySQL 字符串中 `\t` 不是制表符,改用 `SELECT col1, col2`。
6. **浏览器多级缓存**:理解 Memory/Disk/SW/HSTS 四层缓存,逐一清理。
7. **WiFi 断开不解析 hosts**:理解 macOS 无网络时的特殊行为,提供绕过方案。

### 10.2 待解决的问题

1. **walle / deploy 的 secrets 包**:目前 walle 部署系统中存在 secrets 包管理问题,部署时部分敏感配置(数据库密码、API key)的注入流程不够顺畅。需要研究 walle 的 secrets 集成机制,或改用 Ansible Vault。

2. **证书续期**:目前使用自签名证书,浏览器报警告。需考虑:
   - 申请 Let's Encrypt 免费证书(但 dev 环境在内网,无法完成 HTTP-01 验证)。
   - 使用 mkcert 生成被本机信任的本地证书。
   - 把根证书安装到系统钥匙串,使所有由 mkcert 签发的证书都被信任。

3. **Service Worker 的清理自动化**:目前每次切换环境都要手动 `chrome://serviceworker-internals/` 注销 SW。需要研究是否能在应用层面,通过版本号变化触发 SW 自动注销。

4. **数据库快照的版本管理**:目前 dev.sql.gz 和 prod.sql.gz 是单文件覆盖式,无版本历史。需考虑:
   - 引入 git-lfs 或 git-annex 管理快照版本。
   - 或保留最近 N 个快照(`dev-20240101.sql.gz`、`dev-20240102.sql.gz`),定期清理。

5. **跨主机部署**:目前 dev 环境绑定在单台虚机上,如果多人协作开发,需要每人各自部署。后续考虑:
   - 用 Docker 容器化,通过 docker-compose 一键启动。
   - 或用 Vagrant 的多机配置,支持多人共享一台虚机。

### 10.3 未来改进方向

**短期(1-2 周)**:

1. **自动化部署脚本完善**:
   - 编写 `deploy.sh`,一键完成代码更新、依赖安装、缓存清理、服务重启。
   - 集成 `git pull`、`composer install`、`php artisan migrate`、`systemctl reload` 等步骤。
   - 加入回滚机制:部署失败时自动回滚到上一版本。
   - 部署前自动备份当前数据库,出错可手工恢复。备份命名规则:`1dao_db_$(date +%Y%m%d_%H%M%S).sql.gz`,定期清理超过 7 天的旧备份。

2. **CI/CD 流水线**:
   - 在 GitLab CI / Jenkins 中配置流水线,代码 push 触发自动测试 + 部署到 dev 环境。
   - 测试通过后人工审核,部署到 prod 环境。
   - 集成 PHP_CodeSniffer、PHPStan 等静态检查工具。

3. **监控告警**:
   - 在 dev 虚机上部署 Prometheus + Grafana,监控 CPU、内存、磁盘、网络。
   - 在 nginx 上启用 stub_status,监控请求量、响应时间。
   - 在 MySQL 上启用 performance_schema,监控慢查询、连接数。
   - 配置告警:磁盘 >80%、服务宕机、慢查询激增时邮件 / 飞书通知。

**中期(1-2 月)**:

1. **容器化改造**:
   - 将 1dao 应用、crmeb 应用、MySQL、Redis 各自容器化。
   - 用 docker-compose 编排,实现一键启停。
   - 通过 volume 挂载实现数据持久化。
   - 支持多套环境并行(dev、staging、test),各自独立容器组。

2. **日志集中化**:
   - 部署 ELK(Elasticsearch + Logstash + Kibana)或 Loki + Grafana。
   - 收集 nginx、PHP-FPM、MySQL、应用日志。
   - 支持按域名、按环境、按时间维度检索。

3. **域名管理的进一步自动化**:
   - 编写一个 watch 守护进程,监控 hosts 文件变化,自动触发 DNS 缓存刷新。
   - 或开发 macOS 系统扩展(System Extension),提供更稳定的 dev/prod 切换。

**长期(3-6 月)**:

1. **多环境支持**:
   - 支持 feature branch 模式:每个功能分支自动部署到独立子域名(`feature-xyz.1dao.cc`)。
   - 用 PR 合并后自动销毁对应环境,节省资源。

2. **测试自动化**:
   - 引入 PHPUnit + Laravel Dusk,编写单元测试和浏览器测试。
   - 部署后自动运行测试,失败则回滚。
   - 集成到 CI/CD 流水线,作为部署门禁。

3. **性能基线**:
   - 在 dev 环境运行 k6 或 JMeter 压测,建立性能基线。
   - 每次代码变更后跑一次,对比基线,发现性能回归。
   - 报告响应时间、QPS、错误率等指标。

4. **安全加固**:
   - 定期扫描依赖漏洞:`composer audit`、`npm audit`。
   - 配置 WAF(ModSecurity),拦截常见攻击(SQL 注入、XSS)。
   - 启用 nginx 的 limit_req,防 CC 攻击。
   - 数据库定期审计,删除无用的测试账号。

### 结语

本次开发测试环境搭建工作,虽然过程曲折(踩了 DNS、HSTS、Cookie、Shell 脚本、SQL 转义等多个坑),但最终交付了一套稳定可用、文档完善、可扩展的环境管理工具。过程中积累的底层知识(DNS 解析栈、TLS 握手、Cookie 隔离、浏览器缓存层级),对后续运维和排错都有长远价值。

本系列文档(六部分)既是工作总结,也是知识库。后续工程师遇到类似问题,可直接对照原理章节和故障排查手册,快速定位和解决问题。文档中的命令示例均经过实际验证,可直接复制执行;流程图和表格可用于团队培训和新成员上手;故障排查章节可作为值班排错的参考手册。

如读者发现文档中的疏漏或错误,欢迎指正。技术演进日新月异,本手册描述的命令行为、配置语法,基于 2026 年的 macOS Sonoma、Ubuntu 22.04、nginx 1.22、PHP 8.1、MySQL 8.0,后续版本可能有差异,使用时请对照官方文档。建议每次大版本升级后,对文档中受影响的章节做一次回测和修订,保持内容与实际环境同步。

---

*文档版本:v1.0*
*完成日期:2026-08-21*
*适用环境:macOS Sonoma + VirtualBox 7.0 + Ubuntu 22.04 + nginx 1.22 + PHP 8.1 + MySQL 8.0*



