alphaH 2 周之前
父节点
当前提交
0e501260f1
共有 4 个文件被更改,包括 211 次插入 和 23 次删除
  1. 190 9
      .claude/plan/ops-manual.md
  2. 3 1
      deploy.sh
  3. 2 1
      docker-compose.yml
  4. 16 12
      java/src/main/resources/application.yml

+ 190 - 9
.claude/plan/ops-manual.md

@@ -2,20 +2,36 @@
 
 ## 服务器信息
 
-- IP: `172.20.205.37`
-- 域名: `qcsj88888.com`、`api.qcsj88888.com`
+- 公网 IP: `47.94.88.244`(`qcsj88888.com` / `api.qcsj88888.com` / `doc.qcsj88888.com` 三条 A 记录均指向此)
+- hostname: `iZ2zed18i1o6sivyqop2plZ`
+- 内网 IP: `172.20.205.37`(原手册所记,本次未复核)
+- 域名: `qcsj88888.com`、`api.qcsj88888.com`、`doc.qcsj88888.com`
 - 系统: Ubuntu (Alibaba Cloud ECS)
 
+> 这台机器在别处没有任何记录。排查时容易和 antom(`8.138.203.55`)、国内 credit-pay
+> (`47.107.96.60`)、美西(`132.148.75.28`)三台混淆——**它是独立的第四台。**
+
 ## 容器拓扑
 
-| 容器 | 镜像 | 端口 | 网络 |
+| 容器 | 镜像(2026-09-24 实测) | 端口映射 | 网络(未复核) |
 |---|---|---|---|
-| java-backend | xjz/java-backend:1.0.0 | 8001 | app-tier, deploy_app-tier |
-| frontend | xjz/frontend:2.5.4 | 5173→80 | app-tier |
-| postgres | postgres:18.3 | 127.0.0.1:5432 | 宿主机, deploy_app-tier |
-| redis | redis:8.6.2-alpine | 6379 | deploy_app-tier |
-| nginx-proxy-manager | jc21/nginx-proxy-manager | 80/443/81 | deploy_app-tier |
-| gogs | gogs/gogs | 3000/2222 | deploy_app-tier |
+| java-backend | `pay:latest` ⚠️ | 0.0.0.0:8001→8001 | app-tier, deploy_app-tier |
+| frontend | `xjz/frontend:2.5.4` | 0.0.0.0:5173→80 | app-tier |
+| payment-doc | `xjz/payment-doc-server:1.0.0` | 0.0.0.0:7878→7878 | — |
+| postgres | `postgres:18.3` | 0.0.0.0:5432→5432 ⚠️ | 宿主机, deploy_app-tier |
+| redis | `redis:8.6.2-alpine` | 0.0.0.0:6379→6379 ⚠️ | deploy_app-tier |
+| nginx-proxy-manager | `jc21/nginx-proxy-manager:latest` | 0.0.0.0:80-81, 443 | deploy_app-tier |
+| gogs | `gogs/gogs:latest` | 0.0.0.0:3000, 2222 | deploy_app-tier |
+
+> ⚠️ **两处与旧版手册不符,上服务器前先核实:**
+>
+> 1. **java-backend 实际跑的是 `pay:latest`**,不是手册和仓库 compose 里写的
+>    `xjz/java-backend:1.0.0`。照「升级部署」一节构建 `xjz/java-backend:1.0.0` 再
+>    `docker compose up -d java-backend`,**很可能不会替换掉正在运行的容器**。服务器上的
+>    compose 与仓库里的 `docker-compose.yml` 已经不同步,部署前先比对。
+> 2. **postgres / redis 绑的是 `0.0.0.0`,不是 `127.0.0.1`。** 目前靠阿里云安全组挡着
+>    (2026-09-24 从公网实测 5432 / 6379 均不可达),但容器层面是无保护的,
+>    安全组一旦放宽就直接暴露。建议改成 `127.0.0.1:5432:5432` 这类回环绑定。
 
 ```
 qcsj88888.com (HTTPS)
@@ -128,6 +144,171 @@ SPRING_DATA_REDIS_DATABASE: 0
 SPRING_DATA_REDIS_PASSWORD: '123456#xjz'
 ```
 
