{“content”:”---\nname: quotation-saas-deploy\ndescription: HS Design Quotation SaaS — Cloudflare Worker 部署与自定义域名绑定(wrangler + API Token)\n---\n\n# Quotation SaaS 部署指南\n\n## 环境信息\n- Account ID: f6b7326e471bbe3d1b0a0e2ba770f47d\n- Zone ID: 67e7fef748dbfdefb96a4fc5d682d2bc\n- D1: b1381cb9-de0b-4f2b-8122-59a71d05d0ca (quotation-saas)\n- Worker name: quotation-saas\n- Scoped Token (Workers Scripts Write): stored in ~/.hermes/credentials/cloudflare_tokens.json\n- Worker URL: https://quotation-saas.ida-czia.workers.dev\n- Custom Domain: quotation.hsdesign.biz (Workers Route via Zone-level API ✅)\n\n## 关键发现(Trial & Error)\n\n### 1. Zone-level Workers Routes API 绑定自定义域名(成功!)\n- POST /zones/{zone_id}/workers/routes — 添加路由(✅ 成功)\n- PUT /zones/{zone_id}/workers/routes/{id} — 更新路由\n- GET /zones/{zone_id}/workers/routes — 列出路由\n- Zone ID: 67e7fef748dbfdefb96a4fc5d682d2bc\n- 注意:/accounts/{id}/workers/routes/accounts/{id}/workers/domains 都 405,只能用 Zone-level\n\n### 2. Cloudflare Pages 拦截所有 *.hsdesign.biz 子域名\n- hsdesign.biz 在 Cloudflare Pages 上,所有 *.hsdesign.biz 都会被 Pages 接管(403)\n- 但 Zone-level Workers Routes 可以绑定子域名到 Worker(不需要 Custom Domain)\n- DNS 必须是 proxied(Cloudflare 才会拦截并走 Workers)\n- Route 绑定时 script 字段必须指定 Worker 名称,否则是空路由\n\n### 3. 添加自定义域名完整步骤(Zone-level Routes API)\n⚠️ 如果域名已在 Cloudflare Pages 有 Custom Domain:先在 Dashboard 删除 Pages 的 Custom Domain(删除时会一并删除 DNS 记录),然后按以下步骤重建。\n\n⚠️ 重要:删除 Pages Custom Domain 会自动删除该域名的 DNS A 记录!必须重新添加 DNS 记录才能让 Workers Route 生效。\n\nbash\n# Step 1: 添加 DNS 记录(必须是 proxied)\ncurl -s -X POST \"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: {global_api_key}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"quotation\",\"type\":\"A\",\"content\":\"192.0.2.1\",\"proxied\":true}'\n\n# Step 2: 添加 Route 绑定到 Worker\ncurl -s -X POST \"https://api.cloudflare.com/client/v4/zones/{zone_id}/workers/routes\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: {global_api_key}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"pattern\":\"quotation.hsdesign.biz\",\"script\":\"quotation-saas\"}'\n\n# 验证 Route 列表\ncurl -s \"https://api.cloudflare.com/client/v4/zones/{zone_id}/workers/routes\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: {global_api_key}\"\n\n\n### 3b. 完整迁移流程(从 Pages Custom Domain 切换到 Workers Route)\n1. 在 Dashboard 删除 Pages 项目的 Custom Domain(这会删除 DNS 记录)\n2. 立即重建 DNS 记录(必须 proxied):POST /zones/{zone}/dns_records\n3. Workers Route 已经存在会直接生效(如果之前绑定过)\n4. 用 --resolve 测试:curl -sI --resolve \"quotation.hsdesign.biz:443:172.67.xxx.xxx\" https://quotation.hsdesign.biz/\n5. 等 DNS 传播(通常 1-5 分钟),确认正常后删除 Pages 项目(如果不再需要)\n\n### 4. wrangler v4 Service Worker vs ES Module\n- Worker 若有 D1 binding([[d1_databases]] in wrangler.toml),必须是 ES Module 格式\n- ES Module 格式: export default { async fetch(request, env, ctx) { ... } }\n- Service Worker 格式(addEventListener)只在无 binding 时可用\n- 若用 REST API 查 D1(不用 binding),则不需要 ES Module\n\n### 5. wrangler.toml v4 注意事项\n- [[custom_domains]] 不支持放在顶级 toml(wrangler 报错 “Unexpected fields found”)\n- Custom Domain 必须通过 Dashboard 添加\n\n### 5b. Cloudflare Pages API 添加 Custom Domain 的 “Invalid TLD” 错误\n- POST /pages/projects/{project}/custom_domains 对子域名(如 quotation.hsdesign.biz)返回 {\"errors\": [{\"code\": 9007, \"message\": \"Invalid TLD\"}]}\n- 这是 Cloudflare Pages API 的验证问题,不影响 Workers Routes API\n- 解法:改用 Dashboard 操作 — Workers & Pages → 项目 → Custom Domains → Add custom domain(全自动,1分钟完成)\n- quotation.hsdesign.biz 已通过 Dashboard 成功绑定到 quotation-saas-frontend Pages 项目 ✅\n- 注意:如果之后想把域名从 Pages 切换到 Workers Route,必须先从 Dashboard 删除 Pages Custom Domain(会同时删 DNS 记录),然后重建 DNS + Route\n\n### 6. wrangler v4 要求 Scoped Token(不能用 Global API Key)\n- wrangler v4 的 deploy 命令只接受 Scoped Token,不接受 Global API Key\n- 使用 Global API Key 会报错:Invalid access token [code: 9109]\n- 解法 1:创建 Scoped Token(权限:Workers Scripts Write),存储后在 PC 上通过环境变量使用\n- 解法 2:用 Cloudflare REST API 直接上传 worker 脚本(绕过 wrangler)\n\n### 7. 用 Cloudflare REST API 直接上传 Worker 脚本\n当 wrangler 不可用时(无 scoped token),可以直接用 REST API 上传:\nbash\ncurl -s -X PUT \"https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/quotation-saas\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: {global_api_key}\" \\\n -H \"Content-Type: application/javascript\" \\\n --data-binary @worker.js\n\n关键:\n- 发送原始 JavaScript(不是 base64)\n- Content-Type: application/javascript\n- 上传后 Worker 立即生效(无需 route 重新绑定)\n\n### 8. “Email already registered” 的根因诊断\n如果 /auth/register 始终返回 “Email already registered”(任何邮箱):\n1. 最可能原因:D1 数据库缺少 accounts 表\n2. 检查方法:添加 debug endpoint 查 sqlite_master 表列表\n3. 注册逻辑里查询 D1 不报错但返回空结果时,误判为”已注册”\n\n### 6. Cloudflare D1 REST API 返回结构(关键 bug)\n- D1 API 返回格式:{ result: [{ results: [...], success: true, meta: {...} }], success: true }\n- result[0] 是查询结果包装器,result[0].results 才是实际行数组\n- 常见 bug: 直接用 result.lengthresult[0] 而没先提取 result[0].results\n- 正确做法: cfDbQuery 函数应该返回 json.result?.[0]?.results ?? []\n- 这导致所有查询都返回长度为 1 的数组(包装器),永远报 “already registered”\n\n### 7. 下载线上 Worker 源码(直接返回 JS)\n- GET /accounts/{id}/workers/scripts/{name} 直接返回原始 JavaScript 文本(不是 multipart,不是 base64)\n- 响应 Content-Type: application/javascript\n- 下载方法:\nbash\ncurl -s \"https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/quotation-saas\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: {global_api_key}\" > worker_downloaded.js\n\n- 文件内容即完整 worker 源码,可直接 grep/编辑后 PUT 上传\n\n### 8. 两个 D1 数据库\n- quotation-saas: b1381cb9-de0b-4f2b-8122-59a71d05d0ca ← Worker 实际用的\n- hs-design-quotes: 8d776216-e135-4c9f-b1bb-9669cb10bd85 ← 旧项目\n\n### 5. API Token 创建格式(正确格式)\npython\nbody = {\n \"name\": \"token-name\",\n \"policies\": [{\n \"effect\": \"allow\",\n \"resources\": {\"com.cloudflare.api.account.\" + account_id: \"*\"},\n \"permission_groups\": [{\"id\": \"e086da7e2179491d91ee5f35b3ca210a\"}] # Workers Scripts Write\n }]\n}\n# 注意: resource 必须是 \"*\" 不能是 {}(后者报 \"Empty or missing scopes\")\n\n\n## 标准部署流程\n\n### Step 1: 本地准备文件\n\nD:\\hermes\\quotation-worker\\\n quotation-worker.js # ES Module 格式\n wrangler.toml # minimal config\n\n\nwrangler.toml 内容(minimal):\ntoml\nname = \"quotation-saas\"\nmain = \"quotation-worker.js\"\ncompatibility_date = \"2024-01-01\"\naccount_id = \"f6b7326e471bbe3d1b0a0e2ba770f47d\"\n\n\n### Step 2: 复制到 PC 并 deploy\nbash\n# SCP 文件到 PC\nscp quotation-worker.js [email protected]:'C:\\Users\\sozo\\AppData\\Local\\Temp\\'\nscp wrangler.toml [email protected]:'C:\\Users\\sozo\\AppData\\Local\\Temp\\'\n\n\n然后在 PC 上执行(通过 SSH 或 PowerShell):\npowershell\n# 复制到项目目录\ncopy C:\\Users\\sozo\\AppData\\Local\\Temp\\quotation-worker.js D:\\hermes\\quotation-worker\\\ncopy C:\\Users\\sozo\\AppData\\Local\\Temp\\wrangler.toml D:\\hermes\\quotation-worker\\\n\n# 设置 token 并 deploy\n$env:CLOUDFLARE_API_TOKEN = '<scoped-token-from-~/.hermes/credentials/cloudflare_tokens.json>'\ncd D:\\hermes\\quotation-worker\nwrangler deploy\n\n\n### Step 3: 添加 Custom Domain(通过 Zone-level Routes API)\nbash\n# 添加 DNS\ncurl -s -X POST \"https://api.cloudflare.com/client/v4/zones/67e7fef748dbfdefb96a4fc5d682d2bc/dns_records\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: cfk_r8ECCUq8K0nZjyuJGkbWEelbYX4r3NxyBnuqK5zj38f26a11\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"qs\",\"type\":\"A\",\"content\":\"192.0.2.1\",\"proxied\":true}'\n\n# 绑定 Route\ncurl -s -X POST \"https://api.cloudflare.com/client/v4/zones/67e7fef748dbfdefb96a4fc5d682d2bc/workers/routes\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: cfk_r8ECCUq8K0nZjyuJGkbWEelbYX4r3NxyBnuqK5zj38f26a11\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"pattern\":\"qs.hsdesign.biz\",\"script\":\"quotation-saas\"}'\n\n\n## 验证 Worker 是否运行\nbash\n# 从 PC 执行(Termux 会 403 因为 bot 保护)\ncurl https://qs.hsdesign.biz/api/health\n\n\n## Quotation SaaS Worker 端点\n- GET / — 首页(登录/注册)\n- GET /login — 登录页\n- GET /register — 注册页\n- GET /dashboard — 仪表盘(需认证)\n- GET /api/health — 健康检查\n- POST /api/auth/register — 邮箱注册(返回 session_token)\n- POST /api/auth/login — 邮箱登录(返回 session_token)\n- GET /api/account — 获取账户信息(需 Bearer token)\n- PATCH /api/account — 更新账户(logo/颜色/公司)\n- GET /api/quotations — 列表(需 Bearer token)\n- POST /api/quotations — 创建(需 Bearer token)\n- GET /api/quotations/:id — 详情(需 Bearer token)\n- PUT /api/quotations/:id — 更新(需 Bearer token)\n- DELETE /api/quotations/:id — 删除(需 Bearer token)\n\n### 9. Pages Custom Domain 拦截 Workers Route(关键陷阱!)\n当一个子域名同时被以下两者绑定时:\n- Cloudflare Pages 项目的 Custom Domains(通过 Dashboard 添加)\n- Zone-level Workers Routes(通过 API 绑定)\n\nPages 优先,所有请求被 Pages 接管并返回 404,永远不会到达 Worker。\n\n症状:curl https://quotation.hsdesign.biz 返回 {\"error\":\"Not found\"}(Pages 的 JSON 404),而不是 Worker 的响应。\n\n解法:必须从 Pages Dashboard 手动删除 Custom Domain,Workers Route 才能生效。Pages API(/pages/projects/...)在某些账户上返回 No route for that URI,只能通过 Dashboard 移除。\n\n### 10. 给 Worker 加首页的最简方法\n不需要 wrangler,直接用 REST API 上传带 HTML 首页的新版 Worker:\nbash\ncurl -s -X PUT \"https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/quotation-saas\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: {global_api_key}\" \\\n -H \"Content-Type: application/javascript\" \\\n --data-binary @worker_with_landing.js\n\n\n### 10. 给 Worker 加静态首页(Landing Page)\nWorker 只有 API 没有 HTML 页面时,根路径 / 会 404。在 Worker 代码里加一个 HTML 响应即可:\n\njavascript\n// 在 handleRequest 函数开头加\nif (path === '/' && request.method === 'GET') {\n const html = `<!DOCTYPE html><html lang=\"zh\">...`;\n return new Response(html, {\n headers: { 'Content-Type': 'text/html; charset=utf-8' }\n });\n}\n\n\n上传方式(不需要 wrangler,直接用 REST API):\nbash\n# 直接 PUT 上传 JS 文件到 Worker\ncurl -s -X PUT \"https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/quotation-saas\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: {global_api_key}\" \\\n -H \"Content-Type: application/javascript\" \\\n --data-binary @worker_new.js\n\n返回 { \"success\": true } 即生效(几秒内)。\n\n### 11. Cloudflare Pages 会拦截所有子域名\n*.hsdesign.biz 如果在 Cloudflare Pages 上有 Custom Domain 配置,Cloudflare 会优先让 Pages 处理请求,Workers Route 不会生效。症状:Workers Route 已绑定,但域名始终 404 或返回 Pages 错误。\n\n解法:删除 Pages 的 Custom Domain(DNS 记录会一并删除),然后重建 DNS + Route(如 Section 3b)。\n\n### 12. Termux DNS 缓存问题\nCloudflare DNS 已传播(可查 https://cloudflare-dns.com/dns-query?name=...&type=A),但 curl 仍报 “Could not resolve host”:Termux/Android 有本地 DNS 负缓存(negative cache)。用 --resolve 绕过:\nbash\ncurl -sI --resolve \"quotation.hsdesign.biz:443:172.67.154.250\" https://quotation.hsdesign.biz/\n\n\n### 9. Pages Custom Domain 删除后 DNS 记录也被删除(重要!)\n- 通过 Dashboard 删除 Pages Custom Domain 时,关联的 DNS 记录会自动被删\n- Zone-level Workers Route 还在,但域名无法解析\n- 解法:手动重建 DNS A 记录(proxied),Workers Route 立即生效\nbash\ncurl -s -X POST \"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records\" \\\n -H \"X-Auth-Email: ...\" -H \"X-Auth-Key: ...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"subdomain\",\"type\":\"A\",\"content\":\"192.0.2.1\",\"proxied\":true}'\n\n\n### 10. Workers Secrets 无法通过 REST API 设置\n- PUT /accounts/{id}/workers/scripts/{name}/secrets/{var} → 405 Method Not Allowed\n- POST /accounts/{id}/workers/scripts/{name}/secrets → 405 Method Not Allowed\n- 解法:在代码里硬编码 API Key(Cloudflare 内部传输,源码对用户不可见)\njavascript\nconst CF_KEY = 'cfk_r8...';\n// 在 cfDbQuery 中直接使用 CF_KEY 而非 env.CF_API_KEY\n\n\n### 11. env.XXX 变量未定义会导致 Worker 500 错误\n- 如果 env.CF_API_KEY 未在 Worker Settings 中配置,访问时直接 Cannot read properties of undefined\n- 所有使用 env.XXX 的代码路径都会崩溃\n- 检查方法:访问任何触发 D1 查询的 endpoint 观察错误信息\n- 解法:要么通过 wrangler secret put 配置,要么硬编码(见上一条)\n\n### 13. Google OAuth redirect_uri_mismatch 错误\n- 症状:点击 Google 登录后显示 “Access blocked: This app’s request is invalid” → Error 400: redirect_uri_mismatch\n- 根因:Worker 代码里的 redirect URI 是 https://quotation.hsdesign.biz/api/auth/callback,但 Google Cloud Console 只加了 https://quotation.hsdesign.biz/callback\n- 解法:Google Cloud Console → APIs & Services → Credentials → Web client → Authorized redirect URIs\n - 加 https://quotation.hsdesign.biz/api/auth/callback(必须与代码里 REDIRECT_URI 完全一致)\n - 原来的 /callback 路径不对,删掉\n- 验证:改完后重新点击 Google 登录,应该直接进入授权页面\n- quotation.hsdesign.biz 已成功配置 Google OAuth 登录 ✅\n\n### 14. Google OAuth 登录后循环回到登录页(redirect_uri path 不匹配)\n- 症状:Google 登录完成,但 callback 处理后页面还是显示 Login Required\n- 根因:Worker OAuth callback redirect 到 /app?token=xxx,但前端 callback handler 只检查 /auth/callback 路径,导致 token 从未被提取\n- 修复:前端 callback handler 增加 /app 路径判断:\n javascript\n if (url.pathname === '/auth/callback' || url.pathname === '/app') {\n \n- 教训:Worker 端 redirect path 和前端 router 必须保持同步\n\n### 15. 本地文件可能与线上不同步(关键!)\n- 症状PUT /workers/scripts/{name} 上传后报错 Unexpected token ')' 或其他语法错误,但本地文件看起来没问题\n- 根因:本地 Worker 源文件与 Cloudflare 上实际部署的版本不同(如本地 1535 行,线上 1629 行)。编辑本地旧版本后再上传会覆盖线上正确版本,导致故障\n- 解法每次编辑 Worker 前,先从 Cloudflare 下载实际版本:\n bash\n curl -s \"https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/{script_name}\" \\\n -H \"X-Auth-Email: ...\" -H \"X-Auth-Key: ...\" > worker-live.js\n \n - 返回内容是原始 JavaScript 文本Content-Type: application/javascript),可直接编辑后 PUT 上传\n - 上传后 Cloudflare 会自动格式化代码(换行符变 \\n),这是正常的,不是错误\n- 教训:永远不要假设本地文件是最新的。线上版本才是真理。\n\n### 14b. 本地文件可能有语法错误(比预期更严重)\n- 本次教训:下载后发现本地文件不仅行数不同,还有语法问题(</html>;后没有换行符,导致拼出的字符串字面量异常)\n- **验证方法**:下载后立即检查行数和文件大小是否与预期接近:\n - 预期:1629 行,~68KB\n - 本地损坏版:1535 行,~64KB\n - 如果差几百行,肯定有问题\n- **最佳实践**:\n 1.curl -sI 看线上大小\n 2. 下载后wc -l 对比\n 3. 确认后备用原版:cp worker-live.js worker-live-orig.js\n 4. 再开始编辑\n\n### 15. 模板字符串关闭模式的真实格式\n- **根因**:这个 Worker 里 HTML 模板的关闭模式是 );``(反引号+分号+右括号),而不是 ); 或其他变体\n- 症状:搜索 </html>);); 找不到匹配(0 个结果),但文件明明能正常运行\n- 解法:用二进制模式读取文件,检查实际字节。例如 b'</html>;‘=3c2f68746d6c3e603b\n- 本次排查发现线上版本和本地版本模板关闭标签格式一致,都是 );\n\n### 16. 删除 Pages Custom Domain 会删除 DNS 记录\n- 通过 Dashboard 删除 Pages Custom Domain 时,Cloudflare 会自动删除该子域名的 DNS A 记录。Workers Route 依赖 DNS 才能生效,必须手动重建 DNS 记录。\n\n### 17. Workers Secrets 无法通过 REST API 设置\n- PUT /accounts/{id}/workers/scripts/{name}/secrets/{var} → 405 Method Not Allowed\n- 解法:在代码里硬编码 API Key(Cloudflare 内部传输,源码对用户不可见)\n\n### 12. Pages 和 Workers Route 共存时的请求拦截问题\n- Pages 项目绑定的 Custom Domain 会先于 Zone-level Workers Route 拦截请求\n- 表现为:Workers Route 已设置且 DNS 正常,但所有请求返回 Pages 404 或 Worker 不响应\n- 诊断:curl —resolve 绑定 IP 直接测试,确认是哪个服务在响应\n- 解法:从 Pages Dashboard 删除 Custom Domain,让 Workers Route 接管\n\n### 16. 调试 Workers 错误:Error 1101 vs 401\n- Error 1101 = Workers 运行时错误(JavaScript exception),通常是 SQL 字段名错误(如 project vs proj)\n- Error 401 Unauthorized = 认证失败(token 无效或过期),不是代码问题\n- 诊断流程:先用 /api/health 确认 Worker 在跑,再查具体 endpoint\n- 本次修复:列表 SELECT 用 project 但 D1 schema 是 proj,导致 INSERT/UPDATE 成功但列表 1101\n\n### 17. 正确 API 路径(auth endpoints)\n- 注册:POST /api/auth/register(不是 /auth/register)\n- 登录:POST /api/auth/login(不是 /auth/login)\n- 列表:GET /api/quotations(需要 Authorization: Bearer token)\n- Token 格式:session_token(UUID),存储在 accounts.session_token 字段\n- 认证方式:Authorization: Bearer <token> header 或 session cookie\n- 注册成功返回:{ success: true, token: \"<uuid>\", account: {...} }\n\n### 18. SQL 字段名一致性(quotations 表)\n- quotations 表的列名是 proj(不是 project)\n- INSERT 已正确用 proj,UPDATE 也用 proj\n- 容易遗漏:列表 SELECT 的字段映射也必须用 proj\n- 列表 SQL 错误会导致 Error 1101,但单条 GET 可能正常(如果 SELECT 没查那个字段)\n- JakeBilu/quotation-saas-frontend repo 可能不存在或无法访问(gh auth 未登录,git ls-remote 失败)\n- Cloudflare Pages 项目通过 GitHub 连接部署,但没有 GitHub token 从 Termux 推送\n- 临时解法:在 Termux 本地构建 HTML 文件,通过 Cloudflare Pages API 上传:\n bash\n # 打包静态文件\n zip -r dist.zip index.html static/\n # 用 Cloudflare Pages API 上传部署\n curl -X POST \"https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/quotation-saas-frontend/deployments\" \\\n -H \"X-Auth-Email: ...\" -H \"X-Auth-Key: ...\" \\\n -F \"[email protected]\" \\\n -F \"metadata={\\\"branch\\\":\\\"production\\\"}\"\n \n- 长期解法:在 PC 上创建/克隆 JakeBilu/quotation-saas-frontend repo,通过 GitHub 推送触发 Cloudflare Pages 自动部署\n\n### 19. Worker 服务器端不能使用 DOM API(Error 1101 常见原因)\nCloudflare Workers 运行在服务器端,没有 DOM。以下 API 不可用:\n- document.createElementdocument.body.appendChilddocument.head\n- document.getElementByIddocument.querySelector\n- document.cookie(在 Worker 里读不到浏览器 cookie,要用 getCookie() 辅助函数)\n- window.locationMutationObserver\n\n症状:某个路由(如 /ads.js)返回 {\"error\":\"...\"} 或 Error 1101,但 /api/health 正常。\n\n解法:把 DOM 操作代码作为 JavaScript 字符串返回给浏览器执行:\njavascript\nif (path === '/ads.js') {\n return new Response(\n`window.__adLoaded = true;\n(function() {\n var s = document.createElement('script');\n s.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js';\n document.head.appendChild(s);\n // ...\n})();`,\n { headers: { 'Content-Type': 'application/javascript' } }\n );\n}\n\n\n### 20. Dashboard 路由必须指向正确的 Cloudflare Pages 项目\n- 正确: https://quotation-saas-frontend.pages.dev/ — 真正的 SaaS 前端(title “Quotation SaaS - HS Design”)\n- 错误: https://hsdesign-7ni.pages.dev/HS_Design_Quotation — Staff Portal(员工内部工具,不是给客户的)\n- 两个 Pages 项目域名不同,混淆会导致 SaaS 用户看到内部工具界面\n\n### 21. 两个 Cloudflare Pages 项目不要混淆\n- quotation-saas-frontend.pages.dev → SaaS 客户用(有登录/注册/Google OAuth)\n- hsdesign-7ni.pages.dev → Staff Portal(内部员工工具,有 password gate)\n\n### 22. SaaS 前端 token key 是 qs_token,不是 token\n- Staff 版可能用 tokensession_token\n- SaaS 版 quotation-saas-frontendlocalStorage.getItem('qs_token')\n- 这影响了 /auth/login/auth/registerlocalStorage.setItem('token', ...) 是否需要同步更新\n- 方案:把 /dashboard 路由改为 await fetch('https://hsdesign-7ni.pages.dev/HS_Design_Quotation'),不再内嵌大段 HTML\n- 优点:Worker 体积从 1629 行减到 742 行;编辑 Dashboard UI 不需要改 Worker 代码\n- 缺点:多一次 fetch 延迟(~100-200ms);依赖 Pages 在线\n- 实现:\n javascript\n if (path === '/dashboard') {\n const htmlUrl = 'https://hsdesign-7ni.pages.dev/HS_Design_Quotation';\n const htmlRes = await fetch(htmlUrl);\n const html = await htmlRes.text();\n return new Response(html, {\n headers: { 'Content-Type': 'text/html; charset=utf-8' }\n });\n }\n \n- 前提:Pages 必须返回正确的 Content-Type: text/html(Cloudflare Pages 默认自动识别)\n\n## Pitfalls\n1. Dashboard Triggers tab 没有 Custom Domains 选项 — 免费/Standard 账户限制,只能用 Zone-level Routes API\n2. Route 的 script 字段必须指定 — 不指定则是空路由,Worker 不会响应\n3. DNS 必须是 proxied — 否则 Cloudflare 不会拦截请求,Route 不会生效\n4. Termux 访问 workers.dev — 返回 403 Error 1010(Cloudflare bot 保护),用 PC curl 测试\n5. Session 0 — SSH 到 PC 的 Playwright 浏览器无法截图真实桌面(Session 0 隔离)\n6. API 返回 multipart/form-data — GET single worker script 返回的是脚本源码(不是 JSON)\n7. D1 cfDbQuery 返回值 — cfDbQuery 必须返回 result[0].results(行数组),不是完整 D1 JSON 对象,否则所有查询都报 “already registered”\n8. 两个 D1 数据库quotation-saas (b1381cb9) 和 hs-design-quotes (8d776216),不要搞混\n9. wrangler deploy 需要 Scoped Token — Global API Key 对 wrangler v4 无效(报 code: 10021)\n10. env.CF_API_KEY 在 Worker 里是 undefined — Workers Secrets 必须通过 Dashboard 或 wrangler secret put 创建,REST API 的 PUT /accounts/{id}/workers/scripts/{name}/secrets 返回 405。临时解法:在 Worker 代码里直接写死 API Key\n11. 删除 Pages Custom Domain 会删除 DNS 记录 — 必须手动重建 DNS 记录(A record, proxied)\n12. cfDbQuery 必须包含 X-Auth-Email header — 只有 X-Auth-Key 不够,缺少 Email header 会 403\n13. Google OAuth redirect_uri 必须与代码完全一致 — Worker 代码里 REDIRECT_URI/api/auth/callback,Google Console 必须加这个完整路径,不能只加 /callback,否则 redirect_uri_mismatch\n14. SQLite UNIQUE 约束空字符串问题 — 列有 UNIQUE 约束时,空字符串 '' 不能重复(但 NULL 可以有多个)。INSERT 时 referral_code 应传 null 而非 '',否则第二个账号登录就报 UNIQUE constraint failed\n\n### 14. SQLite UNIQUE 约束:空字符串 ≠ NULL\n- 症状UNIQUE constraint failed: accounts.referral_code(用 Google 登录后报错)\n- 根因:referral_code 列有 UNIQUE 约束,代码 INSERT 时传入空字符串 '',但 '' 在 UNIQUE 约束下不能重复(NULL 可以有多个)\n- 修复:INSERT 时 referral_code 传 null 而不是 ''\n sql\n -- 错误(假设其他账号已有 '')\n INSERT INTO accounts (..., referral_code, ...) VALUES (..., '', ...)\n -- 正确\n INSERT INTO accounts (..., referral_code, ...) VALUES (..., null, ...)\n \n- 两处需修复:Google OAuth callback(line ~364)和邮箱注册(line ~411)\n\n### 15. Playwright 测试脚本模板\n- 路径:~/.hermes/scripts/browser_test_hsdesign.py\n- PC 执行:ssh [email protected] \"cmd /c set PYTHONIOENCODING=utf-8 && set PYTHONLEGACYWINDOWSSTDIO=utf-8 && python C:\\Users\\sozo\\AppData\\Local\\Temp\\script.py\"\n- headless=False 才有独立浏览器 session(不会复用 PC Chrome 已登录状态)\n\n## cfDbQuery 完整实现\njavascript\nconst CF_KEY = 'cfk_r8ECCUq8K0nZjyuJGkbWEelbYX4r3NxyBnuqK5zj38f26a11';\n\nasync function cfDbQuery(sql, params = []) {\n const res = await fetch(\n `https://api.cloudflare.com/client/v4/accounts/f6b7326e471bbe3d1b0a0e2ba770f47d/d1/database/b1381cb9-de0b-4f2b-8122-59a71d05d0ca/query`,\n {\n method: 'POST',\n headers: {\n 'X-Auth-Email': '[email protected]', // 必须!\n 'X-Auth-Key': CF_KEY,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ sql, params }),\n }\n );\n const d = await res.json();\n if (!d.success) throw new Error(d.errors?.[0]?.message || 'D1 error');\n return d.result?.[0]?.results || []; // result[0].results 是行数组\n}\n\n11. 删除 Pages Custom Domain 会删除 DNS 记录 — 在 Dashboard 删除 Pages Custom Domain 时,Cloudflare 会自动删除该子域名的 DNS A 记录。Workers Route 依赖 DNS 才能生效,必须手动重建 DNS 记录。\n12. Workers Secrets REST API 不可用 — 无法用 REST API 设置 Worker 环境变量,必须用 wrangler secret put 或在代码里硬编码\n”}