# 攀枝花测试环境 —— 部署台账 目标机:攀枝花政务云(Ubuntu 26.04.1),公网 `117.172.73.106`,入向只开 `8081`/`8082`。 2026-09-22 建成。**这是开发测试环境,不是生产**(无域名/备案/TLS;生产验收清单见 `docs/huifu/08-上线与环境.md` §4)。 ## 布局 | 路径 | 内容 | |---|---| | `/data/talent-card/app.jar` | 后端 fat jar(103 MiB)。**2026-09-29 16:51 更新到提交 `7a5303f`**:V156 已应用(库版本 156,`merchant_onboarding_application.store_cashier_url` 列已建),启动 14.7 s、`/actuator/health` UP。上一版(提交 `61ba794`、库版本 155)留在 `app.jar.bak-20260929-1650`,更早一版在 `app.jar.bak-20260929-1326`;回滚直接换回即可(V156 同样只加一列可空列,旧代码忽略它,安全)。上传来源文件的 sha256:`79d63e44…e4554` | | `/data/talent-card/admin/` | 后台管理端 dist(nginx 托管)。**2026-09-29 16:54 由 `redeploy-admin.sh` 从 Gitblit `main`(`7a5303f`) 重建并替换**:进件详情的「门店照片」区多了「店铺收银台」一列。注意本次构建日志里有「没有 `/etc/talent-card/admin-build.env`,本次不带高德 key 构建」——见下文「已知缺口」 | | `/data/talent-card/uploads/` | 上传落盘目录(对应 `UPLOAD_DIR`) | | `/data/talent-card/src/` | 从 Gitblit 克隆的源码,服务器上构建 admin 用 | | `/data/talent-card/redeploy-admin.sh` | admin 重部署脚本(见下) | | `/data/talent-card/app-flyway-verify.log` | 2026-09-22 空库跑 148 个迁移的原始证据 | | `/etc/talent-card/env` | **唯一的部署变量来源**(`600`),systemd 的 `EnvironmentFile` | | `/etc/systemd/system/talent-card.service` | 后端单元(`active` + `enabled`) | | `/etc/nginx/sites-available/talent-card` | 8081 站点:`/api/` `/uploads/` 反代,`/` 托管 dist | | `/data/mysql` | MySQL datadir(从 `/var/lib/mysql` 迁入,AppArmor 走 `local/usr.sbin.mysqld` 覆盖) | | `/var/lib/mysql` | 迁移前的原始副本,**回滚用**,确认稳定后可删 | ## 环境变量(`/etc/talent-card/env`,10 个键) **改配置只改这个文件**,不要在单元里写 `Environment=` —— systemd 里 `EnvironmentFile` 会覆盖同名 `Environment=`,变量散两处迟早踩坑。改完 `systemctl restart talent-card`。 | 键 | 为什么必须有 | |---|---| | `MYSQL_URL` | 默认值指向办公室开发库(`192.168.110.13`)。这里指向 `127.0.0.1`。**必须带 `rewriteBatchedStatements=true`**:不带时 `batchUpdate` 在驱动层退化成逐条发送,实测同一批 15.4 万行从 **28.5 秒**变成 **986 秒**(约 100 倍)。 | | `MYSQL_USERNAME` / `MYSQL_PASSWORD` | 业务账号 `talent_card@localhost`(`caching_sha2_password`)。密码只在这个文件里。 | | `REDIS_PORT` / `REDIS_PASSWORD` | 默认值是 `6368` + 一个弱口令,与实际装的 Redis 不符 → Actuator 的 Redis 健康指示器会让 `/actuator/health` **永远 503**。业务代码零调用 Redis,**但健康探针会用到**。 | | `SPRING_FLYWAY_ENABLED` | `application.yml` 里 `flyway.enabled: false` 是硬编码的,测试环境要跟着迁移走,所以常开。 | | `SERVER_ADDRESS` | 后端只绑 `127.0.0.1`,对外一律经 nginx。 | | `UPLOAD_DIR` | 上传目录指到 `/data`(系统盘只有 40G)。 | | `APP_PRIVACY_ENCRYPTION_KEY` | 默认值是**仓库里公开的开发 key**。`SensitiveDataCrypto` 是 `SHA-256(该字符串)` 派生 AES key,所以任意字符串合法。**必须在写入任何隐私数据之前设**——事后换会让旧数据解不开。 | | `CORS_ALLOWED_ORIGINS` | **必须包含浏览器实际访问的 origin,哪怕是同源。** 浏览器对同源的非简单请求(POST + JSON)也会带 `Origin` 头,而 `ApiCorsConfig` 只要看到 `Origin` 就按白名单校验,不在名单里直接 **403 `Invalid CORS request`**。漏了它的症状就是「登录 403」。当前值:`http://117.172.73.106:8081`、`http://117.172.73.106:8082`、`http://www.dqrcfwpt.com:8081`、`https://www.dqrcfwpt.com`(后者为将来上 TLS 预留)。**换域名 / 换端口 / 换协议都会换 origin,都要补** —— 2026-09-22 因为换域名又踩了一次。 | `allowed-origin-patterns`(`http://localhost:[*]`)没被覆盖,所以本地 vite / HBuilderX 调试来源照旧放行。 ## 重部署 admin ```bash bash /data/talent-card/redeploy-admin.sh # 默认拉 main,可传分支 ``` 它做:`git fetch` → `npm ci` → `npm run build` → 替换 `/data/talent-card/admin/`。 高德 key 等构建期变量放 `/etc/talent-card/admin-build.env`(`600`),脚本会自动拷成构建用的 `.env`。 **不要把 key 放进仓库**:`talent-card-admin/.gitignore` 里只忽略了 `*.local`,**没有忽略 `.env`**。 之所以能在服务器上构建:Gitblit `git://120.76.84.145/java/talent-card.git` **匿名可读**(已实测 `ls-remote` / `clone` 都不需要凭据),`package-lock.json` 的 `resolved` 指向 `registry.npmmirror.com` (可达)。服务器上装了 `git 2.53.0` / `nodejs 22.22.1` / `npm 9.2.0`。 > 命令通道单次只能带约 4KB,所以 dist(4.4MB)走不了命令通道——这是当初需要人工 SFTP 传包的原因, > 现在用「服务器自建」绕过去了。 ## HTTPS(2026-09-29 已上线;2026-10-08 域名变更) > **2026-10-08 域名变更**:甲方把 `www.dqrcfwpt.com` 与退域名 `dqrcfwpt.com` 解析到了他们自己的机器(公司官网), > 那两个名字**已不在我们手上**。测试环境改用新建子域 **`rencaika.dqrcfwpt.com`**,证书换成 > **Let's Encrypt(certbot 自动续期)**。本节里凡提到 `www` / `:8081` 的表述都是**变更前**的历史; > 当前口径以下面「端口」与「证书」两行为准。另:旧证书是**腾讯云**签发(不是华为云)。 | 项 | 值 | |---|---| | 证书(两套并存,nginx 按 SNI 选) | ① **`rencaika.dqrcfwpt.com`** → **Let's Encrypt**:`/etc/letsencrypt/live/rencaika.dqrcfwpt.com/fullchain.pem` + `privkey.pem`;② `www`/退域名 → **腾讯云**签发的 DV:`/etc/nginx/ssl/talent-card/server.crt`(**含中间链的 bundle**,4451 字节)+ `server.key`(`600`)。**之后换腾讯云证书到新子域:只改 443/8081 那两块里的两行路径再 reload,不需要动别的。** | | 覆盖域名(SAN) | ① `rencaika.dqrcfwpt.com`(LE 那张)② `dqrcfwpt.com` + `www.dqrcfwpt.com`(腾讯云那张) | | **到期** | ① **2027-01-06**(LE,**`certbot.timer` 自动续期,不用人管** —— 前提是 80 上的 `/.well-known/acme-challenge/` 直读例外别删)② **2027-02-11**(腾讯云那张,**人工**:到期前重新下载再传一次) | | 端口 | **`rencaika.dqrcfwpt.com` 就是测试环境**:**443**(`https://rencaika.dqrcfwpt.com`,正式入口、**不带端口**)与 **8081**(同机兼容)都提供完整功能;**8082 = 明文兜底**(`http://117.172.73.106:8082/`,给 IP 访问;HSTS 管不到 IP,这条永远可用);**80 只做 301**,且给 `/.well-known/acme-challenge/` 留了直读例外(**删了证书就自动续期不了,且是静默失败**)。旧名字 `www`/退域名的 443 仍挂着生产占位页,但甲方已把那两个名字指走,**那段实际不生效、可删**。 | | ⚠️ 为什么 8081 必须是 TLS、明文要另开 8082 | 443 上发了 HSTS,而 **HSTS 按「主机」生效、覆盖该主机的所有端口**(RFC 6797 §8.3),且升级时**显式端口会保留**(`http://host:8081` → `https://host:8081`)。所以 8081 若只说纯 HTTP,浏览器(只要访问过一次域名)一律打不开。**也不能让 nginx 用两个 server 块在 8081 上分别 listen 明文与 ssl** —— 同一个 address:port 的 ssl 标志必须一致,混用直接校验失败,报 `no "ssl_certificate" is defined for the "listen ... ssl" directive`,而且**错误行号指向那个明文块**,极易看错地方(2026-09-29 就在这里绕了几圈)。所以拆成 8081(TLS) + 8082(明文)。 | | 生产上线时 | 把 443 块里的 `root` 与 `location` 换成 `include snippets/talent-card-locations.conf;`,80 的 301 不用动。该域名上的 `/api` 目前返回 503 JSON,避免请求落到静态页被误解为可用 | | HSTS | `max-age=86400`(1 天),**没有** `includeSubDomains` / `preload` | | 公网实测 | 链 2 张、`Verify return code: 0 (ok)`、`curl` 不带 `-k` 返回 200、`http://` → 301、HSTS 头存在、`https://` Origin 的登录请求不再 403 | **HSTS 为什么先只给 1 天**:这张证书是**人工续期**的。若一次给到一年又忘了续,浏览器会把访问者锁在硬错误页上、 连"点继续"的逃生口都没有。**等第一次续期成功之后**再把 `max-age` 提到 `31536000`,并考虑 `includeSubDomains`。 不加 `includeSubDomains` 的另一个原因:将来若新增子域(例如 OSS 的 `img.`)而它还没配好 HTTPS, HSTS 会把那个子域一并锁死;`preload` 更是单向门,提交后很难退出。 **顺带解锁的两件事**(都在对方侧操作): - **汇付的五个回调地址**现在可以是真实公网 HTTPS 了,形如 `https://www.dqrcfwpt.com/api/payments/huifu/notify`——这解掉了 `.scratch/pending-order-payment-flow/README.md` 里 F1 那个长期项。**要在汇付控台逐条注册**,且与本地 yml 的 `HUIFU_*_NOTIFY_URL` 保持一致。 - **微信小程序的服务器域名**填 **`https://rencaika.dqrcfwpt.com`**(request / uploadFile / downloadFile 都要,**不带端口**)。 **还没做的:** - ~~退域名 `dqrcfwpt.com` 没有 A 记录~~ **已作废**:2026-10-08 起 `dqrcfwpt.com` 与 `www` 都解析到甲方的机器 (公司官网),我们不再持有这两个名字,原来那条「补 A 记录」的待办随之取消。 - **8081 仍是全公网可达的明文端口**,上面挂着后台 API。HTTPS 已稳定,建议请云管理方关掉它; 在那之前它仍是唯一的明文入口。 - `CORS_ALLOWED_ORIGINS` 里没有 `http://www.dqrcfwpt.com`(80 那条)——**已无关紧要**,因为 80 现在 301 到 https。 ## 小程序体验版接入测试环境(2026-09-29) **三处必须同时对,任一处不一致都表现为「请求失败」**: | 位置 | 值 | |---|---| | 代码 | `rencaika/config/api.uts` 与 `rencaika_merchant/config/api.uts`:`apiEnvironment = 'staging'` + `stagingBaseUrl = 'https://rencaika.dqrcfwpt.com'` | | 微信后台 → 开发管理 → 服务器域名 | request / uploadFile / downloadFile **都填** `https://rencaika.dqrcfwpt.com`(**不带端口**,与代码里的 `stagingBaseUrl` 一致);socket / udp / tcp **留空**(两个小程序零 WebSocket 用法,已 grep 确认) | | 体验成员 | 「成员管理」里逐个加微信号;配额以后台实时提示为准 | - **两个 appid 各配一遍**:人才端 `wxcfee477acb54d179`(后端 `app.wechat.mini-program.app-id` 与它一致)、商户端 `wx550e520ad19458ea`。 ⚠️ `rencaika/manifest.json` 顶层还有一个 `wx6ae2cadae28fce1`,与 `mp-weixin` 段不一致 —— **要确认开发者工具里实际用的是哪个**;用错则与后端 appid 对不上,登录必失败。 - **已验证的服务端前提**:后端 → `api.weixin.qq.com:443` 可达;后端那对 appid/secret **有效**(已向微信换到 token);微信 IP 白名单**未挡**本机(否则会报 40164)。 - **切回 / 切生产**:本机联调把 `apiEnvironment` 改回 `'development'`;生产上线改 `'production'` 并填 `productionBaseUrl`,**且那时微信白名单要去掉端口**(微信规则:不配端口时请求 URL 也不能带端口)。 - **副带效果**:切到 `staging` 后,所有 `apiEnvironment == 'development'` 的开发期便利(假 talentId、演示数据、预览模式、跳过登录)自动关闭 —— 体验版必须真登录。 ## 已知边界 - **收不到汇付回调**:非公网环境的既定事实与既定设计(`docs/adr/0004-active-query-is-authoritative.md`、 `.scratch/pending-order-payment-flow/README.md` 的 F1)。收敛靠读接口惰性查询 + 60 秒一轮的对账快车道, 订单不会卡住。要收回调得等备案域名。 - **8081 对全公网开放且是纯 HTTP**(无 TLS),token 明文传输。已确认接受(测试环境)。 - **域名**:`www.dqrcfwpt.com` → `117.172.73.106`(2026-09-22 解析)。**访问必须带端口**: `http://www.dqrcfwpt.com:8081` —— 80/443 未备案开不出来,实测不带端口是超时。退域名 `dqrcfwpt.com` **没有 A 记录**(公网解析器确认 `ENODATA`),要用不带 www 的形式得先补记录。 - nginx 的 `server_name _` 是通配,所以域名访问无需改站点配置(实测 `http://www.dqrcfwpt.com:8081/` 返回 200)。 - 数据库:`talent_card`,字符集 `utf8mb4` / `utf8mb4_0900_ai_ci`(与测试库同口径, 照 `ResetTestDatabase.java` 只写 charset 不写 collate;**不要**照抄 `init-local-mysql.sql` 的 `utf8mb4_unicode_ci`,那是 5.7 开发库用的)。 - MySQL 8.4 上迁移里的 7 条 `VALUES() is deprecated`(1287)仍可运行,但该语法未来版本会被移除。 - **后台的「商户创建」页地图没有高德 key**:`/etc/talent-card/admin-build.env` **不存在**,`redeploy-admin.sh` 每次都会打印「本次不带高德 key 构建(地图相关页面会报未配置)」。**这不是某次重建弄丢的**:本次替换后的 `admin/assets/MerchantCreateView-.js` 与改动前的构建产物**内容哈希完全相同**(`D144nYCr`,没被本次改动碰到), 两者都没有内联 key,说明构建期变量一直就没配。要用地图:把 `VITE_AMAP_KEY` / `VITE_AMAP_SECURITY_CODE` 写进 `/etc/talent-card/admin-build.env`(`600`,模板见 `talent-card-admin/.env.example`)再跑一次 `redeploy-admin.sh`。别把 key 放进仓库(`.gitignore` 只忽略 `*.local`,**没有忽略 `.env`**)。 ## ⚠️ 用空库部署后,第一件事:处置引导账号 `admin` `V76__admin_operator_authentication.sql:44-47` 会种一个账号,其口令哈希**经实测就是 `123456`** (用 node 按 V76 的参数算 PBKDF2-HMAC-SHA256 / 210000 轮 / 盐 `AQIDBAUG…`,结果与迁移里写死的值逐字符相同)。 `where not exists` 的写法意味着**每一个新库都会得到这个已知口令**。 **所以每次用空库部署(包括将来的生产)之后,必须立刻把 `admin` 的口令换掉**,否则服务商的安全扫描会 再次报「Web弱口令」,而且这次是真的能被登进去。 根治要改代码/加迁移(引导口令从 `ADMIN_BOOTSTRAP_PASSWORD` 注入 + 首登强制改密), 属后台账号体系、不属支付链路 —— 已按 `AGENTS.md` 写成 `.scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md`。 在这台机器上,快速自查有没有遗留的弱口令账号: ```bash python3 /root/check-weak-passwords.py # 拿 69 个常见弱口令撞库里所有 password_hash,只打印命中项 ``` ## ⚠️ docker 的防火墙残留还在内存里 —— **不要去清它** `docker` 2026-09-21 已彻底卸载(包 0 条、`/etc/docker` 与 `/data/docker` 已删、网桥已删、路由干净), 但 **iptables/nftables 里 docker 留下的链与规则仍在运行内存中**: ``` 链:DOCKER / DOCKER-USER / DOCKER-FORWARD / DOCKER-CT / DOCKER-INTERNAL / DOCKER-BRIDGE -A POSTROUTING -s 172.18.0.0/16 ! -o br-bced14232775 -j MASQUERADE -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE -A DOCKER ! -i br-bced14232775 -o br-bced14232775 -j DROP (docker0 同款) ``` **已逐条核实为「当前无害」**:网卡只剩 `ens5` / `lo`,规则引用的 `docker0`、`br-bced14232775` 都不存在, 所以按出接口匹配的那些 DROP/ACCEPT **永不命中**;两条 MASQUERADE 只匹配源 `172.17`/`172.18.x`,而本机是 `172.192.19.1`,**不命中**(对堡垒机那条路也无影响:入向包不走 POSTROUTING,我们的回包源也不是 172.18.x)。 另外 `iptables -S INPUT` 只有 `-P INPUT ACCEPT`、没有任何规则,`ufw` 是 `inactive` —— **本机对入向 80/443 不设限,那是云安全组挡的,与这些残留无关。** **不要为了「清理干净」去动防火墙。** 收益是零(现在无害),风险是切断唯一入口:这台机器**没有 console/VNC**, 唯一管理通道是堡垒机。`AGENTS.md` 第一条纪律划的就是这条边界。 **而且根本不需要清 —— 它不持久:** | 检查 | 结果 | |---|---| | `iptables-persistent` / `netfilter-persistent` | **没装** | | `nftables.service` | 存在但 **disabled** → 开机不加载 nft 规则 | | `/etc/nftables.conf` | 镜像自带的存根;服务 disabled,开机不会读它 | | 开机时间 | 2026-09-20 17:59(截至 09-22 已连续运行 1 周 21 小时)| **下一次重启时内核从空规则表起来,这些就自然消失了。不必为它专门安排重启。** **这条也是 `AGENTS.md`「别再装 docker」的又一实证**:包卸了、目录删了、网桥没了,防火墙规则却还在内存里 —— 如果哪天有人重建 `docker0` 或把 `172.17`/`172.18` 网段带回来,这些规则会**立刻开始生效**, 而 `172.18.0.0/16` **正是堡垒机所在网段**(2026-09-21 失联事故的成因)。 > 如果将来**确实**要清(例如审计明确要求),前提是**先具备带外通道**(请服务商提供 console/VNC 或临时开通), > 并挂 `systemd-run --on-active=5min /bin/bash -c '<恢复命令>'` 自动回滚,清完立刻复检堡垒机通道。 > **在没有带外通道之前,一律不做。** ## 凭据现状(2026-09-22 自查,因服务商「Web弱口令」告警) 告警内容:扫描器抓到一次成功登录 `{"account":"admin","password":"123456"}`。根因与仓库侧的修法见 `.scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md`(引导账号 + 无强度策略)。 **这台机器上的现状:** | 项 | 现状 | 结论 | |---|---|---| | 后台 `admin` 账号 | **2026-09-29 用 `/root/reset-admin-password.py` 重置**为 20 位、四类字符齐全的强口令;明文只写 `/root/admin-password.txt`(`600`),脚本输出里只有类别摘要、哈希自检与「真调一次登录接口」的成败(不打印口令与 token)。重置时一并清零失败计数/锁定、并吊销所有在用会话(那次告警报文里带出过 accessToken)。自查用 `/root/check-weak-passwords.py` | ✓ | | MySQL `root` / `debian-sys-maint` | `auth_socket`,**不启用密码认证**(只能以本机 root 身份连) | ✓ | | MySQL `talent_card` | 32 位随机、四类字符齐全,仅 `localhost`,`caching_sha2_password` | ✓ | | Redis | 32 位随机、四类字符齐全,仅监听 `127.0.0.1` | ✓ | | `/etc/mysql/**` 里存的明文口令 | 无 | ✓ | | `/etc/talent-card/env` | `600` root | ✓ | | OS 账号 `root` / `ubuntu` | **两个都设了口令,且 `ubuntu` 在 `sudo` 组** | ⚠️ 见下 | | sshd | `PermitRootLogin yes` + `PasswordAuthentication yes` | ⚠️ 见下 | **⚠️ 两条必须由人自己处理的:** 1. **OS 口令**在技能包的 `credentials.txt` 里(按约定不读进上下文),所以 `root` / `ubuntu` 的口令强度 必须由用户自己核。若还是回执单那个初始口令,**必须换**(`ubuntu` 在 sudo 组,等于第二个 root)。 2. **千万不要为了"整改弱口令"去关 `PermitRootLogin`** —— 堡垒机就是用 root + 口令登录的, 关掉等于切断唯一管理通道(`AGENTS.md` 记的那次失联事故同类)。正确做法是把口令换强,而不是关入口。 **口令已按四类字符口径轮换(2026-09-22)**:MySQL 与 Redis 的口令都重生成过,现为 32 位、含大小写/数字/ 特殊符号(符号只用 `@%^*+_.-`,避开会破坏 systemd `EnvironmentFile` 解析与 shell 展开的 `$ " ' \ !` 和空格)。 验证方式是 `/actuator/health` 为 `UP` —— 这一条同时证明新 MySQL 口令(Hikari 建连 + Flyway 校验) 与新 Redis 口令都对,另外用新口令走 TCP 登录 MySQL、`redis-cli -a` 取到 `PONG`。 轮换流程(已实跑): 1. `mysql -e "alter user \"talent_card\"@\"localhost\" identified by \"<新>\""` 2. 改 `/etc/talent-card/env` 的 `MYSQL_PASSWORD` / `REDIS_PASSWORD` 3. Redis:改 `/etc/redis/redis.conf` 的 `requirepass` → `systemctl restart redis-server` 4. `systemctl restart talent-card` → 验 health **三个坑(都踩过):** - **纯随机生成不保证四类字符齐全。** 第一次生成 32 位随机的 MySQL 口令,结果**一个特殊符号都没有**; 生成时要**循环到四类都出现**再落库(重生成到第 5 次才齐)。 - **`redis-cli config set requirepass` 会报 `NOAUTH`。** 库上已经设过口令时,未认证不许改。 要么先 `redis-cli -a <旧> config set ...`,要么**直接改配置文件再重启**(更省事,本次就是靠这一步生效的)。 - **轮换后旧口令仍留在备份文件里**(已失效但还在磁盘上)。已收拢为一份当前状态的 `/root/talent-card-env.bak`,旧的 `.bak.2/.bak.3/.bak.4` 与 `env-before-rotate.bak` 都删了。 - 不必担心把自己锁死:MySQL 的 `root@localhost` 走 `auth_socket`,口令改坏了也能用 root 身份重设。 **另一个角度**:告警详情页能看到完整请求体(含口令),是因为 8081 是纯 HTTP。口令换强了, 但**传输仍是明文**(浏览器→服务器、以及服务商的安全设备都能看到)。根治要 HTTPS(域名+备案)。 在此之前可选:后台管理端不放在公网端口上暴露。这条只是提醒,已决定接受公网暴露。