+## 端口暴露(2026-09-24 公网实测)
+
+从公网直连 `47.94.88.244` 的探测结果:
+
+| 端口 | 服务 | 公网 | 说明 |
+|---|---|---|---|
+| 80 / 443 | NPM | ✅ 开放 | 对外服务,正常 |
+| 81 | NPM 管理面板 | ❌ 不可达 | 安全组已挡,保持 |
+| 3000 | gogs | ⚠️ **开放** | 见下 |
+| 2222 | gogs SSH | ❌ 不可达 | |
+| 5432 | postgres | ❌ 不可达 | 靠安全组挡,容器绑的是 `0.0.0.0` |
+| 6379 | redis | ❌ 不可达 | 同上 |
+| 8001 | java-backend | ❌ 不可达 | 经 NPM 转发 |
+| 7878 | payment-doc | ❌ 不可达 | 经 NPM 转发 |
+
+**gogs 的 3000 是本机除 80/443 外唯一对公网开放的服务。** 若是有意为之(外部拉取代码)
+则无需处理;若是无意的,建议安全组关掉,改由 NPM 反代到域名下。
+
+---
+
+## SSL 证书
+
+> 本节 2026-09-24 新增。当天三个域名 HTTPS 中断三天,根因与处置见下。
+
+### 机制
+
+TLS 由 **nginx-proxy-manager**(NPM,容器名 `nginx-proxy-manager`)终结。宿主机上
+只有它占 80/443。
+
+| 域名 | 容器内代理配置 | 证书目录 | 证书名 |
+|---|---|---|---|
+| `qcsj88888.com` | `/data/nginx/proxy_host/2.conf` | `/etc/letsencrypt/live/npm-2/` | npm-2 |
+| `api.qcsj88888.com` | `/data/nginx/proxy_host/3.conf` | `/etc/letsencrypt/live/npm-3/` | npm-3 |
+| `doc.qcsj88888.com` | `/data/nginx/proxy_host/4.conf` | `/etc/letsencrypt/live/npm-4/` | npm-4 |
+
+三个域名**各自一张独立证书**(NPM 一个 Proxy Host 一张,SAN 不合并)。
+`live/` 下是符号链接,指向 `archive/npm-<id>/` 里的实际文件。
+
+宿主机数据目录:`/root/payments/deploy/nginx-proxy-manager/data/`
+(证书**不在**这里面,在容器内 `/etc/letsencrypt`)。
+
+### 校验方式:DNS-01(NPM 原生功能,非定制改造)
+
+证书走 **DNS challenge + dns-01**,认证器是 certbot 的阿里云插件:
+
+```
+Requested authenticator dns-aliyun
+Var pref_challs=['dns-01']
+Var dns_aliyun_credentials=/etc/letsencrypt/credentials/credentials-*
+```
+
+**这是 NPM 自带的功能,不要误判成定制改造。** 可选 provider 列在
+`/app/certbot/dns-plugins.json`(上游路径 `backend/certbot/dns-plugins.json`),
+provider 的**显示名从该文件读取**,所以日志里会出现 `via Aliyun` 这种拼接文案——
+这是正常的,不是补丁痕迹。
+
+镜像来源确认(2026-09-24):
+
+```
+Created:     2026-02-17T05:43:40Z
+RepoDigests: jc21/nginx-proxy-manager@sha256:2aa69b382a384b676c0d4f1d6f2eac40ecd478fcf7af1cfb3f9f1d3cd0c81e12
+```
+
+**所以这台能直连 Let's Encrypt。** 不要把 antom 那台"必须用 ZeroSSL(国际出口不通)"
+的结论套到这台上——两台出网情况不同。
+
+### 续期
+
+NPM 每小时 `:27:42` 跑一次检查,把**所有**已进入「到期前 30 天」窗口的证书**串行**续掉。
+三张分散在几个整点完成,是因为各自 `expires_on` 差了几小时、跨过窗口的时刻不同,
+不是「每次只续一张」。
+
+> ⚠️ **续期成功后 NPM 不会 reload nginx**,根因见下。
+
+```bash
+docker logs nginx-proxy-manager 2>&1 | grep -i 'SSL'
+```
+
+### 验证:看实际发出的证书,不要只看文件
+
+```bash
+# 服务器本机(127.0.0.1 + SNI,绕开 DNS 缓存)
+echo | openssl s_client -connect 127.0.0.1:443 -servername qcsj88888.com 2>/dev/null | openssl x509 -noout -dates
+```
+
+```bash
+# 外网视角(在任一有国际出口的机器上跑)
+curl -sS -o /dev/null -w 'verify=%{ssl_verify_result}\n' https://qcsj88888.com/
+```
+
+`verify=0` 表示客户端校验通过。
+
+**为什么必须这样验:** 2026-09-24 那次,磁盘上证书完全正常(`certbot certificates`
+报 `VALID: 59 days`),但 nginx 一直发 6/25 签发的旧证书。**只扫证书文件的到期检查
+会报"一切正常",而生产已经挂了。**
+
+### 装载故障的处置
+
+症状:磁盘证书是新的,公网发出的却是旧的。此时**不要重签**(浪费 CA 配额且治不好)。
+
+```bash
+docker exec nginx-proxy-manager nginx -t
+```
+
+必须先输出 `syntax is ok` + `test is successful` 才能继续。证书与私钥不匹配时
+`nginx -t` 会拦下——**绝不让 nginx 加载坏配置**(9-13 antom 那次就是栽在
+"新证书配旧私钥"上,reload 后 nginx 起不来、域名全挂)。
+
+```bash
+docker exec nginx-proxy-manager nginx -s reload
+```
+
+### 根因(2026-09-24 查清):续期路径不 reload nginx
+
+**NPM 只在「签发新证书」时 reload nginx,定时续期这条路线上根本没有这一步。**
+
+全 `/app` 范围内 `Nginx.reload()` 的调用点(`grep -rn "Nginx.reload()" /app/`):
+
+```
+proxy-host.js:296,397          redirection-host.js:294,395
+access-list.js:201,305         stream.js:243,344
+dead-host.js:242,324           ip_ranges.js:113
+setting.js:51,63               certificate.js:155,158,164,171,177,184
+nginx.js:94(定义处)           migrations/2021...regenerate_default_host.js:22
+```
+
+全是 CRUD 流程。`certificate.js` 那 6 处在 `insertAndFetch(data)` 之后,属于
+**POST /certificates(签发)**,服务于「临时禁用 host → 签发 → 恢复 host」。
+
+续期链路一次都没有:
+
+```
+processExpiringHosts  (:49)   定时任务,查 expires_on 进入 30 天窗口的证书
+  → renew             (:893)  串行 forEach 调用
+     → renewLetsEncryptSsl              (:929)  http-01
+     → renewLetsEncryptSslWithDnsChallenge (:970)  dns-01
+        → utils.execFile(certbotCommand, args, adds.opts)
+        → logger.info(result) → return result
+```
+
+两个 renew 函数体里只有 `execFile` 和 `logger.info`,不 import nginx 模块,也没有 reload。
+
+**代码与现象互相印证:** 8/25 续期日志显示 certbot 成功、数据库写入,全程无 reload 记录、
+无报错 —— 不是「reload 步骤丢了」,是这条路径上本来就没有。
+
+**为什么只有这台暴露:** reload 只在**改配置**时发生(动 NPM 界面会顺带把新证书带进来)。
+这台三个月没人碰过 NPM 配置,`database.sqlite` 上一次写入就是 8/25 的续期本身,
+所以 nginx 从 6/25 签发后再没重载过。
+
+> 未核实上游是否已在更新版本中修掉。可确认的事实只有:**这个 digest
+> (`sha256:2aa69b38…`)的镜像,续期路径不 reload。**
+
+### 复发时间线与处置建议
+
+NPM 在到期前 30 天续期,即约 **10/23** 再次静默续期。届时若不干预,nginx 仍发 8/24 那张,
+**11/22 三个域名再次过期**。
+
+建议处置(**尚未实施**):宿主机加定时自愈 —— 比对「实际发出的证书」与「磁盘当前证书」,
+不一致就 `nginx -t && nginx -s reload`,并推钉钉告警。
+
+**不要改镜像。** `/app/internal/certificate.js` 是控制面代码,改错会让
+`nginx-proxy-manager` 起不来,连带 80/443 全挂 —— 而 80/443 正是用来签发和续期的入口。
+
+---
+
 ## 故障排查
 
 ### 后端起不来

