diff --git a/PLAN.md b/PLAN.md index e644055..563ab41 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,128 @@ # 项目计划(2026-02-08) +## 0. 手动切换路由方案(2026-02-10) + +### 0.1 目标 + +- 支持在**不发布小程序新版本**的情况下,从服务器 A(`fonts.biboer.cn`)切换到服务器 B(`mac.biboer.cn`),或反向切换。 +- 切换仅通过手动修改远端配置文件触发,不引入自动健康探测。 +- 避免 A/B 来回抖动切换。 + +### 0.2 已确认约束 + +- 字体目录统一一份:`/fonts/`(A、B 各自托管同结构字体文件)。 +- 配置文件独立管理: + - Web:`/fonts.json`(可选 `/default.json`) + - 小程序:`/miniprogram/assets/fonts.json`、`/miniprogram/assets/default.json` +- 小程序合法域名已包含 A/B 两个域名。 +- 不使用 `version` 字段。 + +### 0.3 路由配置文件(route-config.json) + +- 建议路径:`/miniprogram/assets/route-config.json`(A、B 都提供) +- 建议结构: + +```json +{ + "active": "A", + "cooldownMinutes": 10, + "servers": { + "A": { "baseUrl": "https://fonts.biboer.cn" }, + "B": { "baseUrl": "https://mac.biboer.cn" } + } +} +``` + +字段说明: +- `active`: 当前希望启用的目标服务器(`A` 或 `B`)。 +- `cooldownMinutes`: 最短驻留时间。 + - `10` 表示 10 分钟内不允许再次切换。 + - `0` 表示可立即切换。 + +### 0.4 双确认切换规则(核心) + +当客户端当前连接在 A,且读取到 A 配置 `active=B` 时: +1. 客户端继续请求 B 的 `route-config.json`。 +2. 只有 B 也返回 `active=B`,才允许切换到 B。 +3. 若 A/B 不一致,则保持当前服务器不变。 +4. 若读取 B 失败(超时、非 200、JSON 非法),保持当前服务器不变,并记录失败日志。 + +反向切换(B -> A)同理执行。 + +### 0.5 A/B 交互与同步机制 + +为满足“双确认”,A/B 需要配置一致性机制(交互): +- 维护端本地保留单一配置源文件(Git 仓库内)。 +- 通过 `deploy.sh` 一次性下发到 A、B。 +- 采用“临时文件 + 原子替换(mv)”发布,避免客户端读取半文件。 + +推荐切换步骤(A -> B): +1. 同步配置到 B,确保 B 返回 `active=B`。 +2. 再同步配置到 A,改为 `active=B`。 +3. 客户端双确认通过后完成切换。 + +### 0.6 客户端状态与防抖 + +本地持久化字段: +- `activeServerKey`(当前服务器) +- `lastSwitchAt`(最后切换时间戳) +- `routeConfigCache`(最近一次配置) +- `lastRouteCheckAt`(最后一次读取 route-config 时间戳) + +切换判定: +- 若 `cooldownMinutes > 0` 且 `now - lastSwitchAt < cooldownMinutes * 60 * 1000`,拒绝切换。 +- 若 `cooldownMinutes = 0`,通过双确认即可立即切换。 + +### 0.6.1 route-config.json 读取时机 + +1. 启动读取(P0) +- 小程序冷启动时优先读取 `route-config.json`,再加载 `fonts.json/default.json` 与 API 请求。 + +2. 回前台读取(P0) +- `App.onShow` 触发时检查是否超过最小间隔(例如 60 秒),超过则读取。 + +3. 失败兜底读取(P0) +- 当 API 或配置拉取连续失败时,立即触发一次读取并执行双确认逻辑。 +- 若目标服务器配置读取失败,则仅记录日志并维持当前服务器,不执行切换。 + +4. 手动刷新读取(P1,可选) +- 提供调试入口(如“刷新配置”按钮)用于即时验证切换。 + +节流规则: +- 若 `now - lastRouteCheckAt < 60s`,跳过非必要读取,避免频繁请求。 + +### 0.7 实施任务拆分 + +1. 小程序端 +- 新增 `route-manager`(加载路由配置、执行双确认、应用 cooldown、持久化状态)。 +- `render-api`、`font-loader` 改为读取 `route-manager` 当前 `baseUrl`。 +- 启动时先加载路由,再加载 `fonts.json/default.json`。 + +2. 服务端与部署 +- A/B 都部署 `route-config.json`。 +- 使用 `deploy.sh` 统一发布:一次性下发到两台服务器并做原子替换。 +- `deploy.sh` 负责同步 `route-config.json`、字体配置文件,并执行可选巡检。 +- 增加巡检命令:检查 A/B 当前 `active` 是否一致。 + +3. 文档 +- 更新 `miniprogram/README.md`:增加无发版切换流程。 +- 增加运维操作手册:A->B、B->A、回滚流程。 + +### 0.8 验收标准 + +- 仅修改远端 `route-config.json`,小程序无需发版即可切换 A/B。 +- `cooldownMinutes=10` 时,10 分钟内不会再次切换。 +- `cooldownMinutes=0` 时,双确认满足后可立即切换。 +- A/B 配置不一致时不发生切换。 +- 目标服务器配置读取失败(超时/非 200/JSON 非法)时不发生切换。 +- 切换后 API、字体清单、默认配置均来自目标服务器。 + +### 0.9 风险与回滚 + +- 风险:A/B 配置不同步导致无法切换(预期保护行为)。 +- 风险:CDN 缓存导致短时间读取旧配置(通过短缓存 + 原子发布缓解)。 +- 回滚:将 A/B 两端 `active` 同步改回原服务器,并设置 `cooldownMinutes=0` 可快速恢复。 + ## 1. 当前状态 ### 1.1 已完成(保留能力) @@ -1113,4 +1236,3 @@ wx.downloadFile({ url: fontPath }) **计划更新日期**:2026-02-08 **下次更新**:M1 完成后,根据实际情况调整后续里程碑细节 **部署方案**:Cloudflare CDN + 海外服务器(fonts.biboer.cn,免备案) - diff --git a/apiserver/README.md b/apiserver/README.md index 40e5119..692733d 100644 --- a/apiserver/README.md +++ b/apiserver/README.md @@ -87,7 +87,7 @@ location /api/ { sudo nginx -t && sudo systemctl reload nginx ``` -## 4. systemd(可选) +## 4. systemd(Linux,可选) 仓库内提供示例:`apiserver/font2svg-api.service.example`,可复制到: @@ -98,7 +98,38 @@ sudo systemctl enable --now font2svg-api sudo systemctl status font2svg-api ``` -## 5. 约束 +## 5. launchd(macOS,可选) + +仓库内提供示例:`apiserver/font2svg-api.launchd.plist.example`。 + +1. 复制并按本机路径修改(重点改 Python 路径和项目路径): + +```bash +cp apiserver/font2svg-api.launchd.plist.example ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist +``` + +2. 加载并启动: + +```bash +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist +launchctl enable gui/$(id -u)/cn.biboer.font2svg-api +launchctl kickstart -k gui/$(id -u)/cn.biboer.font2svg-api +``` + +3. 查看状态与日志: + +```bash +launchctl print gui/$(id -u)/cn.biboer.font2svg-api +tail -f /tmp/font2svg-api.log +``` + +4. 停止并卸载: + +```bash +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist +``` + +## 6. 约束 - 字体解析完全基于字体清单(默认 `miniprogram/assets/fonts.json`),字体文件统一从 `/fonts/` 读取,`fontId` 必须存在。 - 服务端启用 CORS,允许小程序访问。 diff --git a/apiserver/font2svg-api.launchd.plist.example b/apiserver/font2svg-api.launchd.plist.example new file mode 100644 index 0000000..93f6c61 --- /dev/null +++ b/apiserver/font2svg-api.launchd.plist.example @@ -0,0 +1,41 @@ + + + + + Label + cn.biboer.font2svg-api + + ProgramArguments + + /Users/gavin/font2svg/.venv/bin/python + /Users/gavin/font2svg/apiserver/server.py + --host + 127.0.0.1 + --port + 9300 + --static-root + /Users/gavin/font2svg + + + WorkingDirectory + /Users/gavin/font2svg + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /tmp/font2svg-api.log + + StandardErrorPath + /tmp/font2svg-api.log + + EnvironmentVariables + + PYTHONUNBUFFERED + 1 + + + diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..44e06cb --- /dev/null +++ b/deploy.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# ----------------------------------------------------------------------------- +# deploy.sh +# +# 用途 +# - 一键执行本地推送 + 远端两台服务器拉取代码(A/B)。 +# - 默认部署分支:main。 +# +# 执行流程 +# 1) 运行本地推送脚本(默认 ./gitrun.sh) +# 2) 服务器 A 执行 git fetch + checkout + pull --ff-only +# 3) 服务器 B 执行 git fetch + checkout + pull --ff-only +# +# 前置条件 +# - 本机已配置 SSH 免密登录到 A/B 两台服务器。 +# - 远端目录已存在 Git 仓库(默认 ~/font2svg)。 +# - 本地 GITRUN_SCRIPT 可执行。 +# +# 可覆盖环境变量 +# - GITRUN_SCRIPT 本地推送脚本路径(默认 ./gitrun.sh) +# - DEPLOY_BRANCH 部署分支(默认 main) +# - REMOTE_WORKDIR 远端项目目录(默认 ~/font2svg) +# - SERVER_A 服务器 A(默认 gavin@mac.biboer.cn) +# - SERVER_A_PORT 服务器 A SSH 端口(默认 22) +# - SERVER_B 服务器 B(默认 gavin@biboer.cn) +# - SERVER_B_PORT 服务器 B SSH 端口(默认 21174) +# +# 示例 +# - 使用默认配置: +# bash deploy.sh +# - 指定分支: +# DEPLOY_BRANCH=release bash deploy.sh +# - 指定远端目录: +# REMOTE_WORKDIR=~/apps/font2svg bash deploy.sh +# +# 失败策略 +# - set -euo pipefail:任一步失败立即退出,避免部分成功导致状态不一致。 +# ----------------------------------------------------------------------------- + +set -euo pipefail + +# 部署配置(可通过环境变量覆盖) +GITRUN_SCRIPT="${GITRUN_SCRIPT:-./gitrun.sh}" +DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" +REMOTE_WORKDIR="${REMOTE_WORKDIR:-~/font2svg}" +SERVER_A="${SERVER_A:-gavin@mac.biboer.cn}" +SERVER_A_PORT="${SERVER_A_PORT:-22}" +SERVER_B="${SERVER_B:-gavin@biboer.cn}" +SERVER_B_PORT="${SERVER_B_PORT:-21174}" +SSH_OPTS=( + -o BatchMode=yes + -o ConnectTimeout=8 + -o StrictHostKeyChecking=accept-new +) + +log_info() { + echo "[INFO] $1" +} + +run_local_push() { + if [[ ! -x "$GITRUN_SCRIPT" ]]; then + echo "[ERROR] git 推送脚本不可执行: $GITRUN_SCRIPT" + echo "请先执行: chmod +x $GITRUN_SCRIPT" + exit 1 + fi + log_info "执行本地推送脚本: $GITRUN_SCRIPT" + "$GITRUN_SCRIPT" +} + +run_remote_pull() { + local host="$1" + local port="$2" + log_info "远程拉取: $host:$port ($DEPLOY_BRANCH)" + ssh "${SSH_OPTS[@]}" -p "$port" "$host" \ + "cd $REMOTE_WORKDIR && git fetch origin && git checkout $DEPLOY_BRANCH && git pull --ff-only origin $DEPLOY_BRANCH" +} + +main() { + run_local_push + run_remote_pull "$SERVER_A" "$SERVER_A_PORT" + run_remote_pull "$SERVER_B" "$SERVER_B_PORT" + log_info "部署完成" +} + +main "$@" diff --git a/mac.conf b/mac.conf new file mode 100644 index 0000000..3b41b48 --- /dev/null +++ b/mac.conf @@ -0,0 +1,129 @@ +# Font2SVG - Nginx 配置(mac.biboer.cn) +# 用途:为微信小程序提供静态字体资源 + 远端 SVG 渲染 API + +server { + listen 80; + listen [::]:80; + server_name mac.biboer.cn; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name mac.biboer.cn; + + # SSL 证书 + ssl_certificate /Users/gavin/mac.biboer.cn_ecc/fullchain.cer; + ssl_certificate_key /Users/gavin/mac.biboer.cn_ecc/mac.biboer.cn.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # 静态资源根目录(包含 fonts/、fonts.json、miniprogram/assets/*) + root /Users/gavin/font2svg; + index fonts.json; + + access_log /opt/homebrew/var/log/nginx/access.log; + error_log /opt/homebrew/var/log/nginx/error.log; + + server_tokens off; + + # 小程序跨域访问 + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always; + add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always; + add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always; + + # MIME + types { + application/json json; + font/ttf ttf; + font/otf otf; + font/woff woff; + font/woff2 woff2; + application/vnd.ms-fontobject eot; + } + + # SVG 渲染 API(独立 Python 服务,systemd 监听 127.0.0.1:9300) + location ^~ /api/ { + # 预检请求:直接返回 204(CORS 头由 server 级 add_header 提供) + if ($request_method = OPTIONS) { + return 204; + } + proxy_pass http://127.0.0.1:9300; + 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; + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # 健康检查(可选) + location = /healthz { + proxy_pass http://127.0.0.1:9300/healthz; + 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; + } + + # fonts.json:短缓存,便于更新 + location = /fonts.json { + expires 1h; + add_header Cache-Control "public, must-revalidate" always; + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always; + add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always; + add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always; + try_files $uri =404; + } + + # 小程序配置:短缓存,便于切换 + location = /miniprogram/assets/fonts.json { + expires 1h; + add_header Cache-Control "public, must-revalidate" always; + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always; + add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always; + add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always; + try_files $uri =404; + } + + location = /miniprogram/assets/default.json { + expires 1h; + add_header Cache-Control "public, must-revalidate" always; + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always; + add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always; + add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always; + try_files $uri =404; + } + + # 字体文件:长缓存 + location ~* \.(ttf|otf|woff|woff2|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable" always; + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always; + add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always; + add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always; + try_files $uri =404; + } + + # 默认仅提供静态文件 + location / { + try_files $uri =404; + } + + # 禁止访问隐藏文件 + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +}