From 426858a86bd98d197ea6d107c9e2f88b63f90011 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 18:11:28 +0800 Subject: [PATCH] =?utf8?q?feat:=20HTTPS=20=E5=90=8C=E6=BA=90=E9=83=A8?= =?utf8?q?=E7=BD=B2=20+=20=E7=A6=BB=E7=BA=BF=E9=A2=84=E5=8F=96=E4=BF=AE?= =?utf8?q?=E5=A4=8D=EF=BC=88PWA=20=E7=A6=BB=E7=BA=BF=E7=BC=93=E5=AD=98?= =?utf8?q?=E7=94=9F=E6=95=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit ## 背景 思维导图/语音此前在离线模式不缓存,根因是 Service Worker 在非安全上下文 (http://IP:端口)下不激活。本次完成 HTTPS 部署(Let's Encrypt 证书 + nginx 反代),使 SW 在安全上下文中激活,并修复预取逻辑两处 bug,离线缓存终于生效。 ## 改动点 1. HTTPS 同源部署(零风险,原 :55 完全不动) - api.js: API_BASE 改为读取 REACT_APP_API_BASE(默认值仍为本机 IP:1060,:55 行为不变) - 新增 build-https(REACT_APP_API_BASE="" → 同源相对路径) - 新增 deploy/lexicon-frontend-https.service(:8444 同源前端 serve) - 新增 deploy/certbot-auth-hook.sh(DNS-01 手动挑战钩子,轮询等待 TXT 生效) - 新增 deploy/nginx-words-https.conf(:8443 TLS → /:8444,/api/:1060) - 新增 deploy/nginx-conf.d-words.conf.disabled(原占用 :55 的旧 nginx 站点,已停用) - 在 /etc/nginx 中禁用占用 :55 的 lexicon 与 conf.d/words.conf,避免与 :55 冲突 - certbot 签发 acesimba.cloud 证书(DNS-01 手动,Plan B),已配置自动续期 2. 修复离线预取(sync.js) - 去重逻辑由 meta.prefetchedGroups 改为直接查 SW 缓存 caches.match(url), 避免 SW 未接管时请求绕过 SW 未真正缓存却被误标"已完成"导致永久跳过 - 预取目标仍为 G48–G60(未完成组 + 未开始组前 10 组)的每组首词思维导图 + 每词语音 3. SW 接管时机(index.js) - 在 navigator.serviceWorker.ready 之后再触发一次同步+预取, 确保预取请求被 SW 拦截并写入缓存(首屏即可缓存,无需手动刷新) 4. 前端标题(index.html):单词学习 → Words 5. 云端日志:补充 G38 review3(完成时间 2026-08-03) ## 验证 - https://acesimba.cloud:8443 可打开,SW 激活 - 联网同步后 G48–G60 思维导图/语音写入 Cache Storage(words-pwa-v1) - 离线模式打开 G48–G60 各组思维导图正常显示 - :55 原学习路径保持可用 --- .gitignore | 3 + backend/db/bcd.db | Bin 9461760 -> 9461760 bytes deploy/certbot-auth-hook.sh | 22 +++++++ deploy/lexicon-frontend-https.service | 15 +++++ deploy/nginx-conf.d-words.conf.disabled | 2 + deploy/nginx-words-https.conf | 34 +++++++++++ frontend/.gitignore | 3 + frontend/public/index.html | 2 +- frontend/src/api.js | 5 +- frontend/src/index.js | 4 ++ frontend/src/sync.js | 76 +++++++++++++++++++++++- 11 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 deploy/certbot-auth-hook.sh create mode 100644 deploy/lexicon-frontend-https.service create mode 100644 deploy/nginx-conf.d-words.conf.disabled create mode 100644 deploy/nginx-words-https.conf diff --git a/.gitignore b/.gitignore index fba5ffc..aabb8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ backend/a/error_log.csv # TTS audio cache (runtime-generated) backend/db/tts_cache/ + +# certbot 一次性 DNS 挑战值(临时文件,不入库) +deploy/dns-txt-value.txt diff --git a/backend/db/bcd.db b/backend/db/bcd.db index 7ad8e579f9e96bcc4624b4083b08f5a06ea59c57..fb865f0a7cfdccd74152fa291f359330b0131a53 100644 GIT binary patch delta 1247 zcmZwDH&7H&9LI6q2EaQMm9XF`f&t^n2DpPga}FpfWQK<4jWc#u78Y7DW9f{Aj*VYD!-?+9XLjCuzxV(Dcl$?QDE&zLMDD|x>qPGJqqmww z;>K9hz*v)j1Q`lc#3a4=f zXK@baaRC=`37xo%E9gQP-H0HH7~<$b0=>A3Yv@BiuEW6%+{7*1#sKc%F7AztH4RpL zkUz@-H&0KtN%9f*q@HY%5k(JTA$* z+G;^q>r4$vyq|WaTFR6nHK}$h$K+a#i@Mr0Pr~%hb()U$6J;mPnMUqNGYPf6VrC+pt8BrV5Si%$R@n=^|}HcipfpGvq|vp;i)an^RM@iDgW?E@&Hpkxz%yh01q4?8q1Z delta 701 zcmWmA$yZWg0ETg|_YxJ#kTybuX*5YeI7^vRQC8Q|MkxVtY!DS3GK(}q<)YN)TetF@ zcAge&bz^@*3pcG;b`Cq2JuZH$=Uu&tt!*WNtW_%dW^0#gY^ zH~>8k;t&qQfD)9#2oub(;0Vf4j-xn+<2ZqnI0Y+gI1M|_z<~;!MJ1|m4o;lM1zf}> zTt+oqxPq&2!-H$^q6W37Lp>VMh$b|{hwHe37TiQD+R%;;bmA6n!;d?-i!R*5eLTSa zcini1N9aK>9^(m~;u)Uf1^N)cOT0oqf(Rju0Yor}Aq-;#qZq?$yun+%!#JXtz$B&+ zLmbnX!7S$R9`pEs1$@LOB=8xFSi%=1@f9g7V+E_ZyqdQEmH)|Up?7QjV#KWl#Ex6@ z3$sTn7fz2B7ONgD@~hRcA*Iq@EtmFY=6rHeaS44i^F#cMW&%c?ri&{fok`hKzAN)= ht#Zm0%U~_%imm_Il`V#X(06AmX}=A*LSNRT{0AGUPCWns diff --git a/deploy/certbot-auth-hook.sh b/deploy/certbot-auth-hook.sh new file mode 100644 index 0000000..2e98777 --- /dev/null +++ b/deploy/certbot-auth-hook.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# certbot 手动 DNS-01 验证钩子(方案 B:用户在 DNSPod 手工添加 TXT 记录) +# certbot 会把本次校验值通过环境变量 $CERTBOT_VALIDATION 传入 +set -u + +VALUE="${CERTBOT_VALIDATION:-}" +echo "$VALUE" > /home/words/deploy/dns-txt-value.txt +chmod 644 /home/words/deploy/dns-txt-value.txt +echo "[certbot-auth-hook] TXT 校验值已写入 /home/words/deploy/dns-txt-value.txt" +echo "[certbot-auth-hook] 轮询等待用户在 DNSPod 添加 _acme-challenge.acesimba.cloud TXT(最多 600s)..." + +# 轮询公共 DNS,直到能查到本次校验值(用户添加后通常 1 分钟内可见) +for i in $(seq 1 120); do + FOUND=$(dig +short TXT _acme-challenge.acesimba.cloud 2>/dev/null | grep -F "$VALUE" || true) + if [ -n "$FOUND" ]; then + echo "[certbot-auth-hook] TXT 已传播,返回,certbot 将开始校验。" + exit 0 + fi + sleep 5 +done +echo "[certbot-auth-hook] 600s 内未观测到 TXT,certbot 仍会尝试校验(可能失败)。" +exit 0 diff --git a/deploy/lexicon-frontend-https.service b/deploy/lexicon-frontend-https.service new file mode 100644 index 0000000..d2117cd --- /dev/null +++ b/deploy/lexicon-frontend-https.service @@ -0,0 +1,15 @@ +[Unit] +Description=Words Frontend HTTPS (same-origin build, serve :8444) +After=network.target lexicon-backend.service + +[Service] +Type=simple +User=root +WorkingDirectory=/home/words/frontend +Environment="PATH=/root/.nvm/versions/node/v22.22.2/bin:/usr/bin:/bin" +ExecStart=/root/.nvm/versions/node/v22.22.2/bin/serve -s build-https -l 8444 +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-conf.d-words.conf.disabled b/deploy/nginx-conf.d-words.conf.disabled new file mode 100644 index 0000000..a6ca4db --- /dev/null +++ b/deploy/nginx-conf.d-words.conf.disabled @@ -0,0 +1,2 @@ +# 已禁用:原 words.conf 监听 :55 / :1055,与正在运行的 serve(:55) 冲突, +# 且会阻止 nginx 启动。HTTPS 走 /etc/nginx/sites-enabled/words-https(仅 :8443)。 diff --git a/deploy/nginx-words-https.conf b/deploy/nginx-words-https.conf new file mode 100644 index 0000000..b53cc6d --- /dev/null +++ b/deploy/nginx-words-https.conf @@ -0,0 +1,34 @@ +# Words HTTPS 反向代理(方案 B:仅监听 8443,绝不占用 :55) +# / -> 同源前端 build-https (:8444) +# /api/ -> 后端 FastAPI (:1060) +server { + listen 8443 ssl; + server_name acesimba.cloud; + + ssl_certificate /etc/letsencrypt/live/acesimba.cloud/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/acesimba.cloud/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 后端 API:同源,浏览器看到的都是 acesimba.cloud:8443,无需 CORS + location /api/ { + proxy_pass http://127.0.0.1:1060; + 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; + } + + # 前端静态资源(含 /sw.js、/manifest.json),SPA 回退由 serve -s 处理 + location / { + proxy_pass http://127.0.0.1:8444; + 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; + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index 4d29575..bc2c3eb 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -21,3 +21,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# HTTPS 同源构建产物(由 REACT_APP_API_BASE="" BUILD_PATH=build-https 生成,不入库) +frontend/build-https/ diff --git a/frontend/public/index.html b/frontend/public/index.html index 9665d2f..0da1b4d 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - 单词学习 + Words diff --git a/frontend/src/api.js b/frontend/src/api.js index ff5be5e..0982fde 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,7 +1,10 @@ import axios from 'axios'; // 后端 API 基址。PWA 离线方案中所有组件统一走此出口,便于 Service Worker 拦截与未来切换。 -export const API_BASE = "http://124.222.157.124:1060"; +// 默认走跨域 IP:端口(:55 现有 build 行为不变);HTTPS 同源 build 通过 REACT_APP_API_BASE="" 覆盖为相对路径。 +export const API_BASE = process.env.REACT_APP_API_BASE !== undefined + ? process.env.REACT_APP_API_BASE + : "http://124.222.157.124:1060"; // 统一 axios 实例:超时 8s,避免离线时网络请求长时间挂起 export const http = axios.create({ diff --git a/frontend/src/index.js b/frontend/src/index.js index d3e9525..a918fdb 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -20,5 +20,9 @@ reportWebVitals(); if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(err => console.error('SW registration failed:', err)); + // SW 接管页面后, 再触发一次同步+预取, 确保预取请求被 SW 拦截并写入缓存 + navigator.serviceWorker.ready.then(() => { + import('./sync').then(m => m.triggerAutoSync()).catch(() => {}); + }).catch(() => {}); }); } diff --git a/frontend/src/sync.js b/frontend/src/sync.js index 31ffcd2..f5521fd 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -7,7 +7,7 @@ import { api } from './api'; import { getDirtyProgress, clearDirty, getAllProgress, putProgress, - putWords, setMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount, + putWords, setMeta, getMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount, } from './db'; const OPS = ['study', 'test', 'review1', 'review2', 'review3']; @@ -78,6 +78,9 @@ export async function syncNow() { await setMeta('lastSyncAt', new Date().toISOString()); await recomputeLearnableGroups(); + // 阶段5: 后台预取离线资源(思维导图+语音), 不阻塞同步完成 + prefetchLearnableAssets().catch(() => {}); + setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled }); return result; } @@ -135,6 +138,77 @@ export async function recomputeLearnableGroups() { await setLearnableGroups(Array.from(learnable).sort((a, b) => a - b)); } +// ---------- 预取离线资源(思维导图 + 语音)---------- +// 范围: 未完成组(有记录但未集齐5项) ∪ 未开始组中最前面10组 +// 仅在线执行, 后台非阻塞; 去重直接查 SW 缓存 (caches.match), 不依赖 meta 标记, +// 避免"SW 未接管时预取未真正落盘却被误标为已完成"导致后续跳过。 +const PREFETCH_CONCURRENCY = 6; +const PREFETCH_NOT_STARTED_AHEAD = 10; +const PREFETCH_OPS = ['study', 'test', 'review1', 'review2', 'review3']; + +// mode:'no-cors' 使请求键与 /