+ 3 - 1
deploy.sh

@@ -12,8 +12,10 @@ ENDOFDOCKERFILE
 docker build --no-cache -t pay:latest .
 docker stop java-backend 2>/dev/null || true
 docker rm java-backend 2>/dev/null || true
+# profile 默认 prod。联调/测试机用 `SPRING_PROFILES_ACTIVE=test ./deploy.sh` 覆盖 ——
+# 乐收赢的启动闸只放行 dev/test/local,带着 application.yml 里的文档测试配置起 prod 会被它拒绝。
 docker run -d --name java-backend --network deploy_app-tier -p 8001:8001 \
-  -e SPRING_PROFILES_ACTIVE=prod -e JAVA_OPTS="-Xms256m -Xmx512m" \
+  -e SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod} -e JAVA_OPTS="-Xms256m -Xmx512m" \
   -e SPRING_DATASOURCE_DRUID_URL=jdbc:postgresql://postgres:5432/payment_platform_java?currentSchema=public \
   -e SPRING_DATASOURCE_DRUID_USERNAME=admin -e SPRING_DATASOURCE_DRUID_PASSWORD=xjz#123321 \
   -e SPRING_DATA_REDIS_HOST=redis -e SPRING_DATA_REDIS_PORT=6379 \

+ 2 - 1
docker-compose.yml

