]> acesimba.cloud Git - words.git/commitdiff
feat: HTTPS 同源部署 + 离线预取修复(PWA 离线缓存生效)
authorroot <root@localhost.localdomain>
Fri, 14 Aug 2026 10:11:28 +0000 (18:11 +0800)
committerroot <root@localhost.localdomain>
Fri, 14 Aug 2026 10:11:28 +0000 (18:11 +0800)
## 背景
思维导图/语音此前在离线模式不缓存,根因是 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
backend/db/bcd.db
deploy/certbot-auth-hook.sh [new file with mode: 0644]
deploy/lexicon-frontend-https.service [new file with mode: 0644]
deploy/nginx-conf.d-words.conf.disabled [new file with mode: 0644]
deploy/nginx-words-https.conf [new file with mode: 0644]
frontend/.gitignore
frontend/public/index.html
frontend/src/api.js
frontend/src/index.js
frontend/src/sync.js

index fba5ffcbc45e8abc3d0cdd2812706c8077006238..aabb8b3d6a17d7bb0631102a48c5e12bc99ac6db 100644 (file)
@@ -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
index 7ad8e579f9e96bcc4624b4083b08f5a06ea59c57..fb865f0a7cfdccd74152fa291f359330b0131a53 100644 (file)
Binary files a/backend/db/bcd.db and b/backend/db/bcd.db differ
diff --git a/deploy/certbot-auth-hook.sh b/deploy/certbot-auth-hook.sh
new file mode 100644 (file)
index 0000000..2e98777
--- /dev/null
@@ -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 (file)
index 0000000..d2117cd
--- /dev/null
@@ -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 (file)
index 0000000..a6ca4db
--- /dev/null
@@ -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 (file)
index 0000000..b53cc6d
--- /dev/null
@@ -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;
+    }
+}
index 4d29575de80483b005c29bfcac5061cd2f45313e..bc2c3eba60ee566aa65fb50265b3f604d1840e86 100644 (file)
@@ -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/
index 9665d2fd881f3e9c4c6cd05e0863a9c098a76667..0da1b4d439682b05fffe0636522286a5b9482b56 100644 (file)
@@ -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`.
     -->
-    <title>单词学习</title>
+    <title>Words</title>
   </head>
   <body>
     <noscript>You need to enable JavaScript to run this app.</noscript>
index ff5be5e84ca578fcf450568979785741e91866d4..0982fde74e3b5f2a11fbae7fdc31d70d1f5331a2 100644 (file)
@@ -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({
index d3e95256c2873008cf9a616a63c56a69f328d0a7..a918fdb1be48ec0a87e118c59d3834bd5b79c2b5 100644 (file)
@@ -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(() => {});
   });
 }
index 31ffcd2bebfc1140cc1edc53c7ab9ce316f37e10..f5521fde33b3baa07a715b08f8517759969d76b1 100644 (file)
@@ -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' 使请求键与 <img>/<Audio> 完全一致, 确保 SW cache-first 命中
+function prefetchUrl(url) {
+    return fetch(url, { mode: 'no-cors' }).catch(() => {});
+}
+
+function computePrefetchGroups(allWords, progress) {
+    const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
+    const opsByGroup = {};
+    progress.forEach(p => {
+        const g = Number(p.group_id);
+        if (!opsByGroup[g]) opsByGroup[g] = new Set();
+        opsByGroup[g].add(p.op_type);
+    });
+    const isComplete = (g) => !!opsByGroup[g] && PREFETCH_OPS.every(op => opsByGroup[g].has(op));
+    const incomplete = allGroups.filter(g => opsByGroup[g] && !isComplete(g));   // 已开始但未完成
+    const notStarted = allGroups.filter(g => !opsByGroup[g]);                     // 完全无记录
+    const firstNotStarted = notStarted.slice(0, PREFETCH_NOT_STARTED_AHEAD);
+    return Array.from(new Set([...incomplete, ...firstNotStarted])).sort((a, b) => a - b);
+}
+
+export async function prefetchLearnableAssets() {
+    if (typeof navigator === 'undefined' || !navigator.onLine) return;
+    if (!('caches' in window)) return;
+    const allWords = await getAllWordsFromIDB();
+    if (!allWords || allWords.length === 0) return;
+    const progress = await getAllProgress();
+    const target = computePrefetchGroups(allWords, progress);
+    if (target.length === 0) return;
+
+    const byGroup = {};
+    allWords.forEach(w => {
+        const g = Number(w.groupid);
+        if (!byGroup[g]) byGroup[g] = [];
+        byGroup[g].push(w);
+    });
+
+    // 收集需预取的 URL: 每组首词思维导图 + 每个词语音
+    const urls = [];
+    for (const g of target) {
+        const words = byGroup[g] || [];
+        if (words.length === 0) continue;
+        const picId = words[0].picture_id;
+        if (picId) urls.push(api.pictureUrl(picId));
+        for (const w of words) {
+            if (w.word) urls.push(api.ttsUrl(w.word));
+        }
+    }
+
+    // 准确去重: 只预取 SW 缓存里尚不存在的 URL (避免 meta 误标导致永久跳过)
+    const todo = [];
+    for (const u of urls) {
+        const hit = await caches.match(u).catch(() => undefined);
+        if (!hit) todo.push(u);
+    }
+    if (todo.length === 0) return;
+
+    // 分片并发, 限制同时请求数, 单条失败忽略
+    for (let i = 0; i < todo.length; i += PREFETCH_CONCURRENCY) {
+        const batch = todo.slice(i, i + PREFETCH_CONCURRENCY).map(u => prefetchUrl(u));
+        await Promise.allSettled(batch);
+    }
+}
+
 const OP_LABEL = { study: '学习', test: '测试', review1: '复习I', review2: '复习II', review3: '复习III' };
 
 // 格式化同步结果为可读提示 (保留, 用于失败时/手动诊断)