[{"content":"现象 四个包里各有一个集成测试，都靠环境变量 REKIT_MYSQL_DSN 指向同一个 MySQL 库。单独跑每个都过：\ngo test ./internal/db/ # ok go test ./internal/domain/notify/ # ok 一起跑就翻车：\n--- FAIL: TestNotifyEndToEndWithFakeWechat 建表失败：Error 1824 (HY000): Failed to open the referenced table \u0026#39;wechat_configs\u0026#39; 原因 go test 默认按包并行。并行度是 -p，默认值等于 GOMAXPROCS，通常就是你的 CPU 核数。同一个包内的测试函数默认串行（除非显式 t.Parallel()），但不同包之间是同时跑的。\n而这四个测试共用同一个数据库。其中 internal/db 那个的职责是「验证建表链路」，它一上来就把库里所有表 DROP 掉，再从 DDL 真源重新建一遍：\n// 清空残留表，保证建表从真源全新拉起 full.ExecContext(ctx, \u0026#34;SET FOREIGN_KEY_CHECKS = 0\u0026#34;) for _, tbl := range tables { full.ExecContext(ctx, \u0026#34;DROP TABLE IF EXISTS \u0026#34;+tbl) } 于是就有了这个竞态：notify 的测试正在建带外键的表，internal/db 的测试在隔壁把 wechat_configs 抽走了。外键找不到被引用的表，MySQL 报 1824。\n报错信息完全没有提示这是并发问题，看起来就像 DDL 写错了——所以第一反应会去查建表语句的顺序，方向完全错了。\n解法 REKIT_MYSQL_DSN=\u0026#39;...\u0026#39; go test -p 1 ./... -p 1 让包之间串行执行。\n值得强调的是：这不是「跑得慢一点更保险」，而是这批测试的正确性前提。 只要它们共用一个库、并且其中有一个会做破坏性 DDL，并行跑就是错的。这一点应该写进文档和检查脚本，而不是靠人记住。\n我的做法是在统一的检查脚本里把它固化下来，让人没有忘记的机会：\ntest_flags=\u0026#34;\u0026#34; if [ -n \u0026#34;${REKIT_MYSQL_DSN:-}\u0026#34; ]; then # 共用一个库，必须串行；-p 1 不是性能取舍，是正确性要求 test_flags=\u0026#34;-p 1\u0026#34; fi go test $test_flags ./... 顺带说说共用库的其它代价 -p 1 解决了并发，但「多个测试共用一个库」这个设计本身还有别的坑：\n测试之间会互相看见对方的数据。 比如某个测试的逻辑是「如果已经存在管理员就跳过」，那它是否执行就取决于前面哪个测试跑过——测试结果依赖执行顺序，这是很难调试的一类问题。\n破坏性测试会吃掉真实数据。 我们后来把一份配置数据导进了这个测试库，忘了它同时还是集成测试的目标。下次谁跑一遍带 DSN 的测试，那份配置就没了。\n理想的做法当然是每个测试包用独立的 schema，或者每次跑之前起一个临时容器。但如果因为权限（比如测试账号没有 CREATE DATABASE）或者环境限制做不到，那至少要把约束写清楚：\n检查脚本里强制 -p 1； 文档里写明「这批测试会 DROP 掉目标库所有表」； 绝对不要把 DSN 指向任何有价值的库。 一个容易忽略的细节 如果测试代码里有「只允许在名为 xxx 的库里操作」这类保护，别轻易把它删掉。我们因为测试账号没有建库权限，把那个守卫改成了「用 DSN 里指定的库」——灵活性上去了，但也就此失去了「指错库时当场拒绝」的保护。\n改完之后，唯一的防线就只剩「不设 REKIT_MYSQL_DSN 就自动跳过」。这条防线是够用的，但它要求每个人都清楚：设了这个环境变量，就等于授权删库。这件事必须写在显眼的地方。\n","permalink":"https://taisuii.cn/posts/go-test-p1-shared-database/","summary":"四个包的集成测试，单独跑都过，一起跑就有一个报 Error 1824。","title":"Go 集成测试共用一个数据库时，-p 1 不是性能选项，是正确性要求"},{"content":"要解决的问题 本地跑的服务没有公网地址。自己用浏览器访问没问题，但凡是需要外部主动回调的对接就全卡住：\n微信公众号的消息回调、网页授权回跳 支付渠道的异步通知（notify_url） 各种第三方 Webhook OAuth 回调 这些都是对方的服务器要主动请求你，而且基本都强制 HTTPS。局域网 IP 和 localhost 在这里毫无意义。\n常见的做法是用 ngrok 之类的隧道服务，但域名是随机的、每次重启就变，注册回调地址时很难受。自己有一台带公网 IP 的小机器的话，用 frp 自建更省心：域名固定，想开几个开几个。\n整体形状 本地服务(:端口A) --frpc--\u0026gt; 公网机 127.0.0.1:端口B --nginx--\u0026gt; https://项目名.example.cn 三层各管一件事：\nfrpc/frps 只负责把 TCP 流量从内网穿到公网机的回环地址上。注意是 127.0.0.1，不对外暴露。 nginx 负责按域名分流、终结 TLS、加转发头。 DNS 用一条泛解析 *.example.cn 指向公网机，之后加子域名不用再动 DNS。 把 TLS 放在 nginx 而不是 frp 上，好处是证书只管一处，隧道那层保持简单。\nfrps 侧 公网机上跑 frps，配置可以极简：\nbindPort = 7000 [auth] method = \u0026#34;token\u0026#34; token = \u0026#34;\u0026lt;一串够长的随机字符串\u0026gt;\u0026#34; frpc 侧 内网机上，每个要暴露的端口一段 proxy：\nserverAddr = \u0026#34;\u0026lt;公网机 IP\u0026gt;\u0026#34; serverPort = 7000 [auth] method = \u0026#34;token\u0026#34; token = \u0026#34;\u0026lt;与 frps 相同\u0026gt;\u0026#34; [[proxies]] name = \u0026#34;myapp\u0026#34; type = \u0026#34;tcp\u0026#34; localIP = \u0026#34;127.0.0.1\u0026#34; localPort = 3000 # 本地服务端口 remotePort = 3100 # 公网机上的端口 remotePort 要先查再定。撞上已被占用的端口，frpc 会直接连不上，而错误信息不一定明显：\nssh 公网机 \u0026#39;ss -ltn\u0026#39; nginx 侧 server { listen 80; server_name myapp.example.cn; return 301 https://$host$request_uri; } server { listen 443 ssl; http2 on; server_name myapp.example.cn; ssl_certificate /path/to/cert/fullchain.pem; # *.example.cn 通配证书 ssl_certificate_key /path/to/cert/privkey.pem; location / { proxy_pass http://127.0.0.1:3100; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } } 几个别省的地方：\nproxy_pass 写完整的 http://127.0.0.1:端口。 只写端口号 nginx 会当主机名去做 DNS 解析，请求会挂住到超时而不是干脆报 502，极难定位。\nX-Real-IP 和 X-Forwarded-For 都要给。 后端做限流、风控、日志都靠它们。注意 $proxy_add_x_forwarded_for 是「在客户端送来的值后面追加真实 peer」，所以 XFF 首段是客户端可伪造的，末段才是本机 nginx 写的；X-Real-IP 被直接置为 $remote_addr，客户端追加不进去，最可信。\n$connection_upgrade 需要在 http 块里有对应的 map，否则 websocket 会断：\nmap $http_upgrade $connection_upgrade { default upgrade; \u0026#39;\u0026#39; close; } 把它变成一条命令 上面这套配置每加一个站点就要重写一遍，纯粹是体力活，而且容易手滑。写个脚本收敛掉：\nsite add \u0026lt;子域名\u0026gt; \u0026lt;端口\u0026gt; # 生成 vhost、套通配证书、nginx -t、reload site list # 所有站点及后端死活 site ports # 挑端口前先看占用 site rm \u0026lt;子域名\u0026gt; # 删站 脚本里有三件事值得做：\n写完先 nginx -t，不过就自动回滚。 新站删掉、旧配置还原，绝不留下让 nginx 起不来的配置。这一条最重要——一个坏配置能让整台机器上所有站点一起挂。 不覆盖已存在的站点，除非显式 --force。共享域名的机器上，手滑覆盖掉别人的站是很现实的风险。 校验子域名和端口格式，别让奇怪的输入拼进配置文件。 多项目共用一个域名时的规矩 一台穿透机、一个域名被多个项目共用时，冲突几乎是必然的。两条约定能省掉大部分麻烦：\n每个项目用自己的 \u0026lt;项目名\u0026gt;.example.cn，谁都不许占用顶级域。 顶级域被抢了，整个测试站的入口就废了。 remotePort 先查再用，并且在文档里维护一份已占用清单。 一点安全上的提醒 这套东西的本质是把内网服务开到公网上。所以：\n隧道只穿到公网机的 127.0.0.1，不要直接 0.0.0.0 暴露端口，让 nginx 做唯一入口。 frps 的 token 当密码对待，别进仓库、别进聊天记录。 测试环境如果接的是真实的支付渠道或真实的公众号，它就不再只是「测试环境」了。这种情况下公网可达意味着真实风险，配置该禁用的禁用。 ","permalink":"https://taisuii.cn/posts/frp-nginx-local-to-public/","summary":"微信回调、支付异步通知、OAuth 回跳——这些都要求对方能主动打进来。局域网里的服务天生做不到。","title":"把局域网里的服务暴露到公网调试：frp + nginx + 通配证书的一套做法"},{"content":"起因 一个测试用的域名，底下要挂一堆子域名：每个项目一个，随开随关。原来的做法是每建一个站就签一张单域名证书，很快就不可持续了——签发要人工点、续期要人工管，忘一个就是线上 HTTPS 失效。\n通配证书 *.example.cn 能一次解决：签一张，所有子域名复用同一份证书文件，新建站点只要在 nginx 里指过去就行。\n绕不开的前提：必须 DNS-01 Let\u0026rsquo;s Encrypt 的域名验证有三种方式，能签通配的只有一种：\n验证方式 怎么证明 能签通配吗 HTTP-01 在 http://域名/.well-known/acme-challenge/\u0026lt;token\u0026gt; 放一个文件 不能 TLS-ALPN-01 在 443 端口用特殊 ALPN 协议响应 不能 DNS-01 在 _acme-challenge.域名 加一条 TXT 记录 能 原因不难理解。HTTP-01 是「我能控制这个域名指向的那台服务器」——但 *.example.cn 代表无穷多个子域名，你没法对每一个都放文件，CA 也没法挑一个来验。DNS-01 验的是「我能改这个域名的 DNS 记录」，这才是对整个域名（含所有子域名）控制权的证明。\n所以：想要通配证书，就必须能改 DNS。没有别的路。\n两种改 DNS 的方式 acme.sh 支持这两种，区别只在续期：\n手动模式——它打印出要加的 TXT 记录，你去 DNS 控制台加，回来继续：\nacme.sh --issue --dns -d example.cn -d \u0026#39;*.example.cn\u0026#39; \\ --yes-I-know-dns-manual-mode-enough-go-ahead-please 那个又臭又长的参数名是故意的，它在提醒你：手动模式不能自动续期。60~90 天后到期，整套流程要人工重来一遍。\nAPI 模式——把 DNS 服务商的 API 凭据给 acme.sh，它自己加记录、自己删：\nexport DP_Id=\u0026#34;...\u0026#34; DP_Key=\u0026#34;...\u0026#34; # DNSPod 的示例，各家变量名不同 acme.sh --issue --dns dns_dp -d example.cn -d \u0026#39;*.example.cn\u0026#39; 凭据会存进 ~/.acme.sh/account.conf，之后全自动续期。\n如果这是个要长期活着的站，直接上 API 模式。 手动模式适合「先验证流程能跑通」，但别指望三个月后的自己记得这回事。\n两个实操细节 同名 TXT 记录是两条，不是一条。 签 example.cn + *.example.cn 时，CA 会下发两个不同的 challenge，但主机记录名字都是 _acme-challenge。DNS 允许同名多条 TXT，你必须两条都加、并存。很多人加了第二条时把第一条覆盖掉了，然后验证失败。\n提交前自己先查一遍。 Let\u0026rsquo;s Encrypt 对验证失败有频率限制，撞上了要等。加完记录别急着点继续，先确认权威 NS 上真的有了：\ndig +short TXT _acme-challenge.example.cn @\u0026lt;权威NS\u0026gt; dig +short TXT _acme-challenge.example.cn @8.8.8.8 两条值都出现，再去提交。多花二十秒，省掉被限流的风险。\nacme.sh 默认 CA 不是 Let\u0026rsquo;s Encrypt acme.sh 3.x 起默认 CA 是 ZeroSSL，不是 Let\u0026rsquo;s Encrypt。ZeroSSL 也能用，但它要 EAB 凭据、多一次账户注册，多一个续期时可能出问题的环节。如果你其它证书都是 LE，保持一致更省心：\nacme.sh --set-default-ca --server letsencrypt 要换就在签之前换。 换 CA 会重新生成 challenge，签完再换等于前面加的 TXT 记录白加了。\n装到位，顺便把续期钩子挂上 别手动拷贝 pem 文件。用 --install-cert，它会记住部署位置，续期后自动更新并执行 reload：\nacme.sh --install-cert -d example.cn --ecc \\ --key-file /path/to/cert/privkey.pem \\ --fullchain-file /path/to/cert/fullchain.pem \\ --reloadcmd \u0026#34;nginx -s reload\u0026#34; 签完之后 确认一下拿到的确实是通配：\nopenssl x509 -in fullchain.pem -noout -subject -dates -ext subjectAltName 要看到 DNS:*.example.cn, DNS:example.cn 两个都在。\n之后新增子域名就只剩一件事——写个 nginx server 块指向这份证书，reload。不用签发、不用等审核、不用点任何界面。\n备份的时候记得校验配对 证书是签给域名的，跟服务器无关。换机器直接把 fullchain.pem + privkey.pem 拷过去就能继续用，不用重签。\n但备份最怕的是存了一份对不上的证书和私钥，等到换机器那天才发现。校验很简单——两条命令的输出必须一致：\nopenssl x509 -in fullchain.pem -noout -pubkey | openssl md5 openssl pkey -in privkey.pem -pubout | openssl md5 顺便把 ~/.acme.sh/ 里的账户密钥也一起备份，这样在新机器上能接着自动续期，不用重新注册 ACME 账户。\n","permalink":"https://taisuii.cn/posts/wildcard-cert-dns01/","summary":"一个域名下要开十几个子域名做测试，每个都单独签证书是不可持续的。通配证书能一次解决，但它有个绕不开的前提。","title":"Let's Encrypt 通配证书为什么只能走 DNS-01，以及签完之后省下的那些事"},{"content":"现象 一套服务，生产和测试用同一份 docker-compose.yml，靠 .env 区分环境。测试环境的 .env 写得清清楚楚：\nREKIT_SITE_BASE_URL=https://test.example.cn REKIT_AUTH_URL=https://login.test.example.cn docker compose up -d 起来，容器健康，端口通，/health 返回 200。看起来一切正常。\n直到点了一下登录按钮——跳到了生产的登录页。\n原因 compose 文件里是这么写的：\nservices: site: env_file: - .env environment: HOST: 0.0.0.0 PORT_SITE: 5173 REKIT_SITE_BASE_URL: https://example.com # ← 写死的生产地址 REKIT_AUTH_URL: https://login.example.com # ← 写死的生产地址 Compose 的优先级规则是：environment 高于 env_file。同名变量，environment 赢。\n所以 .env 里那两行从来没生效过。而这个覆盖是完全静默的——compose 不警告、不打日志、docker compose config 要你主动去看才看得到。容器照常启动，健康检查照常通过，因为健康检查只探端口。\n为什么这类 bug 特别难发现 它有三个性质叠在一起：\n不报错。 覆盖是设计行为，不是错误。 健康检查发现不了。 /health 只证明进程活着，不证明它配置对。 只在跨环境时暴露。 生产环境里 environment 写死的值恰好就是对的，所以生产一直好好的，没人会去动它。 也就是说，这个 bug 会一直潜伏到你第一次尝试把服务部署到别的环境——而那时候你会先怀疑自己的 .env 写错了、怀疑挂载路径、怀疑 compose 没重建容器，绕一大圈才想到去看优先级。\n怎么确认 别猜，进容器看实际值：\ndocker compose exec \u0026lt;service\u0026gt; env | grep -E \u0026#39;^REKIT_\u0026#39; 或者不启动容器，直接看 compose 合并后的最终配置：\ndocker compose config 后者更好，因为它把 env_file、environment、.env 变量替换全部展开成最终结果，是「compose 究竟怎么理解你这份文件」的权威答案。\n怎么改 原则很简单：environment 里只放跟容器本身绑定、不随环境变化的东西，其余一律交给 env_file。\nservices: site: env_file: - .env environment: # 只留容器自身的运行参数 HOST: 0.0.0.0 PORT_SITE: 5173 HOST 和 PORT_SITE 是容器内部的监听配置，任何环境都一样，写死没问题。对外地址、密钥、数据库连接这些天然随环境变化的，全部走 .env。\n如果确实想在 compose 里给个默认值，用变量替换而不是硬编码，这样 .env 仍然能覆盖：\nenvironment: REKIT_SITE_BASE_URL: ${REKIT_SITE_BASE_URL:-https://example.com} 注意这跟前面那种写法的区别：${VAR:-default} 是先取 .env 里的 VAR，没有才用默认值，方向正好反过来。\n更一般的教训 这是「生产值写死在代码/配置里」的一个变种。同一个项目里，我在一天内撞到过四处同类问题：\ncompose 少写 command，容器跑的是镜像默认 CMD，结果一个本该是官网的容器实际在跑后端进程； compose 的 environment 写死生产 URL（就是本文）； 后台的「对接文档」页把服务地址写死成生产域名，测试环境的后台照样显示生产地址，接入方照着抄就打到生产上了； 代码里把登录跳转的站点标识写死成生产站点的 slug。 它们的共同点是：在生产环境下全都是对的，所以能长期存活；一旦换环境就集体失效，而且失效方式都是静默的。\n所以换环境部署之后，别只看 /health。进容器核对一遍实际生效的环境变量，再手动点一遍关键链路（登录、回调、支付跳转）。这几分钟能省掉后面几个小时。\n","permalink":"https://taisuii.cn/posts/compose-environment-overrides-env-file/","summary":"把同一套服务部署到测试环境，.env 里明明写的是测试域名，容器里跑出来的还是生产地址。","title":"Docker Compose 的 environment 会静默盖掉 env_file，而且不留任何痕迹"},{"content":"现象 一台机器上四个子域名，反代到本地四个端口。后端都没起来，按说应该整整齐齐返回 502。实际是：\nadmin.example.cn HTTP 502 0.12s login.example.cn HTTP 502 0.13s pay.example.cn HTTP 502 0.12s api.example.cn 超时 （curl 等到 timeout 才放弃） 前三个瞬间失败，第四个卡住不动。四份配置是同一个模板生成的，看起来一模一样。\n差别 把四份配置里的 proxy_pass 拉出来对比：\nproxy_pass http://127.0.0.1:31400; # admin proxy_pass http://127.0.0.1:31401; # login proxy_pass http://127.0.0.1:31402; # pay proxy_pass http://31403; # api ← 少了 127.0.0.1: 为什么不是 502，而是挂起 关键在于 nginx 怎么解析 proxy_pass 后面那个 URL。\nhttp://127.0.0.1:31403 —— host 是 127.0.0.1，port 是 31403。nginx 直接连本地回环，端口没人监听，内核立刻回 ECONNREFUSED，nginx 把它翻译成 502。失败得很快，因为根本没出网卡。\nhttp://31403 —— nginx 按 URL 语法解析，冒号前面什么都没有，于是 31403 整个被当成主机名。这是一个合法的主机名（域名规则允许全数字标签），所以 nginx 不会报配置错误，nginx -t 也照样通过。它会老老实实拿去做 DNS 解析。\nDNS 查一个不存在的名字，走的是超时重试路径——通常是若干秒一轮、重试几次。请求就卡在那里。等 resolver 最终放弃，nginx 才会返回 502 或 504。\n所以这个 bug 的表现是慢，不是错。而慢比错难查得多：你会先去怀疑后端卡住了、怀疑网络、怀疑超时参数，最后才想到去数配置里的字符。\n顺带还有一处 同一份配置里，紧挨着的下一行也被污染了：\nproxy_set_header Host 31403; # 其它三个站是 Host $http_host 后端收到的 Host 头是字符串 31403。如果应用要靠 Host 推导回调地址、拼绝对 URL、或者做 CORS 白名单匹配，这会引发一连串更莫名其妙的问题——而且都发生在「请求确实到达了后端」之后，看起来完全不像同一个原因。\n根因 这两处是同一个来源。面板类工具（宝塔、1Panel 之类）建反代站点时，界面上有个「目标 URL」输入框，模板会把它同时套进 proxy_pass 和 proxy_set_header Host。当时那一栏只填了 31403，没填 127.0.0.1:31403。\n也就是说：改生成后的配置文件只是治标。只要有人再从面板保存一次那个站点，配置就会被重新生成，bug 原样回来。要根治得去面板把那一栏改对。\n怎么避免 proxy_pass 后面永远写完整的 scheme://host:port。少写 host 不会报错，只会让你在几个月后花一下午查一个「后端偶尔很慢」的问题。\n多站点部署时，横向对比同类配置比逐份细读有效得多：\ngrep -h \u0026#34;proxy_pass\u0026#34; /path/to/vhost/*.conf | sed \u0026#39;s/^\\s*//\u0026#39; | sort -u 一眼就能看出哪一行长得不一样。\n用脚本或模板统一生成 vhost，别让人手填能被拼进配置的自由文本。\n排障时，区分「快速失败」和「超时」。前者通常是连接被拒（端口没人听、防火墙 REJECT），后者通常是解析卡住、路由黑洞、或者防火墙 DROP。这两类的排查方向完全不同，先分清能省很多时间。\n","permalink":"https://taisuii.cn/posts/nginx-proxy-pass-port-only/","summary":"同一组反代配置，三个站点干脆地返回 502，第四个却一直转圈到超时。区别只有一个冒号。","title":"nginx 的 proxy_pass 只写端口号：一个不报 502、而是把请求挂死的坑"}]