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

完整技术文档合集

编写日期: 2026-08-21

作者: 技术文档组

文档版本: 1.0

目录

第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。这一步必须由已有服务器访问权限的人(管理员或用户)完成,操作方式通常是:



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

### 2.1 VMware Fusion 环境检查

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

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

第三章:Debian 系统配置

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

3.1 网络配置

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

确认虚机 IP:

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



## 第四章: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 支持虚机快照——某一时刻的完整状态(内存+磁盘)冻结。搞砸了可以一键恢复到快照点:



## 附录:关键命令速查

### A.1 VMware 虚机管理

总结

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

核心成果:

  1. 明确了项目背景:梳理了 1dao 站群的六个子站点(主站、导航、博客、商城、禅道、walle)、线上环境信息(IP、OS、软件版本)、接手需求和授权流程,理清了域名耦合的规模(数据库 400+ 处、代码 24 个文件)。
  1. 完成了方案决策:基于安全隔离、风险控制、授权依赖等考量,从"直连线上"转向"本机虚拟机"方案,确立了"测试环境与生产完全隔离"的根本原则。
  1. 创建了 VMware 虚拟机:检查了 VMware Fusion 工具链,配置了 2GB 内存/40GB 磁盘/Bridge 网络的虚机,选用 Debian 13 trixie netinst ISO,编写了 preseed 自动应答文件并用 pycdlib 重新打包 ISO(解决了 boot info table 的坑),实现了全自动安装。
  1. 完成了 Debian 系统配置:配置了静态网络(虚机 IP 192.168.207.130)、SSH 密钥认证、清华 apt 源、Asia/Shanghai 时区、英文 locale、dev 用户 sudo 权限、ufw 防火墙(开放 22/80/443)。
  1. 打通了 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 | 本系列第一部分,共三部分

第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 官方仓库:



## 第二章 备份文件解密

### 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 解密执行

第三章 数据库恢复

3.1 创建数据库

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


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,节省磁盘空间:


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

## 第四章 站点代码恢复

### 4.1 解压 site-data 包

第五章 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 备份解压:


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

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

### 6.1 测试结果总览

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

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 现象

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 软件栈安装



## 附录:版本信息表

| 组件          | 版本      | 安装命令             | 配置文件路径                          |
|---------------|-----------|----------------------|---------------------------------------|
| 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 运维*

  

第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 个文件硬编码了域名:



## 第二章:域名方案演变历程(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`:

第三章: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"。


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 的写法。实测:



## 第四章: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`):

#!/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:


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` 指定独立数据目录:

第七章: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 等):



## 第八章:经验教训

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

### 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` 的配置管理,以及如何把这些脚本组织成可维护、可扩展的工具集。

  

第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 检查:


#!/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 按**旧域名长度降序**执行(长的先替换):

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 的替换互不影响:

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 中实现:

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

3.1 第一版实现

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


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,且强制校验非空:


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 数据库替换循环

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


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 解析:


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):


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

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

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


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 模式下的核心代码:


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 单对替换:


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

执行后,验证三处:

  1. eb_system_config 表中 menu=site_url 的 value:

   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,此处单对测试目的是验证主流程能跑通。)

  1. 代码文件:grep -l dev.1dao.cc /var/www/1dao.cc/index.html 命中。
  2. 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,显式声明"这里允许失败":

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):



## 第五章 `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`:

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`

第六章 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 加:


add_header X-Environment dev always;

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

6.4 各站 root 配置

完整 dev-domains.conf:



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

### 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 失败也能被捕获。

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

set -euo pipefail

for x in $items; do

cmd $x # 某些 x 会让 cmd 失败,但这是预期的

done

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


#!/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:常用命令速查

附录 D:故障排查

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

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

排查:


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

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

排查:


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 没连上。

排查:


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 集成等内容。

第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 的验证(除非手动导入并信任);
  • 这个强制行为在浏览器层面,无法通过服务器配置绕过。


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

浏览器是最后一个"坑",因为它在用户和服务器之间插入了多层缓存: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 缓存

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。

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 误判。


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

数据库是 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` | 所有库 + 建库语句 | 自动恢复所有库 |

