# 项目计划(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` 可快速恢复。 ### 0.10 非付费负载均衡预案(Cloudflare Workers,暂不实施) 目标: - 在不使用 Cloudflare Load Balancer 付费能力的前提下,实现 A/B 两端流量分发与故障回退。 - 小程序侧统一使用单一入口域名,减少合法域名和切换复杂度。 建议架构: 1. 新增统一入口域名(示例:`mp-api.biboer.cn`),仅小程序访问该域名。 2. 入口域名绑定 Cloudflare Worker 路由(`mp-api.biboer.cn/*`)。 3. Worker 负责: - `/api/*` 按权重分流到 A/B(初始建议 A:90%,B:10%)。 - 主节点失败时自动重试备用节点(一次重试即可)。 - `GET/HEAD` 以外请求复用同一请求体,避免重试时 body 丢失。 4. 静态配置与字体资源在首阶段固定走 A(`fonts.biboer.cn`),避免 A/B 字体清单不一致引起渲染差异。 权重修改与热加载(推荐): 1. 不将权重硬编码在 Worker 代码中,避免每次改权重都重新发布。 2. 新增独立配置文件(示例:`/miniprogram/assets/lb-config.json`),由 Worker 定期拉取。 3. Worker 本地缓存最近一次有效配置,按 TTL(建议 30~60 秒)自动刷新。 4. 权重调整流程仅需改配置文件并发布配置,无需小程序发版、无需 Worker 重新部署。 `lb-config.json` 建议结构: ```json { "weights": { "A": 90, "B": 10 }, "ttlSeconds": 60, "minSwitchIntervalSeconds": 30 } ``` 字段约束: - `weights.A`、`weights.B`:`0~100`,且总和必须为 `100`。 - `ttlSeconds`:Worker 重新拉取配置间隔,建议 `30~60`。 - `minSwitchIntervalSeconds`:最小切换间隔,避免流量来回抖动。 前置条件(实施前必须满足): 1. A/B 字体文件与清单一致(当前 `fontCount` 必须对齐)。 2. A/B API 行为一致:`/healthz`、`/api/render-svg`、`/api/render-png`。 3. 小程序后台已配置统一入口域名为合法 `request/downloadFile` 域名。 分阶段实施: 1. P0(演练) - 先用 Worker 做单域名透传(100% A),验证链路稳定性。 2. P1(灰度) - 开启 `/api/*` 90/10 分流,观察失败率与时延(至少 24 小时)。 3. P2(增配) - 视稳定性逐步调整 80/20、70/30,不直接跳 50/50。 4. P3(稳态) - 形成标准运维流程:权重调整、紧急回切、日志巡检。 回滚策略: 1. Worker 立即切为 100% A。 2. 若 Worker 故障,DNS/路由回退到 A 直连入口。 3. 保留 `route-config` 手动切换机制作为最终兜底,不删除。 配置容错(实施时必须包含): 1. 配置拉取失败:继续使用“上一次有效配置”,不回退空配置。 2. 配置格式非法:记录错误并忽略本次配置,继续使用旧配置。 3. 连续失败超过阈值:自动降级到 `A=100, B=0`,并输出告警日志。 暂不实施说明: - 本节仅记录方案和执行顺序,当前版本继续使用“手动 A/B 切换”主路径。 - 待 A/B 数据一致性、日志与监控项完善后,再启动该预案实施。 ## 1. 当前状态 ### 1.1 已完成(保留能力) - Web 应用主链路可用:字体选择、预览、收藏、导出(SVG/PNG) - Python CLI 可独立运行:`font2svg.py`、`pic2svg.py` - 字体清单生成脚本可用:`scripts/generate-font-list.py` ### 1.2 新增目标 - 在不影响现有 Web/CLI 的前提下,新增微信小程序版本(新目录:`miniprogram/`) - 小程序版本以移动端体验为主,不做 Web 页面 1:1 迁移 ## 2. 小程序专项范围 ### 2.1 技术选型 **开发方式**:微信小程序原生开发(TypeScript + WXML + WXSS) - 理由:充分利用 Worker、Canvas 2D 等原生能力,避免框架转换损耗 - 放弃 Taro/uni-app:复杂业务逻辑转换可能引入额外兼容问题 **核心算法复用**:直接复制 Web 端 `utils/` 核心模块 - `svg-builder.ts`:字形路径生成与 SVG 构建(纯函数,无浏览器 API 依赖) - `text-layout.ts`:文本换行逻辑 - `font-loader.ts`:适配为小程序 API(`wx.downloadFile` + `wx.getFileSystemManager`) **字体资源托管**:云存储(微信云开发 / 第三方 OSS) - 字体文件上传到云端,生成 CDN URL - `fonts.json` 清单文件托管(含字体元数据) - 按需下载,突破小程序 2MB 主包限制 ### 2.2 范围内(MVP) - 文本输入 + 字体选择(搜索/分类/收藏)+ 实时预览 - 字号、颜色调节 - 导出 SVG(文件系统写入 + 分享) - 导出 PNG(Canvas 2D 渲染 + 保存相册) - 本地状态持久化(文本、字体、参数) ### 2.3 范围外(后续迭代) - HarfBuzz/WASM 高级 shaping(首版关闭,使用 opentype.js 基础能力) - 多字体对比预览(Web 版特性,小程序屏幕受限) - 批量导出与 ZIP 打包(小程序 API 限制) - 字体树分类折叠展开(简化为扁平列表 + 搜索) ## 3. 移动端差异与技术策略 ### 3.1 交互策略 - 单列流程:输入 -> 预览 -> 导出 - 大按钮、触控优先、避免多栏复杂布局 ### 3.2 性能策略 - 字形生成放入 `Worker`,主线程专注渲染 - 长文本分批处理,支持任务取消 - 字体对象缓存与内存上限控制 ### 3.3 平台适配策略 - 文件系统:`wx.getFileSystemManager()` + `writeFile/saveFile` - PNG 导出:`wx.canvasToTempFilePath` - 文件分享:`wx.shareFileMessage` - 图片保存:`wx.saveImageToPhotosAlbum`(带授权处理) - 预览:优先 `image` 组件渲染 SVG,必要时回退 PNG ### 3.4 包体策略 - 字体与 Worker 支持分包/按需加载 - 首包仅保留必要资源 ## 4. 目录结构设计 ``` miniprogram/ ├── pages/ # 页面 │ ├── index/ # 主页(输入+预览+导出) │ │ ├── index.wxml │ │ ├── index.ts │ │ ├── index.wxss │ │ └── index.json │ └── font-picker/ # 字体选择页 │ ├── font-picker.wxml │ ├── font-picker.ts │ ├── font-picker.wxss │ └── font-picker.json ├── components/ # 组件 │ ├── text-input/ # 文本输入组件 │ ├── font-item/ # 字体列表项 │ ├── preview-card/ # 预览卡片 │ └── export-panel/ # 导出面板 ├── workers/ # Worker 线程 │ └── svg-generator/ # SVG 生成 Worker │ └── index.ts ├── utils/ # 工具函数 │ ├── core/ # 核心算法(复用 Web) │ │ ├── svg-builder.ts # SVG 生成核心 │ │ ├── text-layout.ts # 文本换行 │ │ └── glyph-path.ts # 字形路径 │ ├── mp/ # 小程序专用 │ │ ├── font-loader-mp.ts # 字体加载适配 │ │ ├── storage-mp.ts # 存储适配 │ │ ├── canvas-export.ts # Canvas 导出 │ │ └── share-mp.ts # 分享/保存 │ └── worker-manager.ts # Worker 管理器 ├── typings/ # 类型定义(复用 Web types/) │ └── font.d.ts ├── assets/ # 静态资源 │ └── icons/ # 图标(复用 Web) ├── styles/ # 全局样式 │ └── variables.wxss # 设计 token(基于 Figma) ├── miniprogram_npm/ # npm 构建产物 ├── app.ts # 小程序入口 ├── app.json # 全局配置 ├── app.wxss # 全局样式 ├── package.json # npm 依赖 ├── tsconfig.json # TypeScript 配置 └── project.config.json # 微信开发者工具配置 ``` ## 5. 里程碑与验收 ### M1:工程骨架(P0) **实施步骤**: 1. 创建 `miniprogram/` 目录,微信开发者工具初始化原生项目 2. 配置 `project.config.json`: - 设置 `miniprogramRoot`、基础库版本 ≥ 2.9.0 - 启用 TypeScript 编译插件 3. 创建 `package.json`,安装依赖: ```json { "dependencies": { "opentype.js": "^1.3.4" }, "devDependencies": { "miniprogram-api-typings": "latest", "typescript": "~5.9.3" } } ``` 4. 配置 `tsconfig.json`(参考 `frontend/tsconfig.json`) 5. 创建 `app.ts`、`app.json`、`app.wxss` 6. 创建首页 `pages/index/`(空白页面) 7. 微信开发者工具"构建 npm" **验收标准**: - 开发者工具可启动并进入首页(显示空白页面) - TypeScript 编译无错误 - npm 包构建成功(`miniprogram_npm/` 生成) ### M2:核心算法迁移(P0) **实施步骤**: 1. **复制纯算法模块**: - `frontend/src/utils/svg-builder.ts` → `miniprogram/utils/core/svg-builder.ts` - 保留 `generateSvg()` 函数(移除 `generateSvgWithHarfbuzz()`) - `frontend/src/utils/text-layout.ts` → `miniprogram/utils/core/text-layout.ts` - 提取字形路径逻辑到 `miniprogram/utils/core/glyph-path.ts` 2. **复制类型定义**: - `frontend/src/types/font.d.ts` → `miniprogram/typings/font.d.ts` 3. **平台适配层开发**: - `miniprogram/utils/mp/font-loader-mp.ts`: - `loadFontFromUrl()`:使用 `wx.downloadFile()` + `wx.getFileSystemManager()` - `miniprogram/utils/mp/storage-mp.ts`: - 封装 `wx.getStorageSync/setStorageSync` - 提供类型安全的 `getStorage`、`setStorage` 方法 4. **编写单元测试**: - `miniprogram/utils/core/__tests__/svg-builder.test.ts` - 测试用例:单字符、多字符、空字符串、特殊字符 **验收标准**: - 输入文本 + Font 对象可生成有效 SVG 字符串 - SVG 结构正确(包含 ``、``、`viewBox`) - 核心算法单测通过(覆盖率 > 80%) ### M3:预览链路(P0) **实施步骤**: 1. **主页布局**(`pages/index/`): - 文本输入区(`