From f7f4a8cc3714c7e377c98750b19dc9cee8ea0da5 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 22 九月 2026 13:40:00 +0800
Subject: [PATCH] docs(server): 攀枝花测试环境部署台账、重建脚本与弱口令整改交接
---
.scratch/server-testenv/talent-card.service | 36 +++++
.scratch/server-testenv/check-weak-passwords.py | 73 ++++++++++
.scratch/server-testenv/nginx-talent-card.conf | 44 ++++++
.scratch/server-testenv/redeploy-admin.sh | 49 +++++++
.scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md | 71 ++++++++++
.scratch/server-testenv/README.md | 136 +++++++++++++++++++
6 files changed, 409 insertions(+), 0 deletions(-)
diff --git a/.scratch/server-testenv/README.md b/.scratch/server-testenv/README.md
new file mode 100644
index 0000000..a2caa6d
--- /dev/null
+++ b/.scratch/server-testenv/README.md
@@ -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(域名+备案)。
+在此之前可选:后台管理端不放在公网端口上暴露。这条只是提醒,已决定接受公网暴露。
diff --git a/.scratch/server-testenv/check-weak-passwords.py b/.scratch/server-testenv/check-weak-passwords.py
new file mode 100644
index 0000000..0f38d79
--- /dev/null
+++ b/.scratch/server-testenv/check-weak-passwords.py
@@ -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())
diff --git a/.scratch/server-testenv/nginx-talent-card.conf b/.scratch/server-testenv/nginx-talent-card.conf
new file mode 100644
index 0000000..5ecc843
--- /dev/null
+++ b/.scratch/server-testenv/nginx-talent-card.conf
@@ -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;
+ }
+}
diff --git a/.scratch/server-testenv/redeploy-admin.sh b/.scratch/server-testenv/redeploy-admin.sh
new file mode 100644
index 0000000..644b63e
--- /dev/null
+++ b/.scratch/server-testenv/redeploy-admin.sh
@@ -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"
diff --git a/.scratch/server-testenv/talent-card.service b/.scratch/server-testenv/talent-card.service
new file mode 100644
index 0000000..85ee48d
--- /dev/null
+++ b/.scratch/server-testenv/talent-card.service
@@ -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
diff --git a/.scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md b/.scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md
new file mode 100644
index 0000000..d8d0dea
--- /dev/null
+++ b/.scratch/weak-credentials-hardening/issues/01-bootstrap-admin-and-password-policy.md
@@ -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 默认值仍在)。
--
Gitblit v1.7.1