第六章:网络配置的教训

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

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 内置)决定:



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

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

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

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

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

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

第八章:文档化的重要性

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

8.1 技术文档的价值

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

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

解决方案:为每个方案写技术文档,包含三大要素:

  • 架构图:整体结构与数据流;
  • 使用步骤:从零到可用的可复现命令序列;
  • 故障排查:常见错误的诊断与解决。

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

8.2 文档结构

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


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

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

8.3 ASCII 架构图的优势

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

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

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


                    +-----------------+
                    |   宿主机(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:


#!/usr/bin/env bash

## 第九章:工作方法论

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

### 9.1 先调查后动手

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

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

第十章:最佳实践速查表

以下是全书所有最佳实践的分类汇总,可作为日常工作的 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、先备份、小步快跑、工具化、配置驱动"作为肌肉记忆,工程风险就能被压到最低,工程效率就能被拉到最高。

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

第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 配置上下文:


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 的命令:

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,两者不是一回事。

本项目最初的错误设计是 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:

   setcookie('login_token', $token, [
       'domain' => 'dev.1dao.cc',  // 注意:不带前导点
       'secure' => true,
       'httponly' => true,
       'samesite' => 'Lax',
   ]);
  1. 使用不同浏览器:开发用 Chrome,线上用 Firefox,各自独立的 Cookie 存储。这是临时方案,治标不治本。
  2. 使用不同浏览器 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:


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 的

排查方法:



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

### 4.1 set 命令详解

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

set -e # errexit:命令失败立即退出

set -u # nounset:未定义变量报错

set -o pipefail # 管道中任一环节失败即报错



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

#!/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`:

if ! mysql -uroot 1dao_db -e "REPLACE INTO ..."; then

echo "ERROR: 替换失败" >&2

exit 1

fi



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

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

#!/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:

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`:

while IFS=$'\t' read -r table col; do

mysql -uroot 1dao_db -e "REPLACE INTO ... " || true

done < <(...)



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

**pipefail 的陷阱**:

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



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

### 4.2 shell 数组与排序

bash 的数组语法:

第五章:数据库快照原理

5.1 mysqldump 机制

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

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

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

关键参数:


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 字节字符(如表情符号)正确导出。

管道的应用:



## 第六章: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;` 标记的服务器,处理所有未匹配的请求。

**示例**:

第七章:故障排查手册

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 缓存中。

  1. 检查证书是否匹配域名

   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 中,浏览器报错。

- 如果是自签名证书,需要手动添加到系统钥匙串信任。

  1. 检查是否在 HSTS preload list

- 访问 hstspreload.org 查询域名。

- 如果用了 .dev / .app 等 Google TLD,会被强制 HTTPS。即使提供 HTTP,浏览器仍会改写为 HTTPS。

  1. 检查浏览器 HSTS 缓存

- chrome://net-internals/#hsts

- 在 "Query HSTS/PKP domain" 输入域名,查询是否被缓存。

- 在 "Delete domain security policies for domain" 删除该域名。

  1. 检查系统时间

- 证书有效期是绝对时间。如果系统时间不对(如 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 解析

   ping dev.1dao.cc
   # 如果 ping 不通域名,但 ping IP 通,是 DNS 问题
   # 如果 ping IP 也不通,是网络层问题
  1. curl -v 检查连接

   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。

  1. ssh 虚机,检查服务状态

   ssh vagrant@192.168.207.130
   sudo systemctl status nginx
   sudo systemctl status php8.1-fpm
  1. 检查 nginx error.log

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

   sudo ufw status
   # 如果 80/443 未开放:
   sudo ufw allow 80
   sudo ufw allow 443
  1. 检查 macOS hosts 文件

   grep 1dao.cc /etc/hosts
   # 应输出:192.168.207.130 1dao.cc dev.1dao.cc ...

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

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

排查步骤:

  1. curl -I 检查状态码

   curl -I http://dev.1dao.cc/
   # HTTP/1.1 500 Internal Server Error
   # HTTP/1.1 200 OK 但内容空白
  1. 查 nginx error.log

   sudo tail -50 /var/log/nginx/error.log
   # PHP message: PHP Fatal error:  Uncaught ...
   # 或:FastCGI sent in stderr: "Primary script unknown"
  1. 查 PHP-FPM 错误日志

   sudo tail -50 /var/log/php8.1-fpm.log
  1. 查应用日志

   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
  1. 检查文件权限

   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
  1. 检查数据库连接

   # 在虚机上测试 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
  1. 清缓存

   # 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 确认系统层面

   which-env
   # 应输出: Current environment: dev (pointing to 192.168.207.130)
   # 同时 ping 1dao.cc 应解析到 192.168.207.130
   ping -c 1 1dao.cc
  1. 清浏览器 DNS 缓存

- Chrome:chrome://net-internals/#dns → "Clear host resolver cache"

- Firefox:about:networking#dns → "Clear DNS Cache"

  1. 清 Socket 连接池

- 浏览器会复用已建立的 TCP 连接(keepalive)。如果旧连接还在,即使 DNS 已切换,仍走旧 IP。

- Chrome:chrome://net-internals/#sockets → "Flush socket pools"

- 或关闭所有浏览器窗口重开。

  1. 用无痕窗口验证

- 无痕窗口有独立的 DNS 缓存和 socket 池。

- 如果无痕窗口正常而普通窗口异常,确认是浏览器缓存问题。

  1. 检查 Service Worker

- SW 拦截请求可能返回旧环境的缓存。

- chrome://serviceworker-internals/ 注销所有 SW。

  1. 检查 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

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

   ls -la /var/lib/1dao-env/
   # dev.sql.gz 应存在,且修改时间是最近
   # 如果 dev.sql.gz 不存在或时间过早,restore 失败但未报错
  1. 检查数据库实际值

   mysql -uroot 1dao_db -e "SELECT value FROM tb_config WHERE name='site_url'"
   # 应输出 dev.1dao.cc
   # 如果仍是 1dao.cc,数据库未切换成功
  1. 检查 set -euo pipefail 是否导致脚本提前退出

   # 临时修改脚本,在每步后加 echo 调试
   bash -x /usr/local/bin/switch-env dev
   # -x 选项打印每条命令,查看哪一步退出
  1. 检查 CONCAT 的 \t 问题

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

   sudo nginx -t
   sudo systemctl reload nginx
   # nginx 配置改了但不 reload,旧配置仍生效
  1. 检查 .env 文件

   grep -E 'APP_URL|DOMAIN' /var/www/1dao/.env
   # 应为 dev.1dao.cc

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

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

排查步骤:

  1. 确认虚机 IP 可达

   ping 192.168.207.130
   # 应能 ping 通(虚机在线)
  1. 用 IP+端口直连绕过 DNS

   curl -H "Host: dev.1dao.cc" http://192.168.207.130/
   # 如果能返回页面,说明 nginx 和 PHP 都正常
   # 问题在 DNS 解析层
  1. 确认 hosts 条目存在

   grep 1dao /etc/hosts
   # 应输出:192.168.207.130  1dao.cc dev.1dao.cc ...
  1. 理解 macOS 无网络时不解析 hosts

- 这是 macOS 的已知行为。WiFi 断开时,mDNSResponder 没有活动网络接口,即使 hosts 文件有配置也不解析。

- 验证:ping 1dao.cc 会报 Unknown host,但 ping 192.168.207.130 正常。

  1. 解决方案

- 重新连接 WiFi(哪怕 WiFi 不通外网,只要接口 active 即可)。

- 创建虚拟网络接口:


     sudo ifconfig lo0 alias 127.0.0.2 up
     # 这会让系统认为有活动接口,mDNSResponder 开始工作

- 用 IP+Host 头直连,绕过 DNS。


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

8.1 macOS 主机命令



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

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

### 阶段一:环境规划(上午 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*