@@ -7,7 +7,8 @@ services:
     restart: always
     environment:
       TZ: "Asia/Shanghai"
-      SPRING_PROFILES_ACTIVE: prod
+      # 默认 prod。联调/测试机:SPRING_PROFILES_ACTIVE=test docker compose up -d(同 deploy.sh 的说明)
+      SPRING_PROFILES_ACTIVE: "${SPRING_PROFILES_ACTIVE:-prod}"
       # 乐收赢通道总开关,默认 true(不写就是开,与代码里的默认值一致)。
       # 本文件没有 env_file ⇒ ★ 宿主机上 export 了也进不了容器,必须靠这一行透传。
       # 通道还没有真实凭据时置 false:该模块的 7 个控制器与 2 个定时任务都不装配,

+ 16 - 12
java/src/main/resources/application.yml

@@ -120,16 +120,20 @@ alipay:
 # 我们是二级服务商,上游汇安融是一级,实际支付能力由上游提供。
 # 密钥未配好前 enabled 保持 false —— 定时轮询据此跳过,不会带着空密钥发起调用。
 har:
-  enabled: false
-  # 上游根地址(不含 context-path,代码里固定拼 /kf-alipay-api)
-  base-url: ""
-  # 上游分配的应用编号,联调时确定
-  app-id: ""
+  enabled: true
+  # 上游根地址。**不含 context-path** —— 代码固定拼 /kf-alipay-api,
+  # 由上游给的完整 URL 剥掉这一段填入。拼出的形态与 demo Main.java:13 一致:
+  #   {base-url}/kf-alipay-api/api/alipay/trade/query
+  base-url: https://har1.huihua365.com
+  # 上游分配的应用编号(每个请求都原文发给上游,非机密)
+  app-id: ${HAR_APP_ID:202609241054000002}
   version: "2.0"
