| .gitignore | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| .scratch/server-testenv/README.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| .scratch/server-testenv/check-weak-passwords.py | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| .scratch/server-testenv/nginx-talent-card.conf | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| .scratch/server-testenv/redeploy-admin.sh | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| .scratch/server-testenv/talent-card.service | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| .scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| AGENTS.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| docs/huifu/08-上线与环境.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
.gitignore
@@ -12,3 +12,7 @@ # 探针的临时输出,以及为「分批应用迁移」而拷的迁移副本——副本入库会与真迁移混淆。 .scratch/**/out/ .scratch/**/dryrun-migrations/ /.zcode/ /.scratch/offline-env/ /.scratch/sdk-scan/ /.scratch/db-copy/ .scratch/server-testenv/README.md
New file @@ -0,0 +1,136 @@ # 攀枝花测试环境 —— 部署台账 目标机:攀枝花政务云(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) | | `/data/talent-card/admin/` | 后台管理端 dist(nginx 托管) | | `/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」。 | `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 传包的原因, > 现在用「服务器自建」绕过去了。 ## 已知边界 - **收不到汇付回调**:非公网环境的既定事实与既定设计(`docs/adr/0004-active-query-is-authoritative.md`、 `.scratch/pending-order-payment-flow/README.md` 的 F1)。收敛靠读接口惰性查询 + 60 秒一轮的对账快车道, 订单不会卡住。要收回调得等备案域名。 - **8081 对全公网开放且是纯 HTTP**(无 TLS),token 明文传输。已确认接受(测试环境)。 - 数据库:`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)仍可运行,但该语法未来版本会被移除。 ## ⚠️ 用空库部署后,第一件事:处置引导账号 `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,只打印命中项 ``` ## 凭据现状(2026-09-22 自查,因服务商「Web弱口令」告警) 告警内容:扫描器抓到一次成功登录 `{"account":"admin","password":"123456"}`。根因与仓库侧的修法见 `.scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md`(引导账号 + 无强度策略)。 **这台机器上的现状:** | 项 | 现状 | 结论 | |---|---|---| | 后台 `admin` 账号 | 已重置;用 `/root/check-weak-passwords.py` 拿 69 个常见弱口令撞 PBKDF2 哈希,**0 命中** | ✓ | | 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(域名+备案)。 在此之前可选:后台管理端不放在公网端口上暴露。这条只是提醒,已决定接受公网暴露。 .scratch/server-testenv/check-weak-passwords.py
New file @@ -0,0 +1,73 @@ #!/usr/bin/env python3 # 弱口令自查:拿一批常见弱口令去撞 talent_card 库里所有 password_hash。 # # 算法与 MerchantPasswordHasher 一致:PBKDF2WithHmacSHA256(possword, salt, iterations, 256bit) # 存储格式:pbkdf2$<iterations>$<base64 salt>$<base64 derived> # # 只打印命中的账号名,不打印哈希值。 # 用法:python3 /root/check-weak-passwords.py import base64 import hashlib import subprocess import sys CANDIDATES = [ "123456", "1234567", "12345678", "123456789", "1234567890", "123456789a", "a123456789", "a123456", "123456a", "abc123", "123abc", "abc123456", "Aa123456", "Aa123456.", "Aa123456789", "111111", "000000", "666666", "888888", "112233", "123123", "123321", "654321", "5201314", "woaini1314", "qwerty", "qwerty123", "1qaz2wsx", "1q2w3e4r", "zxcvbnm", "qwe123", "admin", "admin123", "admin888", "admin@123", "Admin@123", "admin123456", "administrator", "root", "root123", "toor", "password", "password123", "passw0rd", "P@ssw0rd", "P@ssword123", "letmein", "welcome", "iloveyou", "monkey", "dragon", "test", "test123", "test@123", "Test@123", "demo123", "talentcard", "talent_card", "talent-card", "card123", "rencaika", "rc123456", "pzh123456", "huifu123", "merchant123", "shop123", "88888888", "168168", "666888", ] TABLES = ("admin_operator", "merchant_operator") def derive(password, encoded): parts = encoded.split("$") if len(parts) != 4 or parts[0] != "pbkdf2": return None iterations = int(parts[1]) salt = base64.b64decode(parts[2]) expected = base64.b64decode(parts[3]) got = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations, dklen=len(expected)) return got == expected def rows_for(table): sql = "select id, account, password_hash from " + table out = subprocess.run(["mysql", "-N", "-B", "-D", "talent_card", "-e", sql], capture_output=True, text=True) if out.returncode != 0: print(" (跳过 " + table + ":" + out.stderr.strip().splitlines()[0] + ")") return [] return [line.split("\t") for line in out.stdout.strip().splitlines() if line] def main(): accounts = [] for table in TABLES: for row in rows_for(table): accounts.append((table, row[0], row[1], row[2])) print("待检账号 " + str(len(accounts)) + " 个;候选弱口令 " + str(len(CANDIDATES)) + " 个") hits = 0 for table, rid, account, encoded in accounts: matched = [c for c in CANDIDATES if derive(c, encoded) is True] if matched: hits += 1 print("[命中] " + table + " id=" + rid + " account=" + account + " -> " + str(matched)) if hits == 0: print("结论:没有任何账号命中这批常见弱口令。") else: print("结论:有 " + str(hits) + " 个账号仍是弱口令,必须改。") return 0 if hits == 0 else 1 if __name__ == "__main__": sys.exit(main()) .scratch/server-testenv/nginx-talent-card.conf
New file @@ -0,0 +1,44 @@ # 东区人才卡 —— 测试环境 nginx 站点(2026-09-22) # # 部署位置:/etc/nginx/sites-available/talent-card (再软链到 sites-enabled/) # # 为什么监听 8081:这台机器公网入向只开了 8081/8082,80/443/8080 都要备案号才能开。 # # 为什么前端和 API 走同一个端口:admin 的 .env.example 里 VITE_API_BASE_URL 默认留空 # (同源,注释写明「留空即可避免浏览器跨域」)。同源之后 CORS 那一堆白名单就不必配了。 server { listen 8081; server_name _; # 后端 spring.servlet.multipart.max-file-size 是 10MB,而 nginx 默认只放 1MB, # 不抬高的话图片上传会在 nginx 这层就 413,看后端日志什么都没有。 client_max_body_size 20m; # 进件/开通这类要打通道的接口慢,留出余量 proxy_read_timeout 120s; proxy_send_timeout 120s; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $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; } # 上传的图片由后端自己托管(UploadResourceConfig 把 /uploads/** 映射到 app.upload.directory), # 所以这里只转发,不要用 alias 指向磁盘 —— 两处指向同一个目录早晚会不一致。 location /uploads/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 后台管理端 SPA(dist 尚未部署,目录里先放占位页) location / { root /data/talent-card/admin; try_files $uri $uri/ /index.html; } } .scratch/server-testenv/redeploy-admin.sh
New file @@ -0,0 +1,49 @@ #!/bin/bash # 东区人才卡 —— 测试环境:从 Gitblit 拉最新源码,在服务器上重建后台管理端并部署 # # 用法:bash /data/talent-card/redeploy-admin.sh [分支] 默认 main # # 为什么在服务器上构建:这样前端部署不再需要人工走堡垒机网页 SFTP 传包 # (命令通道单次只能带约 4KB,而 dist 有 4.4MB)。服务器出网已开通: # * git://120.76.84.145/java/talent-card.git 匿名可读(已验证 ls-remote / clone) # * registry.npmmirror.com:443 可达(package-lock.json 的 resolved 就指向它) # # 高德 key 等构建期变量放在 /etc/talent-card/admin-build.env(600)——**不要**放进仓库: # talent-card-admin/.gitignore 里并没有忽略 `.env`,只有 `*.local`。 set -euo pipefail BRANCH="${1:-main}" SRC=/data/talent-card/src ADMIN=/data/talent-card/admin ENVFILE=/etc/talent-card/admin-build.env REPO=git://120.76.84.145/java/talent-card.git if [ ! -d "$SRC/.git" ]; then echo "== 首次克隆 $REPO ($BRANCH)" git clone --depth 1 --branch "$BRANCH" "$REPO" "$SRC" else echo "== 拉取最新 $BRANCH" git -C "$SRC" fetch --depth 1 origin "$BRANCH" git -C "$SRC" checkout -B "$BRANCH" "origin/$BRANCH" fi echo " 源码版本: $(git -C "$SRC" log --oneline -1)" if [ -f "$ENVFILE" ]; then cp "$ENVFILE" "$SRC/talent-card-admin/.env" echo "== 已注入构建期变量: $ENVFILE" else echo "== 注意: 没有 $ENVFILE,本次不带高德 key 构建(地图相关页面会报未配置)" fi cd "$SRC/talent-card-admin" echo "== npm ci" npm ci --no-audit --no-fund echo "== npm run build" npm run build mkdir -p "$ADMIN" find "$ADMIN" -mindepth 1 -delete cp -a "$SRC/talent-card-admin/dist/." "$ADMIN/" echo "== 已部署到 $ADMIN" ls -la "$ADMIN" .scratch/server-testenv/talent-card.service
New file @@ -0,0 +1,36 @@ # 东区人才卡 —— 测试环境后端 systemd 单元(2026-09-22) # # 部署位置:/etc/systemd/system/talent-card.service # 改环境变量请改 /etc/talent-card/env,不要改本单元 —— 那个文件是唯一的变量来源。 # # 几条有意为之的取舍: # * 用 EnvironmentFile 而不是在单元里写 Environment=,因为 systemd 里 # EnvironmentFile 会覆盖同名 Environment=,变量散在两处迟早踩坑。 # * 不带 APP_PAYMENT_HUIFU_ENABLED:它不在 env 文件里,于是走 application.yml 的默认 # true + environment=prod。迁移验证时那个 false 只存在于临时的 systemd-run 命令里。 # * SPRING_FLYWAY_ENABLED=true 常开:application.yml 里 flyway.enabled 硬编码 false, # 测试环境要跟着迁移走,每次重启自动补新迁移。 # * SERVER_ADDRESS=127.0.0.1:后端只绑本机,对外一律经 nginx(8081)。 [Unit] Description=Talent Card backend (test environment) After=network-online.target mysql.service redis-server.service Wants=network-online.target # 应用启动时 Hikari 会立即取连接,DB 没起来就活不过初始化,所以显式依赖 Requires=mysql.service [Service] Type=simple User=root WorkingDirectory=/data/talent-card EnvironmentFile=/etc/talent-card/env ExecStart=/usr/bin/java -Xms256m -Xmx2048m -jar /data/talent-card/app.jar # JVM 收到 SIGTERM 的正常退出码是 143,别让一次正常停止被算成失败 SuccessExitStatus=143 Restart=on-failure RestartSec=10 StandardOutput=append:/data/talent-card/app.log StandardError=append:/data/talent-card/app.log [Install] WantedBy=multi-user.target .scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md
New file @@ -0,0 +1,71 @@ # 弱口令自查:引导账号、口令强度策略、配置里的弱口令默认值 - **来源**:政务云服务商安全告警「Web弱口令」(2026-09-22) - **现象**:扫描器抓到一次**成功**的后台登录——请求体 `{"account":"admin","password":"123456"}`, 响应 200 且带 `accessToken` - **归属**:后台账号体系 / 配置默认值,**不属于支付链路**。按 `AGENTS.md` 的约定整理成交接文档, 请转给负责「其余业务」的那位;本文件不含可执行改动。 ## 根因(三条叠加) ### 1. 引导账号写死在迁移里,且**每个全新库都会重新种一遍** `talent-card-server/src/main/resources/db/migration/V76__admin_operator_authentication.sql:44-47` ```sql -- Local bootstrap account: admin / 123456. Change it before shared deployment. insert into admin_operator(account, password_hash, display_name, role_code, status) select 'admin', 'pbkdf2$210000$AQIDBAUGBwgJCgsMDQ4PEA==$tFP9EZj7...', '平台管理员', 'platform_admin', 1 where not exists (select 1 from admin_operator where account = 'admin' and deleted = 0); ``` 注释自己就写着「上线前要改」,但 `where not exists` 的写法意味着**任何空库部署都会得到同一个已知口令**。 本次告警就是它。**已执行的迁移按项目规约只追加不修改,所以修法必须绕开 V76。** ### 2. 代码里没有口令强度校验,所以弱口令能被反复设回去 | 位置 | 现状 | |---|---| | `AdminManagementService.resetStaffPassword(...)` | 只校验 `password.length() < 6 \|\| > 64` —— **`123456` 完全合法** | | `AdminManagementService.createStaff(...)` | 只校验非空 | | `MerchantPasswordHasher` | 只负责哈希(PBKDF2WithHmacSHA256 / 210000 轮 / 256 位 / 16 字节随机盐),**不管强度** | | 商户端 `merchant_operator` | 用的是**同一个**哈希器与同一套校验 → 同样的问题 | **关键含义**:即使这次把 `admin` 的口令换强,**后台「重置密码」入口仍能把任何账号改回 `123456`**。 只改数据不改策略,等于没修根因。 ### 3. 配置里的弱口令默认值(环境变量没注入时就会静默生效) | 文件 | 内容 | |---|---| | `application.yml:17` | `password: ${MYSQL_PASSWORD:123456}` | | `application.yml:30` | `password: ${REDIS_PASSWORD:123456}` | | `.env.example:4` | `MYSQL_PASSWORD=123456` | | `docker-compose.yml` | `MYSQL_ROOT_PASSWORD: root`、`MYSQL_PASSWORD: talent_card`、redis 无密码(docker 方案已放弃,但文件还在,一 grep 就是弱口令) | | `scripts/apply-sql-file.ps1:25`、`scripts/reset-test-db.ps1:88`、`scripts/run-server-tests.ps1:45`、`scripts/probes/*.ps1` | `-Password '123456'` 默认值 | (后两类连的是办公室开发库/测试库,不在公网;但「自查整改」时会被逐条 grep 出来。) ## 建议改法 1. **引导账号**:两条路都不需要动 V76—— - **(a) 初始口令从环境变量注入**:启动时若库中不存在可用的平台管理员,就用 `ADMIN_BOOTSTRAP_PASSWORD` 创建一个;**该变量缺失时不创建任何账号**(宁可不建,也不要造出已知口令)。需要一个小的 `ApplicationRunner`。 - **(b) 首登强制改密**:新增迁移给 `admin_operator` 加 `must_change_password` 列,引导账号置 1; 登录后除改密接口外一律拒绝。 - 倾向 (a) + (b) 一起做:(a) 堵住「新库必有已知口令」,(b) 兜住「口令是别人代设的」。 2. **口令强度策略**:抽一个 `PasswordPolicy`(建议 ≥12 位、且含大小写/数字/符号中至少 3 类、 不在常见弱口令表内),在 `createStaff` / `resetStaffPassword` 与商户端对应入口统一调用。 **存量用户不要动**——老口令仍允许登录,只约束新设置的口令;否则会把现有的人锁死在门外。 3. **配置默认值**:把这几处默认值改成空值或显式占位(如 `change-me`),让「忘了注入」变成**启动即失败**, 而不是静默退化成弱口令。 4. **确认登录失败锁定真的在生效**:`admin_operator` 已经有 `login_failed_count` / `locked_until` 字段。 这次扫描器一次就登进去了——既说明口令弱,也说明当时没有锁定在拦。 ## 附:不属于本移交、但同属凭据类的两条 - `application.yml` 里汇付的 `system-id` / `merchant-id` / `private-key` / `platform-public-key` 与 `app.wechat.mini-program.app-secret` 是**字面量明文**且已进 git 历史 —— 已记在 `.scratch/huifu-handover/issues/01-plaintext-credentials-in-application-yml.md`,属支付链路。 - `app.privacy.encryption-key` 的默认值是仓库里公开的开发 key(攀枝花测试环境已用 `APP_PRIVACY_ENCRYPTION_KEY` 覆盖,但 yml 默认值仍在)。 AGENTS.md
@@ -40,6 +40,49 @@ 测试库是哪两个库、什么版本**、3 项已知基线失败,以及一类会随测试库累积而出现的环境相关失败;不要为 证明它们是"既有的"而重复造 worktree)。 ### 攀枝花政务云服务器:操作纪律(务必遵守) 这台机器**没有公网自服务控制台**——console/VNC 只在「云管理方」手里,进去只有一条路:堡垒机 (`172.18.105.9`)。**一旦把这条路弄断,你和用户谁都进不去,只能求服务商。** 2026-09-21 已经发生过 一次(docker 网桥占用 `172.18.0.0/16`,与堡垒机网段冲突,回程路由被劫走)。以下每条都是那次事故立的: - **只在用户明确授权后动手。** 用户说"先别干活"、或语义模糊(例如只说"我们继续吧")时,**只给方案和 命令,不执行**。事故当天就是凭一句模糊的"继续吧"动了手。 - **改网络/路由/防火墙/端口/SSH 之前,先确认带外通道可用。** 没有 console/VNC 兜底,这类改动一律不做。 - **改之前先枚举已知网段**,不要只看"自己那一段": | 网段 | 是谁 | |---|---| | `172.192.19.0/24` | 服务器自己,网关 `172.192.19.254` | | **`172.18.0.0/16`** | **堡垒机所在网段**(堡垒机连服务器的来源是 `172.18.140.254`) | | `192.169.1.10` | 办公侧(VPN 客户端来源) | | `192.168.110.0/24` | 办公室开发库 `192.168.110.13` | - **不要在这台机器上引入 docker(2026-09-21 已卸载,别再装回去)。** 两个原因:一是它的网桥默认 地址池会依次分到 `172.17`、`172.18`、`172.19`…,正好撞上堡垒机网段 `172.18.0.0/16`,回程路由被劫走 就直接失联;二是这台机器的虚拟 CPU 缺 SSE4.2/POPCNT,以 x86-64-v2 为基线的镜像根本跑不起来(见下)。 **MySQL/Redis 一律原生装。** 万一将来确实要重新引入,**必须在建任何网络之前先钉死地址池**: `"default-address-pools": [ { "base": "10.201.0.0/16", "size": 24 } ]`(同时把 `data-root` 指向 `/data`——40G 系统盘装不下镜像)。 - **一次只改一件事,每步之后先复检堡垒机通道还在**,再走下一步。 - **高危改动挂自动回滚**:`systemd-run --on-active=5min /bin/bash -c '<撤销命令>'`,确认通道正常后再 取消它。最坏情况 5 分钟后自己恢复,不必求人。 - **给服务商的命令要一次说准、可直接复制、并写出期望输出。** 消息要经**两跳**(对接人 → 云管理方), 每次返工就是几十分钟。事故当天给的 `docker compose down` 缺了 `-f`(本仓的 compose 叫 `docker-compose.prod.yml`,不是 compose 的默认文件名),白费一跳。 - **不要把凭据写进聊天。** 必须提供给执行人时,事后立刻轮换。 **这台机器的硬约束(改环境前先看):** - **虚拟 CPU 缺 SSE4.2/POPCNT/AVX**(KVM 上是老的 QEMU 型号,物理 CPU 其实够用)→ **任何以 x86-64-v2 为基线的镜像都跑不起来**:官方 `mysql:8.4` 会 `Fatal glibc error: CPU does not support x86-64-v2` 循环退出(exit 127)。Redis 官方镜像正常,Ubuntu 自己的包都正常。要跑这类镜像,得请服务商 把虚拟机的 CPU 型号改成 `host-passthrough`。 - **出网**已开通,是**任意公网地址**(不是域名白名单)。但 **Docker Hub 直连不通**(`registry-1.docker.io` 连接被重置),拉镜像走 `public.ecr.aws/docker/library/<name>`。 - **入向**只有 `8081`/`8082` 开放(公网地址 `117.172.73.106`);`80`/`443`/`8080` 要备案号才能开。 - **办公网到 `172.192.19.0/24` 没有路由**,所以只能走堡垒机,不能直连。 ### Git 提交 提交信息一律用中文,沿用仓库既有风格:`type(scope): 中文摘要`,type 保留英文 docs/huifu/08-上线与环境.md
@@ -205,6 +205,12 @@ - **编号全仓共享**。新增迁移前**先看** `talent-card-server/src/main/resources/db/migration/` 当前的**最大版本号** (截至本提交是 `V154`)。本仓库由多人按业务域分工,两人从同一祖先各自独立占同一段编号时 **git 不报冲突, Flyway 会直接报「同一版本两个迁移」**。 - **最大版本号 ≠ 迁移条数**。当前是 **148 个**:147 个 `.sql` + **1 个 Java 版迁移** (`src/main/java/db/migration/V125__parking_lot_registration_snapshot.java`,包名 `db.migration`)。 `1..154` 里真正的空档只有 **6 个**:97 / 105 / 109 / 110 / 111 / 126。 **数迁移时不要只数 `resources/db/migration/*.sql`**——那样会漏掉 Java 版迁移,把 `V125` 误判成空档 (这个错 2026-09-22 真的发生过)。要核条数就看 Flyway 启动日志里的 `Successfully validated N migrations`。经过见 §8.2。 - **让位规则**:合并时**本地让号**,因为远端那批可能已在别人的环境里跑过。刚发生过一次四连让位: `V148–V151` → `V151–V154`(`V148__merchant_capability_sync_until`→`V151`、 `V149__payment_attempt_convergence`→`V152`、`V150__merchant_numeric_code_attempt`→`V153`、 @@ -249,3 +255,128 @@ 一句话:**测试环境、跑测试的命令、重置库、已知基线失败、WSL 的坑一律以 `docs/agents/testing.md` 为准** (那里是唯一维护点,要更新就改它);本文件只讲**部署与上线**(凭据、回调地址、开关、验收清单、迁移编号规约), 测试部分只做索引,**不重复维护**。 ## 8. 服务器环境实测记录(攀枝花政务云,2026-09-22) > **这不是生产部署。** 这台机器目前承担的是**开发测试环境**:没有域名、没有备案、没有 TLS, > 公网入向只开了 `8081`/`8082`,外部访问仍要过堡垒机,进程还是 `systemd-run` 临时单元(重启不自动拉起)。 > 本节只记录「环境能起来、迁移能在 8.4 上跑通」这一层事实,**不代表上线验收**(验收见 §4)。 第一次在**空库**上把迁移集完整跑一遍,验证 MySQL 8.4 兼容性。目标机 Ubuntu 26.04.1、 MySQL `8.4.11-0ubuntu0.26.04.1`(原生 apt,非容器;datadir 迁至 `/data/mysql`,AppArmor 走 `/etc/apparmor.d/local/usr.sbin.mysqld` 覆盖而非改主 profile)、Redis `8.0.5`、JDK `17.0.20`。 ### 8.1 结论:迁移集在 8.4 上通过 - Flyway 报 `Successfully applied 148 migrations to schema talent_card, now at version v154`(60.9 秒)。 - `flyway_schema_history` **148 行**、`success` 全为 1、最高版本 `154`;建出 **97 张表**。 - 紧接着 `jpa.hibernate.ddl-auto: validate` **通过**,应用正常启动 —— 实体与迁移建出的结构自洽。 - 再次启动时 `Successfully validated 148 migrations` + `Schema talent_card is up to date. No migration necessary.`(checksum 一致,可重复校验)。 ### 8.2 「154」不是迁移条数 - `154` 是**最大版本号**;`1..154` 里真正的空档是 **6 个**:97 / 105 / 109 / 110 / 111 / 126。 - `.sql` 文件 **147** 个,另有 **1 个 Java 版迁移**:`V125__parking_lot_registration_snapshot` (`src/main/java/db/migration/`,它在 `flyway_schema_history` 里的 `type` 是 `JDBC` 而不是 `SQL`)。 - Flyway 实际执行 **148** 个 = 147 + 1 = 154 − 6。 - **教训**:只数 `resources/db/migration/*.sql` 会把 Java 版迁移漏掉,从而把 `V125` 误判成空档 —— 这个错 2026-09-22 真的发生过一次,并一度被当成「合并时丢了迁移」。要核条数就看 Flyway 的 `Successfully validated N migrations`。 ### 8.3 日志里三类「看着吓人但不是失败」的输出 | 输出 | 含义 | |---|---| | `PROCEDURE ... does not exist`(1305),15 条 | 15 个迁移用「`DROP PROCEDURE IF EXISTS` → `CREATE` → `CALL` → `DROP`」做幂等 ALTER。**全新库上第一次 `DROP IF EXISTS` 必然打这条**(MySQL 返回 warning 而非 error,Flyway 把任何返回消息都记成 WARN)。只在全新库出现,预期内。 | | `Apparent connection leak detected`(HikariCP) | yml 里 `leak-detection-threshold: 10000`,而启动期主线程会长占连接(Flyway 长迁移、支行目录导入)。日志随后就跟着 `... was returned to the pool (unleaked)`,**误报**。 | | `VALUES function is deprecated`(1287),7 条 | **这条要认真对待**:`INSERT ... ON DUPLICATE KEY UPDATE ... VALUES(col)` 自 MySQL 8.0.20 起弃用,**未来版本会移除**。8.4 上仍能运行。 | ### 8.4 部署侧四件必须做的事 **1. `MYSQL_URL` 必须带 `rewriteBatchedStatements=true`**(Connector/J 默认 `false`)。不加这个参数, 代码里的 `batchUpdate` 在驱动层会退化成逐条发送。实测对比(同一张 `bank_branch_directory`,约 15.4 万行): | | 语句执行次数 | 耗时 | 单行耗时 | |---|---|---|---| | 不带该参数 | 52,915 | 986.4 秒 | 18.6 ms | | 带该参数 | 155 | **28.5 秒** | 0.18 ms | 约 **100 倍**差距(`performance_schema.events_statements_summary_by_digest` 实测)。副作用:批处理被改写后 `executeBatch()` 返回 `SUCCESS_NO_INFO`,`BankBranchDirectoryInitializer` 会打出 `Imported 0 bank branch directory rows` —— **数据是全的,只是那个计数器失真了**,不要据此判断导入失败。 **2. `REDIS_PORT` / `REDIS_PASSWORD` 必须注入**,不能因为「业务代码零调用 Redis」就跳过。 Actuator 注册了 Redis 健康指示器:应用默认连 `localhost:6368` + 密码,而 Ubuntu 装的 Redis 在 `6379` 且默认无密码 → `/actuator/health` **永远 503**(`management.endpoint.health.probes.enabled: true` 时 liveness/readiness 一并受影响)。实测把这五项都注入后 health 恢复 `{"status":"UP"}`: `MYSQL_URL` / `MYSQL_USERNAME` / `MYSQL_PASSWORD` / `REDIS_PORT` / `REDIS_PASSWORD`。 **3. `CORS_ALLOWED_ORIGINS` 必须包含**部署后浏览器实际访问的那个 origin**,哪怕是同源。** 这条曾经被想当然地漏掉(理由是「前端和 API 走同一个端口 = 同源 = 不需要配 CORS」), 结果**登录直接 403 `Invalid CORS request`**。机制是:**浏览器对同源的非简单请求也会带 `Origin` 头** (POST + `Content-Type: application/json` 就是),而 `ApiCorsConfig` 给 `/api/**` 注册的 `CorsFilter` 只要看到 `Origin` 就按白名单校验,不在名单里就 403。**「同源」并不等于「不会有 Origin 头」。** 实测判据(改前 / 改后): | 请求 | 改前 | 改后 | |---|---|---| | `POST /api/admin/auth/login` + `Origin: <部署域名>` | 403 `Invalid CORS request` | 400 `account must not be blank`(已进业务代码) | | `OPTIONS` 预检 + 同 Origin | — | 200,带 `Access-Control-Allow-Origin` | | `POST` + `Origin: http://evil.example.com` | 403 | **仍 403**(白名单照样生效) | 注意 yml 里还有 `allowed-origin-patterns`(默认 `http://localhost:[*]`、`http://127.0.0.1:[*]`), 它覆盖本机随机端口的调试来源、**与精确白名单是两个键**,覆盖 `CORS_ALLOWED_ORIGINS` 不会动它—— 所以本地 vite / HBuilderX 调试不受影响。**上线时把真实域名加进 `CORS_ALLOWED_ORIGINS` 即可。** **4. 凭据要逐条过一遍「弱口令」口径。** 政务云的安全扫描不是形式主义——2026-09-22 它**真的登进来了一次** (抓到请求体 `{"account":"admin","password":"123456"}`,响应 200 带 `accessToken`)。部署时以下三件事一起做: | 凭据 | 要求 | |---|---| | `APP_PRIVACY_ENCRYPTION_KEY` | 必须在**写入任何隐私数据之前**覆盖仓库里的开发默认值(见 §8.6)。 | | MySQL / Redis 口令 | 服务商口径是「大小写 + 数字 + 特殊符号」组合。**注意纯随机生成不保证四类齐全**:32 位随机实测有约 1% 概率一个特殊符号都没有(本次第一次就漏了,重生成到第 5 次才齐),所以生成时要**循环到四类都出现**再落库。特殊符号只用 `@%^*+_.-`,避开会破坏 systemd `EnvironmentFile` 解析与 shell 展开的 `$ " ' \ !` 和空格。 | | 后台引导账号 | `V76` 种下的 `admin` 口令**经实测就是 `123456`**(用 node 按迁移里的参数算 PBKDF2,结果逐字符相同),而 `where not exists` 意味着**每个新库都会重种一遍** —— **空库部署后第一件事就是把它换掉**。根因与修法见 `.scratch/weak-credentials-hardening/issues/01-*`。 | 改 Redis 口令有个坑:库上**已经设过** `requirepass` 时,`redis-cli config set requirepass <新>` 会报 `NOAUTH Authentication required`(未认证不许改)。要么先 `redis-cli -a <旧> config set ...`, 要么**直接改 `/etc/redis/redis.conf` 的 `requirepass` 再 `systemctl restart redis-server`**(更省事)。 轮换后**旧口令仍会留在备份文件里**(已失效但还在磁盘上),记得同步刷新,只留一份当前状态的备份。 不必担心把自己锁死:MySQL 的 `root@localhost` 走 `auth_socket`,口令改坏了也能用 root 身份重设。 ### 8.5 这台机器的性能约束(影响所有部署) 虚拟 CPU **缺 SSE4.2 / POPCNT / AVX**。仓库 `AGENTS.md` 只把它记在「以 x86-64-v2 为基线的镜像跑不起来」上, 但它**同样拖慢原生 MySQL**:InnoDB 页校验退化为软件实现,写入路径明显变慢。根治要请服务商把虚拟机 CPU 型号改成 `host-passthrough`。 另外 `innodb_buffer_pool_size` 是 MySQL 默认的 **128MB**,在 15G 内存的机器上当生产库太小,上线前应调大。 ### 8.6 上线前仍未完成的安全项 - **`APP_PRIVACY_ENCRYPTION_KEY` 必须在写入任何隐私数据之前覆盖**:默认值是开发用的 key, 事后再换会让已加密的数据解不开。 - `app.payment.huifu` 的四个键与 `app.wechat.mini-program.app-secret` 在 `application.yml` 里是字面量明文, 已随 fat jar 落到服务器。轮换需要汇付侧配合,见 `.scratch/huifu-handover/issues/01-*.md`。 ### 8.7 本机收不到回调 —— 这是既定设计,不是部署缺陷 **不要把它当成这台机器的问题。** 非公网环境收不到回调是**已经记录在案的既定事实**,而且整套设计本来就不 依赖回调:见 [ADR 0004](../adr/0004-active-query-is-authoritative.md)(「通道状态以主动查询为权威,回调只作 加速器」),以及 `.scratch/pending-order-payment-flow/README.md` 的 F 系列清单 —— F1 正是「回调地址指向 公网 IP,开发环境不可达」。那里还定过一条边界:**不允许为了开发便利开「跳过真实通道」的置付捷径**, 所以开发/测试环境的收敛只能走真实查询,不能靠伪造资金事实。 不依赖回调的收敛路径(**订单不会因为没回调而卡住**): - **读接口惰性查询**:进入待支付页 / 刷新支付状态时,`PendingOrderPortalController` 会当场经 `PaymentReconciliationService` 问一次通道;顺带把「关单处理中」也问出结论(`concludeUndecidedCloses`)。 - **服务端对账快车道**:`reconciliation.fixed-delay-ms` 默认 **60 秒**一轮。 - **慢车道退避**:每行另有 `pending-after-minutes`(默认 5)与 `last_reconciled_at`,只对长时间没被问过的行 生效 —— **它是最坏情况的下限,不是通常要等的时间**。 - **人工即时入口**:后台订单列表的「立即对账」,跑的是同一套真实对账逻辑。 唯一要守住的是 **`app.payment.huifu.reconciliation.enabled` 保持默认 `true`**:它是支付与退款唯一的后台兜底, 关掉就只剩「用户点了取消 / 重申支付」这一个入口(见 §1.2)。