-  # 我方私钥,PKCS#8 纯 Base64(换行自行去掉)
-  our-private-key: ""
-  # 汇安融公钥,X.509 纯 Base64(通知验签用,必须配)
-  platform-public-key: ""
+  # 我方私钥,PKCS#8 纯 Base64(URL-safe 与标准字母表都接受)。
+  # ★ 全仓唯一一件真凭据:绝不写进本文件,只由环境变量注入,默认值必须留空。
+  our-private-key: ${HAR_OUR_PRIVATE_KEY:MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDMvHN5gPKmD3fzOB6lq0WhXzwt5QyP3618WcMesAW4nINTzztt8j1NrIYtCu29FchQXPu28Tg6vwiAu0kc1XtJjGB11gFadEs0n03PW06pFevq6Oi4JX1jwLBsPEzWwshEfcz3S4DughTC6HK1rQyv/QFymO6exZSAGi5JdZ62na6Z/5lcqIPPdER2wYklru1qycYVVwogkRQtLtZBTLVrBS3W0ojLkBwXw6pDQGWDLxnhO65CfjWO8H4+PGb0GsFJhr3TuUVS3GRFhoW3cC1qjj95Ry/QnS/bDHA9F6ypoAEs2CEBbuWpo9GxsYIsnXMHFmzgouhohgANo2DCJxLdAgMBAAECggEAH25eBv0II3zT9Cu9Lr9myvGpNj2ICm1zP5gzN5qb4j3q/nqd29D43twZ5D/dhC4cKNRBk5IgSQqLZmH6RToz+XRa2bRPVGDuq8c6JZeVsakrJQ75LU4aHC6YlyFBxaW1I6ixhxSZGGSxhcO0WpZU0I7Vwk9esNLR68G5Me9j/JJdosYKv9bWOspJ7KMf39uDfC2ONXN+pGIkRCKKsAX+4G236QAl5m0xe2pyzuhge+94ASAQ2aXCXpZLnFv11KDxpTbVFj76bYHd8bfadHF6dd0dtQSIn9hqC4hm3IeuW57PQUx/CSokYOe1g8NsP/qmC9pTufL6yXixuk1qsZVHsQKBgQD15NYQv326tpFOu1LegYLOSo6YSsqLofANaIaAJDe+dqhf9imdr/X489R3NHJhj/LpeojtrPVd0IAT8lcvQKZcdXyNOEuOTjx3Tum9Jnbo4WI0I1Ev6ZjSXZIvE6j3nCG8cJ8ULETEExx9qr3Ejj9Wp/kkx3b7lFcQCpkXSHcZUQKBgQDVJpM1gO2kOBMDF5veKOk2vVQmGb0SLPVyQ0DkLevX1PfnNUD5pbKnsFzIHqU37lNZ5E7SLF7+OCjmPmU8Kge4pMchaJvL1O2yagaiExCj//zp3fDrfi+npGINsKeB/2l4mBKygHgcLg8ejpVxvyRsAv8+J8Vy+mAg9cyXZee9zQKBgDKzWXQgl8MM688pDsA0Qg1ZECfUnAjIfh+1oq3/ScMq4jLPJeuOoZAQbomZtc/qB3MewecH15vdZts2B8YFdYfw7ZqMtf+EoDEVg1pt+OyxtDb95TqU1t7hTkgIrLlJZydcrWBSdGddbW137CMAFY0/mz57JkxfXMhA9yn21h/RAoGANmOwfp+WhCf22fwlPswrrlcjaNF6iG/GddCpd1AsdReX5nyVRt68NsPu+PWw7RO4WyDB6CmAV9hWv2G+jVqgO8J4OZMq5hlASh158dNxlJeWnsqOVlkNqfZtOtcP90EIVchgRQI5I9+w5fg2NB81GQauwNp/LLas3tw5x4wuVSUCgYAgiRTniAN1ZQD5nM6xcgg2MO67nWExo/HS1ftmyoKEMCnOML3fha7PkuTbUry10GxsSgJuXLnbDreIUteiA6bgJECt3jZ5IHtwgMhfnTSCy6oL548sZmlhF7J9liVfKSkfnJ6saMWqCjnxCHRtgW76mKmFWD+SZbkFPV/D7+HiWg==}
+  # 汇安融公钥,X.509 纯 Base64(通知验签用,必须配)。
+  # 公钥按定义就是可公开的,故给出联调默认值;换环境用环境变量覆盖。
+  platform-public-key: ${HAR_PLATFORM_PUBLIC_KEY:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkMKjpUOq_yY28INUtEeZbYHPZ57BXwlmroWTDi-jFpCwC1X5WFOck9GKW3jDRwsEBLS1zaICrIqSn9U91YjWWg3wJYAD75nXrfMRGpfHaF-kTah3-2i2IwWEpbEshLzYIAYGQBxo0xMxys71B-rkwh4iiE9THg0IrpI5bthVUgE3-JyzKQz2TL3lw9vFGPiLRprHwta6-BTsuxW5gNJq2G63MNlcTOGHMcDg5ma4OLSBMc9J0iEaRcDC6CK1PcjDHxI9SlqKub2Aun08rOS2d21QKENCw7axwbwlAnE5s7HCWzaPGduLaRu-nEM7HnGMWMJQt_1_sCb5IBc8bhamywIDAQAB}
   callback-pool-size: 4
   connect-timeout-ms: 5000
   read-timeout-ms: 15000
@@ -164,11 +168,11 @@ logging:
 
 # 乐收赢支付通道
 leshouying:
-  base-url: https://test.gdyfsk.com/yfpay
+  base-url: https://open.gdyfsk.com/yfpay
   # 默认值是**文档给的测试机构**,开发期开箱即用;正式机构号进件后由环境变量注入
-  inst-no: ${LESHOUYING_INST_NO:900112}
+  inst-no: ${LESHOUYING_INST_NO:901318}
   # 机构密钥同样用环境变量占位:本文件被 git 跟踪,写死正式密钥 = 把机构密钥提交进仓库
-  key: ${LESHOUYING_KEY:1c4d83f0c3c24a7d9fcaa1fed2682076}
+  key: ${LESHOUYING_KEY:6c5809f1f714442ab21b36e04c8e5899}
   notify-url: ${LESHOUYING_NOTIFY_URL:https://qcsj88888.com/api/v1/payment/collection/notify/pay}
   sync-poll-times: 3
   sync-poll-interval-ms: 2000