Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s
# Conflicts: # docs/index.html # server/cmd/server/main.go
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pangolin CI/CD 全流程 · 设计方案(#30)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card h3{margin-top:0;color:var(--fg)}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>Pangolin CI/CD 全流程 · 设计方案</h1>
|
||||
<p class="sub">#30 · 2026-07-05 · <span class="tag warn">设计定稿待审</span> · 范围 A~F(排除 iOS、备份#26、TLS#25)· 真相源 <code>docs/superpowers/specs/2026-07-05-cicd-design.md</code></p>
|
||||
|
||||
<div class="lead">
|
||||
<b>目标:</b>tag 触发的 <b>编译 → 测试 → 发版(Gitea release)→ 部署</b> 全自动。参考 jiu 的
|
||||
<code>.gitea/workflows</code> + <code>scripts/ci/*.sh</code> 结构,适配 pangolin 的部署目标(pangolin1 /
|
||||
pangolin.yanmeiai.com)与多端产物。现状:仅 <code>ci.yml</code> 校验无部署,服务端手动部署、官网未部署、下载死链。
|
||||
</div>
|
||||
|
||||
<h2>1. 范围</h2>
|
||||
<table>
|
||||
<tr><th>子块</th><th>内容</th></tr>
|
||||
<tr><td><b>A 基座</b></td><td><code>scripts/ci/*</code>(env/provision/test/release/notify/lib-forgejo)+ checks 保留</td></tr>
|
||||
<tr><td><b>B 官网</b></td><td>Astro 构建 → 部署 <code>pangolin.yanmeiai.com</code></td></tr>
|
||||
<tr><td><b>C 服务端</b></td><td>交叉编译 server/agent/migrate → release → ssh pangolin1(备份→migrate→换二进制→重启→健康检查)</td></tr>
|
||||
<tr><td><b>D Android</b></td><td>apk(arm64,release keystore 签名)→ release 资产</td></tr>
|
||||
<tr><td><b>E macOS</b></td><td>公证 dmg(Developer ID + notarytool)→ release 资产</td></tr>
|
||||
<tr><td><b>F Windows</b></td><td>exe/installer(Inno Setup)→ release 资产</td></tr>
|
||||
</table>
|
||||
<p class="small"><b>排除:</b>iOS(G,未来)、SQLite 备份/容灾(#26)、控制面 TLS(#25)。</p>
|
||||
|
||||
<h2>2. 已锁定决策</h2>
|
||||
<table>
|
||||
<tr><th>维度</th><th>决定</th><th>理由</th></tr>
|
||||
<tr><td>runner</td><td>nas=官网+服务端(容器化)· mac=Android+macOS · windows=Windows</td><td>nas 常在线且 Astro/Go 轻量(非 Flutter Web);mac/windows 做必须它们的活</td></tr>
|
||||
<tr><td>触发</td><td>tag <code>site-v*</code> / <code>server-v*</code> / <code>client-v*</code> + <code>manual.yml</code> 手动派发</td><td>同 jiu,发版即部署,可手动重放</td></tr>
|
||||
<tr><td>macOS 签名</td><td>mac 自动 Developer ID 签名 + notarytool 公证 + staple</td><td>凭据入 secret(见 §6)</td></tr>
|
||||
<tr><td>Android 签名</td><td>正式 release keystore</td><td>app 级专属签名身份</td></tr>
|
||||
<tr><td>下载链接</td><td>官网 href 指向 Gitea release 稳定资产 URL</td><td>发版即更新,见 §5</td></tr>
|
||||
<tr><td>镜像</td><td>goproxy.cn / flutter-io.cn</td><td>国内网络</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>3. 架构</h2>
|
||||
<h3>3.1 共享基座 <code>scripts/ci/</code>(镜像 jiu)</h3>
|
||||
<ul>
|
||||
<li><code>_env.sh</code> —— 公共环境(镜像源、路径、版本号解析 <code>${tag#prefix-v}</code>)</li>
|
||||
<li><code>lib-forgejo.sh</code> —— release 建/查 + 资产上传(用 <code>FORGEJO_TOKEN</code>)</li>
|
||||
<li><code>provision-mac.sh</code> —— mac 幂等装 flutter / xcode-select / gomobile / NDK+JDK17</li>
|
||||
<li><code>test.sh</code>、<code>notify.sh</code>、<code>compile-*.sh</code>、<code>deploy-*.sh</code>、<code>release-*.sh</code></li>
|
||||
</ul>
|
||||
<p class="small">每个 <code>compile-*</code> 封装该端已验证的构建命令(Android 走 <code>build-libbox.sh android</code> +
|
||||
<code>flutter build apk --split-per-abi</code>;macOS 走 Developer ID 签名 + <code>notarytool submit --wait</code> +
|
||||
<code>stapler</code>)。工作流只调脚本,逻辑在脚本里、便于本地复现。</p>
|
||||
|
||||
<h3>3.2 工作流 <code>.gitea/workflows/</code></h3>
|
||||
<table>
|
||||
<tr><th>工作流</th><th>触发</th><th>runner</th><th>步骤</th></tr>
|
||||
<tr><td><code>checks.yml</code>(现 ci.yml)</td><td>push 分支</td><td>nas</td><td>保留:shellcheck / openapi / redline / flutter analyze+test / go test</td></tr>
|
||||
<tr><td><code>deploy-site.yml</code></td><td><code>site-v*</code></td><td>nas</td><td><code>node:20</code> 构建 Astro(注入 SITE_URL)→ <code>deploy-site.sh</code></td></tr>
|
||||
<tr><td><code>deploy-server.yml</code></td><td><code>server-v*</code></td><td>nas</td><td><code>golang:1.25</code> 交叉编译 → test → release → <code>deploy-server.sh</code></td></tr>
|
||||
<tr><td><code>build-android.yml</code></td><td><code>client-v*</code></td><td>mac</td><td>provision → <code>compile-android.sh</code>(签名 apk)→ release</td></tr>
|
||||
<tr><td><code>build-macos.yml</code></td><td><code>client-v*</code></td><td>mac</td><td>provision → <code>compile-macos.sh</code>(签名+公证 dmg)→ release</td></tr>
|
||||
<tr><td><code>build-windows.yml</code></td><td><code>client-v*</code></td><td>windows</td><td><code>compile-windows.sh</code>(exe/installer)→ release</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>3.3 服务端部署(固化 F3/F4 手动那套,带回滚)</h3>
|
||||
<div class="card">
|
||||
<ol>
|
||||
<li>scp <code>pangolin-{server,agent,migrate}</code> 到 pangolin1 <code>/tmp</code></li>
|
||||
<li><code>systemctl stop pangolin-server</code></li>
|
||||
<li><code>wal_checkpoint(TRUNCATE)</code> → <code>cp</code> 备份 <code>pangolin.db.bak-pre-<tag></code></li>
|
||||
<li><code>pangolin-migrate up</code>(pangolin 用户);<b>失败即恢复备份 + 重启旧 server + 退出非零</b></li>
|
||||
<li><code>install</code> 新二进制到 <code>/usr/local/bin</code>(旧的备份为 <code>.bak-<tag></code>)</li>
|
||||
<li><code>systemctl start</code> + <code>/healthz</code> 健康检查;agent 随连接自恢复</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>4. 官网部署 —— Cloudflare Pages</h2>
|
||||
<div class="card" style="border-left:3px solid var(--warn)">
|
||||
<b>架构变更(2026-07-06 实施):</b>原计划 rsync 到 pangolin1 的 nginx。但节点 <code>:443</code> 被 sing-box(VPN 数据面)占用,而 CF 免费套餐 proxied 回源只能打 :80/:443、改回源端口需 Enterprise —— 同机同 IP 上官网 HTTPS 与 VPN 无法共存。<b>故官网改由 Cloudflare Pages 托管</b>:纯静态、全程 HTTPS、<code>_headers</code>/CSP 原生生效、不落 VPS,从根上无 :443 冲突,也不拖累 VPN 机器。<b>已上线</b> <code>https://pangolin.yanmeiai.com</code>。
|
||||
</div>
|
||||
<p>Astro <code>npm ci && npm run build</code>(<code>SITE_URL=https://pangolin.yanmeiai.com</code>)→ <code>dist/</code>
|
||||
经 <code>npx wrangler pages deploy</code> 发布到 Pages 项目 <b>pangolin-site</b>(自定义域 <code>pangolin.yanmeiai.com</code>,CNAME → <code>pangolin-site.pages.dev</code>,proxied)。需 secret <code>CLOUDFLARE_API_TOKEN</code>(带 Account>Pages>Edit)+ <code>CLOUDFLARE_ACCOUNT_ID</code>;deploy 步骤在 <code>node:20</code> 容器内跑 wrangler。灾备:产物仍纯静态,可另 rsync 到镜像。</p>
|
||||
|
||||
<h2>5. 下载链接闭环(30A)</h2>
|
||||
<p><code>web/website/src/config/site.ts</code> 增 <code>downloads:{ android, macos, windows }</code>,值为 Gitea release
|
||||
稳定 latest 资产 URL(Forgejo 支持 <code>…/releases/latest/download/<asset></code> 则直用;否则构建期用
|
||||
<code>FORGEJO_TOKEN</code> 查最新 <code>client-v*</code> 版本烘焙进 href)。<code>Download.astro</code> 各平台按钮读
|
||||
<code>SITE.downloads.<platform></code>。客户端发版后官网重部署即刷新(或 build-* 完成触发 deploy-site)。</p>
|
||||
|
||||
<h2>6. 密钥与作用域(solo / wangjia,命名对齐 jiu 以共用)</h2>
|
||||
<table>
|
||||
<tr><th>Secret</th><th>作用域</th><th>说明</th></tr>
|
||||
<tr><td><code>FORGEJO_TOKEN</code> / <code>FORGEJO_URL</code></td><td>账户级</td><td>建 release + 传产物,jiu 复用</td></tr>
|
||||
<tr><td><code>MACOS_DEVELOPER_ID_CERT_P12_BASE64</code> / <code>MACOS_DEVELOPER_ID_CERT_PASSWORD</code></td><td>账户级</td><td>Developer ID 证书(账号级),与 jiu 共用;续期改一处</td></tr>
|
||||
<tr><td><code>APPSTORE_API_KEY_P8_BASE64</code> / <code>APPSTORE_API_KEY_ID</code> / <code>APPSTORE_API_ISSUER_ID</code></td><td>账户级</td><td>公证 API key(KEY_ID=<code>3PZTHR8YMJ</code>),与 jiu 同一把</td></tr>
|
||||
<tr><td><code>DEPLOY_SSH_KEY</code></td><td>pangolin 仓库级</td><td>授权到 pangolin1,最小权限</td></tr>
|
||||
<tr><td><code>ANDROID_KEYSTORE_BASE64</code> / <code>ANDROID_KEYSTORE_PASSWORD</code> / <code>ANDROID_KEY_ALIAS</code> / <code>ANDROID_KEY_PASSWORD</code></td><td>pangolin 仓库级</td><td>Android app 级专属签名(<b>pangolin 自己的 keystore,不复用 jiu</b>)</td></tr>
|
||||
<tr><td><code>MACOS_APP_PROVISION_PROFILE_BASE64</code> / <code>MACOS_SYSEXT_PROVISION_PROFILE_BASE64</code></td><td>pangolin 仓库级</td><td>主 app + PacketTunnel sysext 描述文件(pangolin bundle 专属)</td></tr>
|
||||
</table>
|
||||
<p class="small">命名对齐 jiu(<code>MACOS_*</code>/<code>APPSTORE_*</code>/<code>ANDROID_*</code>):<b>Apple 那套放账户级 → jiu/pangolin 共用一份</b>,compile-macos 脚本可复用 jiu 的;Android keystore 虽同命名规范但<b>各 app 独立、不共享</b>。工作流用 <code>secrets.XXX</code> 引用,作用域对写法透明。</p>
|
||||
|
||||
<h2>7. 实现顺序</h2>
|
||||
<p>范围虽 A~F,按风险/依赖递增落地,每阶段独立可发、独立验收:</p>
|
||||
<ol>
|
||||
<li><b>A 基座</b> + <code>checks</code> 迁移(抽 <code>scripts/ci</code> 骨架)</li>
|
||||
<li><b>B 官网</b>(最简,验证 release/deploy 骨架)</li>
|
||||
<li><b>C 服务端</b>(固化手动部署)</li>
|
||||
<li><b>D Android</b>(解锁下载链接;需 keystore 就绪 + gradle 接签名)</li>
|
||||
<li><b>E macOS</b>(最复杂:证书 + 2 描述文件 + 公证)</li>
|
||||
<li><b>F Windows</b>(windows runner + Inno Setup)</li>
|
||||
</ol>
|
||||
|
||||
<h2>8. 验证</h2>
|
||||
<ul>
|
||||
<li>每条流水线先 <code>workflow_dispatch</code> 手动跑通、核对产物/部署,再依赖 tag。</li>
|
||||
<li>服务端:<code>server-v*</code> → migrate 版本 + <code>/healthz</code> + 行数守恒(同 F3 核对)。</li>
|
||||
<li>官网:<code>site-v*</code> → 站点可访问 + canonical 正确 + redline 扫描。</li>
|
||||
<li>客户端:release 资产可下载安装(Android 侧载 / macOS <code>spctl</code> / Windows 安装)。</li>
|
||||
<li>下载链接:官网按钮落到最新 release 资产。</li>
|
||||
</ul>
|
||||
|
||||
<h2>9. 风险与缓解</h2>
|
||||
<table>
|
||||
<tr><th>风险</th><th>缓解</th></tr>
|
||||
<tr><td>nas 内存(3.8G)构建 OOM</td><td>容器化单 job、Astro/Go 轻量;必要时该端移 mac</td></tr>
|
||||
<tr><td>migrate 在生产出错</td><td>部署前备份 + 失败自动回滚(§3.3),已在 F3/F4 手动验证</td></tr>
|
||||
<tr><td>Android keystore 丢失</td><td>存 Bitwarden(文件+密码);终身签名身份</td></tr>
|
||||
<tr><td>macOS 公证凭据泄露</td><td>账户级 secret,不落盘;<code>.p8</code>/<code>.p12</code> 用完即删临时文件</td></tr>
|
||||
<tr><td>发版后下载链接不刷新</td><td>build-* 成功触发 deploy-site 重烘焙,或用 latest-download 稳定 URL</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>10. 不在本方案</h2>
|
||||
<p class="small">iOS 流水线(G)、SQLite 备份/容灾(#26)、TLS(#25)、上架 Play、Windows 代码签名(先不签)。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pangolin CI/CD 实现计划(#30)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:24px 0 8px;color:var(--fg)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
|
||||
.card h3{margin-top:0;color:var(--accent2)}
|
||||
.files{font-family:var(--mono);font-size:12px;color:var(--fg2);margin:6px 0 10px}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
ul,ol{padding-left:22px;margin:8px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.phase{font-size:19px;margin:40px 0 6px;color:var(--accent);font-weight:700}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>Pangolin CI/CD 实现计划</h1>
|
||||
<p class="sub">#30 · 2026-07-05 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(带 checkbox)· 设计 <a href="cicd-design.html">cicd-design.html</a></p>
|
||||
|
||||
<div class="lead">
|
||||
<b>目标:</b>tag 触发的「编译 → 测试 → 发版(Gitea release)→ 部署」全自动。镜像 jiu 的
|
||||
<code>scripts/ci/*.sh</code>(逻辑)+ <code>.gitea/workflows/*.yml</code>(编排)。runner:nas(官网+服务端,容器化
|
||||
<code>node:20</code>/<code>golang:1.25</code>)、mac(Android+macOS)、windows(Windows)。
|
||||
<b>CI「测试」= <code>workflow_dispatch</code> 手动触发跑一遍 + 观察产物/部署</b>(非经典单元 TDD)。
|
||||
</div>
|
||||
|
||||
<h2>全局约束</h2>
|
||||
<ul>
|
||||
<li>参考:jiu 的 <code>~/code/jiu/.gitea/workflows/*</code> + <code>~/code/jiu/scripts/ci/*</code>(proven,copy+adapt)。</li>
|
||||
<li>nas 每 job <code>docker run</code> 官方镜像(<code>node:20</code>/<code>golang:1.25</code>),不装宿主工具链。</li>
|
||||
<li>国内镜像:<code>GOPROXY=goproxy.cn,direct</code>、<code>PUB_HOSTED_URL/FLUTTER_STORAGE_BASE_URL=flutter-io.cn</code>。</li>
|
||||
<li>Secrets(已建,对齐 jiu):账户级 <code>FORGEJO_TOKEN/URL</code>、<code>MACOS_DEVELOPER_ID_CERT_P12_BASE64/PASSWORD</code>、<code>APPSTORE_API_KEY_P8_BASE64/APPSTORE_API_KEY_ID/APPSTORE_API_ISSUER_ID</code>;仓库级 <code>DEPLOY_SSH_KEY</code>、<code>ANDROID_KEYSTORE_BASE64/PASSWORD</code>、<code>ANDROID_KEY_ALIAS/PASSWORD</code>、<code>MACOS_APP_PROVISION_PROFILE_BASE64</code>、<code>MACOS_SYSEXT_PROVISION_PROFILE_BASE64</code>。</li>
|
||||
<li>客户端铁律:macOS 递增 <code>CURRENT_PROJECT_VERSION</code>;Android NDK≥28、gomobile JDK17、libbox 包名 <code>io.nekohasekai.libbox</code>。</li>
|
||||
<li>Bash 禁 <code>$()</code>;提交带 Co-Authored-By footer。部署机 <code>pangolin1</code>(103.119.13.48),官网 <code>pangolin.yanmeiai.com</code>。</li>
|
||||
</ul>
|
||||
|
||||
<div class="phase">Phase 1 —— 基座 + 官网 + 服务端(无签名,可立即上线)</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 1 · scripts/ci 基座</h3>
|
||||
<div class="files">Create: scripts/ci/_env.sh · lib-forgejo.sh · notify.sh(抄 jiu 同名改 pangolin 专属值)</div>
|
||||
<b>产出:</b><code>_env.sh</code>(镜像源 + <code>ver_from_tag</code> 用 <code>${ref#refs/tags/prefix-v}</code> 参数展开);<code>lib-forgejo.sh</code>(<code>forgejo_release_ensure</code> / <code>forgejo_upload_asset</code>,curl+API);<code>notify.sh</code>。
|
||||
验证:<code>bash -n</code> + <code>shellcheck</code> 0 告警 → commit。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 2 · checks 工作流(保留)</h3>
|
||||
<div class="files">Modify: .gitea/workflows/ci.yml</div>
|
||||
现有 ci.yml(nas,shellcheck/openapi/redline/flutter/go test)保留;把 <code>scripts/ci/*.sh</code> 纳入 shellcheck 扫描。push 观察全绿。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 3 · 官网发布</h3>
|
||||
<div class="files">Create: scripts/ci/compile-site.sh · deploy-site.sh · .gitea/workflows/deploy-site.yml</div>
|
||||
<b>前置(基础设施,改机器前问用户):</b>pangolin1 装 nginx/caddy 配 <code>pangolin.yanmeiai.com</code> vhost(web 根 <code>/var/www/pangolin-site</code>);CF DNS 指向 103.119.13.48(<code>cf-api</code>,记 baize);TLS 先 CF 橙云或并入 #25。
|
||||
<ul>
|
||||
<li><code>compile-site.sh</code>:<code>node:20</code> 容器 <code>npm ci && SITE_URL=https://pangolin.yanmeiai.com npm run build</code>。</li>
|
||||
<li><code>deploy-site.sh</code>:<code>DEPLOY_SSH_KEY</code> → <code>rsync -az --delete dist/ pangolin1:/var/www/pangolin-site/</code>。</li>
|
||||
<li><code>deploy-site.yml</code>:tag <code>site-v*</code> + dispatch,<code>runs-on: nas</code>。</li>
|
||||
</ul>
|
||||
验证:dispatch → <code>curl -I https://pangolin.yanmeiai.com/</code> 200。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 4 · 服务端发布(固化 F3/F4,带回滚)</h3>
|
||||
<div class="files">Create: scripts/ci/compile-backend.sh · release-server.sh · deploy-server.sh · .gitea/workflows/deploy-server.yml</div>
|
||||
<ul>
|
||||
<li><code>compile-backend.sh</code>:<code>golang:1.25</code> 容器,<code>CGO_ENABLED=0 GOOS=linux GOARCH=amd64</code> 编 server/agent/migrate。</li>
|
||||
<li><code>release-server.sh</code>:<code>forgejo_release_ensure</code> + 上传三个二进制。</li>
|
||||
<li><code>deploy-server.sh</code>(核心,复刻手动次序):</li>
|
||||
</ul>
|
||||
<pre>#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
DB=/var/lib/pangolin/pangolin.db; BIN=/usr/local/bin; TAG="$1"
|
||||
scp server/out/pangolin-{server,agent,migrate} pangolin1:/tmp/
|
||||
ssh pangolin1 "bash -s" <<REMOTE
|
||||
set -euo pipefail
|
||||
systemctl stop pangolin-server
|
||||
runuser -u pangolin -- sqlite3 "$DB" 'PRAGMA wal_checkpoint(TRUNCATE);'
|
||||
cp -p "$DB" "$DB.bak-pre-$TAG"
|
||||
if ! runuser -u pangolin -- env DB_DRIVER=sqlite DB_DSN=$DB /tmp/pangolin-migrate up; then
|
||||
echo "!! migrate 失败,回滚"; cp -p "$DB.bak-pre-$TAG" "$DB"; systemctl start pangolin-server; exit 1
|
||||
fi
|
||||
cp -p "$BIN/pangolin-server" "$BIN/pangolin-server.bak-$TAG" || true
|
||||
install -m755 /tmp/pangolin-server "$BIN/pangolin-server"
|
||||
install -m755 /tmp/pangolin-agent "$BIN/pangolin-agent"
|
||||
install -m755 /tmp/pangolin-migrate "$BIN/pangolin-migrate"
|
||||
systemctl start pangolin-server; systemctl is-active pangolin-server
|
||||
REMOTE
|
||||
curl -fsS -m10 --retry 5 --retry-connrefused http://103.119.13.48:8080/healthz >/dev/null && echo healthz OK</pre>
|
||||
<code>deploy-server.yml</code>:tag <code>server-v*</code> + dispatch,nas,compile → test → release → deploy。
|
||||
验证:dispatch → migrate 版本前进 + <code>/healthz</code> 200 + 行数守恒。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 5 · scripts/ci/test.sh</h3>
|
||||
<code>test.sh server</code> → <code>golang:1.25</code> <code>go test ./...</code>;<code>test.sh client</code> → <code>flutter test</code>。接入 deploy-server 的 test 步骤。
|
||||
</div>
|
||||
|
||||
<div class="phase">Phase 2 —— Android(解锁官网下载链接)</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 6 · Android gradle 接 release 签名</h3>
|
||||
<div class="files">Modify: client/android/app/build.gradle · Create: keystore.properties(gitignore)</div>
|
||||
加 <code>signingConfigs.release</code>,从 env/<code>keystore.properties</code> 读 keystore + 三密码(<code>ANDROID_*</code>);<code>buildTypes.release.signingConfig</code> 指向它。本机验证 <code>apksigner verify --print-certs</code> 显示 CN=Pangolin(非 debug)。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 7 · Android CI</h3>
|
||||
<div class="files">Create: scripts/ci/compile-android.sh · release-client.sh · .gitea/workflows/build-android.yml</div>
|
||||
<code>compile-android.sh</code>:<code>build-libbox.sh android</code>(JDK17/NDK≥28)→ secrets 落 keystore → <code>flutter build apk --release --split-per-abi --dart-define=PANGOLIN_API_URL=…</code>。<code>build-android.yml</code>:tag <code>client-v*</code>,<code>runs-on: mac</code>,provision → compile → release。验证:真机 <code>adb install -r</code> 可用。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 8 · 官网下载链接接 Android</h3>
|
||||
<div class="files">Modify: web/website/src/config/site.ts(downloads.android)· Download.astro</div>
|
||||
<code>site.ts.downloads.android</code> = Forgejo <code>/releases/latest/download/<asset></code> 稳定 URL(不支持则构建期烘焙)。重部署官网,点击落到最新 apk。关 todo 30A(Android 部分)。
|
||||
</div>
|
||||
|
||||
<div class="phase">Phase 3 —— macOS + Windows</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 9 · macOS 签名+公证 dmg</h3>
|
||||
<div class="files">Create: scripts/ci/compile-macos.sh · .gitea/workflows/build-macos.yml(证书导入+notarytool 可复用 jiu compile-macos.sh)</div>
|
||||
建临时 keychain → <code>MACOS_DEVELOPER_ID_CERT_P12_BASE64</code> 导入 → 两个描述文件解码装入 → 递增 <code>CURRENT_PROJECT_VERSION</code> → Xcode Developer ID 构建 app+sysext → <code>notarytool submit --key-id $APPSTORE_API_KEY_ID --issuer $APPSTORE_API_ISSUER_ID --wait</code> → <code>stapler</code> → dmg。<code>runs-on: mac</code>。验证:另一台 mac <code>spctl -a -vv</code> + <code>stapler validate</code> 通过。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 10 · Windows 安装包</h3>
|
||||
<div class="files">Create: scripts/ci/compile-windows.sh · .gitea/workflows/build-windows.yml(参照 jiu install-innosetup.ps1)</div>
|
||||
<code>flutter build windows --release</code> → Inno Setup 打包(<b>先不代码签名</b>,首装有 SmartScreen 提示可接受)。tag <code>client-v*</code>/<code>winbuild*</code>,<code>runs-on: windows</code>。验证:windows 机装上能连。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 11 · 下载链接全端闭环</h3>
|
||||
<code>site.ts.downloads</code> 补齐 macos/windows;三端按钮全接 release。客户端发版触发官网重部署刷新。关 todo 30A/30B。
|
||||
</div>
|
||||
|
||||
<h2>不在本计划</h2>
|
||||
<p class="small">iOS(G)、备份/容灾(#26)、TLS(#25)、Windows 代码签名、上架商店。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>全栈设计审查 2026-07(前端 / 后端 / 数据库)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.p0{border-left:3px solid var(--bad)}
|
||||
.card.p1{border-left:3px solid var(--warn)}
|
||||
.card.p2{border-left:3px solid var(--accent2)}
|
||||
.card h3{margin-top:0;color:var(--fg)}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.loc{font-family:var(--mono);font-size:12px;color:var(--fg2);margin-top:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 返回文档索引</a>
|
||||
|
||||
<h1>全栈设计审查 · 2026-07</h1>
|
||||
<p class="sub">范围:<code>server/</code>(Go 控制面 + agent)· <code>client/</code>(Flutter)· 数据库 schema(migrations 1–20)· 部署脚本。方法:核心链路逐文件精读(认证 / 会话 / 连接下发 / 用量记账 / 配额卡控 / 设备管理),非全量逐行。<span class="tag info">13 项发现</span> <span class="tag bad">P0 ×2</span> <span class="tag warn">P1 ×5</span></p>
|
||||
|
||||
<div class="lead">
|
||||
总体评价:<b>架构底子是好的</b>——方言层数据库解耦、argon2id 密码、refresh 单次轮换 + Redis 白名单、gRPC mTLS、
|
||||
per-device dp_uuid 归因、Lua 滑窗限流、节点三态判活(DB×agent在线×数据面健康),都属同规模项目少见的干净设计。
|
||||
问题集中在<b>两条主线</b>:① 传输与数据安全的「最后一公里」没封口(明文 API、零备份);
|
||||
② 多处「一次写对、后续演进没跟上」的<b>接缝漂移</b>(connect 发每设备凭证 / disconnect 撤账户凭证;
|
||||
设备唯一键改造写进注释却没落地;agent 用量取走即焚)。
|
||||
</div>
|
||||
|
||||
<h2>发现汇总</h2>
|
||||
<table>
|
||||
<tr><th>#</th><th>严重度</th><th>域</th><th>一句话</th></tr>
|
||||
<tr><td>F1</td><td><span class="tag bad">P0 安全</span></td><td>全栈</td><td>控制面全程明文 HTTP:密码 / JWT / 会话轮询裸奔公网</td></tr>
|
||||
<tr><td>F2</td><td><span class="tag bad">P0 运维</span></td><td>数据库</td><td>SQLite 生产库零备份——单盘单机,丢了就是全部</td></tr>
|
||||
<tr><td>F3</td><td><span class="tag warn">P1 正确性</span></td><td>后端+DB</td><td>同一台机器换账号登录 → 设备注册永远 403 → 无法连接,且提示的自救方法无效</td></tr>
|
||||
<tr><td>F4</td><td><span class="tag warn">P1 正确性</span></td><td>后端</td><td>connect 下发每设备凭证,disconnect 却撤账户级凭证——断开从未真正吊销</td></tr>
|
||||
<tr><td>F5</td><td><span class="tag warn">P1 可靠性</span></td><td>agent</td><td>用量「取走即焚」:计数器已清零,上报失败数据永久丢(注释写 at-least-once,实为 at-most-once)</td></tr>
|
||||
<tr><td>F6</td><td><span class="tag warn">P1 可靠性</span></td><td>后端</td><td>Redis 重启 ≈ 全员被登出;sessions 表自称权威却不被 refresh 路径参考</td></tr>
|
||||
<tr><td>F7</td><td><span class="tag warn">P1 容量</span></td><td>后端</td><td>argon2id 64 MiB/次登录,1 GB 小机上一波并发登录即可 OOM</td></tr>
|
||||
<tr><td>F8</td><td><span class="tag info">P2 数据库</span></td><td>数据库</td><td>sessions / audit_log 无限增长,无留存策略;按登录史全量扫描</td></tr>
|
||||
<tr><td>F9</td><td><span class="tag info">P2 产品</span></td><td>后端</td><td>免费额度按 UTC 日重置 = 北京时间早上 8 点,「今日」口径与用户认知不符</td></tr>
|
||||
<tr><td>F10</td><td><span class="tag info">P2 一致性</span></td><td>全栈</td><td>免费时长三个时钟各说各话:服务端分钟(有流量才计)、凭证 TTL、客户端倒计时</td></tr>
|
||||
<tr><td>F11</td><td><span class="tag info">P2 可靠性</span></td><td>后端</td><td>agent 在线状态纯内存:server 重启后短窗内全体拒连</td></tr>
|
||||
<tr><td>F12</td><td><span class="tag info">P2 客户端</span></td><td>客户端</td><td>单实例探测固定端口 47654 无握手:被占则 App 无法启动,任意本地进程可唤窗</td></tr>
|
||||
<tr><td>F13</td><td><span class="tag info">P2 后端</span></td><td>后端</td><td>ReportUsage 多条 SQL 无事务,崩溃可留部分记账</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>P0 — 必须尽快处理</h2>
|
||||
|
||||
<div class="card p0">
|
||||
<h3>F1 · 控制面全程明文 HTTP <span class="tag bad">安全</span></h3>
|
||||
<p><b>现象</b>:客户端默认 API 基址是 <code>http://103.119.13.48:8080</code>(裸 IP + 明文)。登录密码、
|
||||
access/refresh token、会话轮询、设备列表——所有控制面流量在公网明文传输。服务端 argon2id 只保护「存储」,保护不了「传输」。</p>
|
||||
<p><b>影响</b>:任何链路中间者(ISP、Wi-Fi、GFW 探测设备)可截获密码与 token 直接接管账户。对一个「主打隐私」的产品,这是与定位直接矛盾的短板;且中国链路上明文 HTTP + 可疑 payload 更易被主动探测/干扰。</p>
|
||||
<p><b>修法</b>:域名 + 反代 TLS(Caddy 一行配置自动 Let's Encrypt,或 nginx+certbot),客户端默认改
|
||||
<code>https://api.<domain></code>;Android 移除 <code>usesCleartextTraffic</code>;服务端 8080 收回 loopback。
|
||||
无域名过渡期可先自签 + 客户端证书 pinning(次优)。</p>
|
||||
<div class="loc">client/lib/services/api_config.dart:8 · scripts/local_test.sh:20(API_URL)· server :8080 直挂公网</div>
|
||||
</div>
|
||||
|
||||
<div class="card p0">
|
||||
<h3>F2 · SQLite 生产库零备份 <span class="tag bad">运维</span></h3>
|
||||
<p><b>现象</b>:<code>deploy/</code> 全目录无任何 backup / dump / litestream 痕迹。用户、订阅、激活码、用量全部在
|
||||
pangolin1 单机单盘的一个 SQLite 文件里。</p>
|
||||
<p><b>影响</b>:磁盘损坏 / 误操作 / VPS 商跑路 = 用户资产全灭,无法恢复付费用户订阅关系(直接经济损失 + 信誉损失)。这是当前全项目期望损失最大的单点。</p>
|
||||
<p><b>修法</b>(一晚可落地):① 最简:cron 每日 <code>sqlite3 .backup</code> + <code>rclone</code> 推 Cloudflare R2/S3 异地,保留 30 天;
|
||||
② 更优:Litestream 持续复制到对象存储(秒级 RPO,内存开销可忽略,适合 1GB 小机)。恢复流程写进 runbook 并演练一次。</p>
|
||||
<div class="loc">deploy/bootstrap/ · deploy/single-node/deploy.sh(均无备份任务)</div>
|
||||
</div>
|
||||
|
||||
<h2>P1 — 设计缺陷,建议排期修</h2>
|
||||
|
||||
<div class="card p1">
|
||||
<h3>F3 · 同机换账号 → 设备注册永远 403,连接被卡死 <span class="tag warn">正确性</span></h3>
|
||||
<p><b>链路</b>:客户端 <code>device_id</code> 一次生成、安全存储持久、<b>跨账号复用</b>(登出不清)。
|
||||
<code>devices.uuid</code> 是<b>全局 UNIQUE</b>(migration 000001),<code>RegisterIfAbsent</code> 遇到「uuid 已属他人」直接
|
||||
<code>ErrForbidden</code>;登录侧注册是 best-effort → <b>登录成功但设备永远注册不上</b>;随后
|
||||
<code>ConnectNode</code> 因设备未注册拒发凭证,提示「请退出后重新登录以重新注册设备」——<b>而重新登录永远解不了这个死结</b>。</p>
|
||||
<p><b>影响</b>:一台机器先后登两个账号(家人共用电脑、用户换号、测试机)→ 第二个账号完全无法连接,且用户按提示操作也无效。migration 16 头注释已写明「UNIQUE(uuid)→UNIQUE(user_id,uuid) 需表重建,风险隔离到单独迁移」——<b>该迁移至今未落地</b>,是典型的「注释里的 TODO 变成生产 bug」。</p>
|
||||
<p><b>修法</b>:① 落地推迟的迁移:<code>UNIQUE(user_id, uuid)</code>(设备身份按用户隔离,语义即「此用户的此设备」);
|
||||
dp_uuid 归因按 (user,device) 查本就成立;② 或语义改「重绑」:新登录抢走设备行(转移 owner 并吊销旧主会话)——更贴近「一台设备此刻只属一个账号」的现实;③ 客户端兜底:登出时按账号命名空间存 device_id。推荐 ①+③。</p>
|
||||
<div class="loc">server/internal/devices/service.go:209(ErrForbidden)· server/migrations/sqlite/000016_*.up.sql 头注释 · server/internal/httpapi/nodes.go:245(DEVICE_NOT_REGISTERED)· client/lib/services/device_identity.dart:66</div>
|
||||
</div>
|
||||
|
||||
<div class="card p1">
|
||||
<h3>F4 · disconnect 撤销的不是 connect 发出的凭证 <span class="tag warn">正确性</span></h3>
|
||||
<p><b>现象</b>:<code>ConnectNode</code> 走每设备凭证 <code>EnsureDeviceDpUUID → devDp</code>(nodes.go:244);
|
||||
<code>DisconnectNode</code> 却吊销<b>账户级</b> <code>ent.DpUUID</code> 并删账户凭证行(nodes.go:371-377),且接口没有
|
||||
<code>device_id</code> 入参。<b>用户主动断开从未真正吊销数据面凭证</b>——每设备凭证在节点上一直活到 TTL(付费 24h)。</p>
|
||||
<p><b>影响</b>:「断开」的服务端语义失效;被移除/被强退的设备若本地还留着 sing-box 配置,断开后的
|
||||
TTL 窗口内仍可直连数据面(绕过控制面判定)。DeleteDevice 路径有自己的 revoker 是对的,但普通 disconnect 是空转。</p>
|
||||
<p><b>修法</b>:disconnect 请求体加 <code>device_id</code>,查 <code>devDp</code> 后吊销之;账户级 dp_uuid 作为遗留兜底再撤一次亦可。顺手给 revoke 失败加告警(现在 <code>_ =</code> 吞掉)。</p>
|
||||
<div class="loc">server/internal/httpapi/nodes.go:244 vs 336-380</div>
|
||||
</div>
|
||||
|
||||
<div class="card p1">
|
||||
<h3>F5 · 用量「取走即焚」:上报失败 = 数据永久丢 <span class="tag warn">可靠性</span></h3>
|
||||
<p><b>现象</b>:v2ray 用量源 <code>QueryStats(Reset_: true)</code> <b>先清零内核计数器</b>拿到 delta;
|
||||
<code>runUsage</code> 里 <code>ReportUsage</code> 一旦失败直接 <code>return err</code> 拆会话重连——<b>刚取走的这窗口数据没有任何缓冲,永久丢失</b>。注释声称 at-least-once,实际是 at-most-once。</p>
|
||||
<p><b>影响</b>:控制面-agent 之间任何 gRPC 抖动(server 重启、网络闪断——每分钟一窗,天天发生)都在漏记:
|
||||
免费用户少计分钟 = 变相多送时长;统计页字节数偏低。计费相关数据不该按「尽力而为」设计。</p>
|
||||
<p><b>修法</b>:Collect 后先并入内存 pending 缓冲,ReportUsage 成功才清;失败保留、下窗口合并重发(按 dp_uuid 累加,幂等安全);再给报文加 <code>window_id</code>,控制面按 (node,window_id) 去重防重发双计。缓冲上限封顶(如 1h)防内存膨胀。</p>
|
||||
<div class="loc">server/internal/agentd/usage_v2ray.go:66(Reset_)· server/internal/agentd/usage.go:42-51</div>
|
||||
</div>
|
||||
|
||||
<div class="card p1">
|
||||
<h3>F6 · Redis 重启 ≈ 全员被登出;「权威」sessions 表不参与 refresh 判定 <span class="tag warn">可靠性</span></h3>
|
||||
<p><b>现象</b>:refresh token 白名单只活在 Redis(<code>jwt:refresh:*</code>)。single-node 部署用发行版默认 Redis(RDB 快照,非 AOF)——crash/重启丢最近几分钟到全部白名单 → 存量 refresh 全被拒 → <b>全体用户被迫重新登录</b>。而 sessions 表注释自称「可查询的权威记录」,refresh 路径却从不回查它——两边脑裂:DB 说会话有效,Redis 说无效,以 Redis 为准。</p>
|
||||
<p><b>影响</b>:1GB 小机上 Redis 恰是 OOM-killer 高危对象;一次意外重启= 一次全量掉线事故 + 客服风暴。</p>
|
||||
<p><b>修法</b>:refresh 白名单 miss 时<b>回查 sessions 表</b>(jti 存在且未 revoke → 放行并回填 Redis),Redis 降级为缓存而非唯一真相;同时 single-node 部署给 Redis 开 AOF (<code>appendonly yes</code>) + <code>maxmemory</code> 上限。这也顺手消除了「强退后 Redis 删失败仍可刷新」的反向缝隙。</p>
|
||||
<div class="loc">server/internal/auth/token.go:228-234 · server/internal/sessions/store.go:1-5(“authoritative”)· deploy/single-node/deploy.sh:104</div>
|
||||
</div>
|
||||
|
||||
<div class="card p1">
|
||||
<h3>F7 · argon2id 64 MiB/次登录,1 GB 机可被打 OOM <span class="tag warn">容量/安全</span></h3>
|
||||
<p><b>现象</b>:argon2id 参数 64 MiB × 4 线程。登录是公开端点:~10 个并发登录请求 ≈ 640 MB 瞬时内存——机器总共 1 GB,还要跑 sing-box + agent + Redis。滑窗限流按 scope(邮箱/IP) 计,攻击者换 IP/邮箱可绕。</p>
|
||||
<p><b>修法</b>:给密码哈希加<b>全局并发闸</b>(semaphore 1–2 个并发,其余排队),几行代码把内存上限钉死在 128 MiB;
|
||||
或按 OWASP 备选参数降到 19 MiB×2。限流再加全局维度(每秒总登录数)兜底。</p>
|
||||
<div class="loc">server/internal/auth/password.go:19-21 · server/internal/auth/ratelimit.go</div>
|
||||
</div>
|
||||
|
||||
<h2>P2 — 结构性小患 / 口径问题</h2>
|
||||
|
||||
<div class="card p2">
|
||||
<h3>F8 · sessions / audit_log 无限增长,无留存策略 <span class="tag info">数据库</span></h3>
|
||||
<p>每次登录一行 sessions、永不清理;audit_log 纯追加。<code>LastLoginByDevice</code> 按用户<b>全史扫描</b>(ORDER BY created_at ASC 无 LIMIT),<code>HasActiveSession</code>(15s 轮询热路径)只有 user_id 单列索引可用。年级尺度上小机的磁盘与查询都会被拖住。<b>修</b>:留存任务(revoked 会话 >90 天、audit >180 天定期删)+ 复合索引 <code>(user_id, device_id, revoked_at)</code>;LastLogin 改每设备 MAX 子查询或维护 devices.last_login 列。</p>
|
||||
<div class="loc">server/internal/sessions/store.go:54-62,134-152 · migrations 000016(仅两个单列索引)</div>
|
||||
</div>
|
||||
|
||||
<div class="card p2">
|
||||
<h3>F9 · 免费额度按 UTC 日重置(北京时间 08:00)<span class="tag info">产品</span></h3>
|
||||
<p><code>utcToday()</code> / <code>windowEnd.UTC().Truncate(24h)</code>:主力用户在国内,「今日剩余」却在早上 8 点跳变,倒计时/额度体验诡异且难解释。<b>修</b>:额度日界定死 <code>Asia/Shanghai</code>(产品定位明确,不必 per-user 时区),服务端集中改 <code>utcToday</code> 与记账日期两处即可,客户端展示自动跟随 /me。</p>
|
||||
<div class="loc">server/internal/usage/quota.go:40,55 · server/internal/nodes/handler_grpc.go:291</div>
|
||||
</div>
|
||||
|
||||
<div class="card p2">
|
||||
<h3>F10 · 免费时长三个时钟不一致 <span class="tag info">一致性</span></h3>
|
||||
<p>同一「10 分钟」有三种度量:① 服务端 minutes_used——<b>有流量的窗口才 +1</b>(挂着不动不扣);② 凭证 TTL——发放时定死墙钟;③ 客户端倒计时——连接起墙钟递减。后果:闲置用户被客户端切断但服务端几乎没扣分 → 重连又是满额倒计时(免费时长实际无上限,只要愿意重连);反之轻流量用户每窗口整分扣。<b>修</b>:先定口径——推荐「连接在线即计时」(agent 按凭证存活窗口计 1 分钟,不看流量),三个时钟自然对齐;或接受现状但把客户端倒计时以 /me 剩余为准动态校正(已部分做)。</p>
|
||||
<div class="loc">server/internal/agentd/usage_v2ray.go:96-101(有流量才计)· httpapi/nodes.go:235(TTL)· client connection_provider 倒计时</div>
|
||||
</div>
|
||||
|
||||
<div class="card p2">
|
||||
<h3>F11 · agent 在线状态纯内存,server 重启短窗全体拒连 <span class="tag info">可靠性</span></h3>
|
||||
<p><code>hub.IsOnline</code> 是进程内 map;server 重启后到 agent 重连前,ListNodes 全灰、ConnectNode 全拒(503)。当前单节点影响秒级,可接受;但多节点后放大。<b>修</b>:启动后给一个宽限窗(如 60s 内 unknown 视为 up),或 agent 心跳落 Redis 带 TTL。与 todo #8(掉线告警)同一片改。</p>
|
||||
</div>
|
||||
|
||||
<div class="card p2">
|
||||
<h3>F12 · 单实例探测:固定端口 47654、无握手 <span class="tag info">客户端</span></h3>
|
||||
<p>任何本地进程先占住该端口 → 真 App 启动时 bind 失败误判「已有实例」直接退出(<b>App 无法启动且无提示</b>);反之任意本地进程连一下就能唤起主窗(无害但脏)。<b>修</b>:连接后交换 magic 字节验明正身,验不过改用文件锁兜底再启动;唤窗同样验 magic。</p>
|
||||
<div class="loc">client/lib/system_tray.dart:15-38</div>
|
||||
</div>
|
||||
|
||||
<div class="card p2">
|
||||
<h3>F13 · ReportUsage 多条 SQL 无事务 <span class="tag info">后端</span></h3>
|
||||
<p>每设备 Accumulate + 每用户 Accumulate 是多条独立语句,中途崩溃留部分记账(设备有、账户无)。量级小、图表级偏差,配合 F5 的 window_id 幂等一起收进单事务即可。</p>
|
||||
<div class="loc">server/internal/nodes/handler_grpc.go:300-359</div>
|
||||
</div>
|
||||
|
||||
<h2>做得好的(保持)</h2>
|
||||
<ul>
|
||||
<li><b>方言层</b>(<code>internal/db/dialect.go</code>):裸 SQL + 中性 Upsert/锁语义,时间 Go 端算——MySQL/SQLite 真正可切换,测试免 docker。</li>
|
||||
<li><b>认证栈</b>:argon2id + RS256 双 kid 轮换 + refresh 单次使用轮换 + typ 声明防混用,教科书级。</li>
|
||||
<li><b>节点判活</b>:DB 状态 × agent gRPC 在线 × 数据面健康三合一(<code>effectiveNodeStatus</code>),并拒绝向离线 agent「假装下发成功」——正是修过 6 天静默事故后的正确形态。</li>
|
||||
<li><b>per-device dp_uuid + v2ray per-user 计数</b>:归因链路是准的(老 clash 均摊源已弃用、仅遗留代码)。</li>
|
||||
<li><b>免费额度账户级共享 + 凭证 TTL 硬切断</b>:卡控在服务端成立,客户端绕过也兜得住。</li>
|
||||
<li><b>迁移成对成套</b>(mysql/sqlite 各一份 up/down),审查期未见漂移。</li>
|
||||
</ul>
|
||||
|
||||
<h2>建议处理顺序</h2>
|
||||
<table>
|
||||
<tr><th>批次</th><th>项</th><th>理由</th></tr>
|
||||
<tr><td><b>立刻</b></td><td>F2(备份)→ F1(TLS)</td><td>F2 一晚落地、消掉最大期望损失;F1 需要域名决策,动客户端默认值要随发版</td></tr>
|
||||
<tr><td><b>下一迭代</b></td><td>F3 + F4(一起动 devices/凭证接缝);F5 + F13(一起动用量链路);F6 + F7(一起动 auth 可靠性)</td><td>三组各自同一片代码,一组一 PR</td></tr>
|
||||
<tr><td><b>排队</b></td><td>F8–F12</td><td>口径决策(F9/F10)先拍板再动手;F11 并入 todo #8</td></tr>
|
||||
</table>
|
||||
|
||||
<p class="small">备注:web/(usercenter/website)本轮未深审(改动频率与暴露面低于 server/client 核心链路);
|
||||
近期已修复且验证过的不再列出:用量多设备超计(#22)、被移除设备判活(dev==nil)、弱网看门狗误伤(#18)、统计流广播订阅。
|
||||
本报告基于 worktree-macos-killswitch @ 2026-07-02。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>联系我们:渠道二级页(Telegram 频道/群组,DB 配置)· 交互设计</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
|
||||
/* ── 交互原型:用真实 App 暗色 token(design/colors_and_type.css [data-theme=dark])──*/
|
||||
.proto{
|
||||
--p-bg:#14110E; --p-bg-subtle:#1F1C18; --p-surface:#221E19; --p-surface2:#2A251F;
|
||||
--p-fg1:#F4EFE8; --p-fg2:#B6AC9C; --p-fg3:#897F6F; --p-fg-on-accent:#1F1C18;
|
||||
--p-accent:#CC8B5C; --p-accent-hover:#D9A982; --p-accent-subtle:rgba(204,139,92,.14);
|
||||
--p-accent-border:rgba(204,139,92,.30);
|
||||
--p-border:rgba(242,238,231,.10); --p-border-strong:rgba(242,238,231,.18);
|
||||
--p-green:#7FB07A;
|
||||
background:var(--p-bg);border:1px solid var(--border);border-radius:14px;overflow:hidden;
|
||||
display:flex;height:520px;margin:18px 0;font-size:14px;
|
||||
}
|
||||
.proto *{box-sizing:border-box}
|
||||
/* sidebar */
|
||||
.p-side{width:190px;flex:none;background:var(--p-bg);border-right:1px solid var(--p-border);padding:18px 14px;display:flex;flex-direction:column;gap:4px}
|
||||
.p-brand{display:flex;align-items:center;gap:10px;padding:4px 8px 18px}
|
||||
.p-logo{width:30px;height:30px;border-radius:8px;background:var(--p-accent-subtle);display:flex;align-items:center;justify-content:center;color:var(--p-accent);font-size:16px}
|
||||
.p-brand b{color:var(--p-fg1);font-size:16px;line-height:1.1}
|
||||
.p-brand span{display:block;color:var(--p-fg3);font-size:9px;letter-spacing:2px}
|
||||
.p-nav{display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:9px;color:var(--p-fg2);cursor:default}
|
||||
.p-nav .ic{width:17px;text-align:center;opacity:.85}
|
||||
.p-nav.active{background:var(--p-accent-subtle);color:var(--p-accent);font-weight:600}
|
||||
/* main */
|
||||
.p-main{flex:1;display:flex;flex-direction:column;min-width:0}
|
||||
.p-top{display:flex;align-items:center;gap:12px;padding:16px 22px;border-bottom:1px solid var(--p-border)}
|
||||
.p-top .ttl{font-size:19px;font-weight:700;color:var(--p-fg1)}
|
||||
.p-top .rt{margin-left:auto;display:flex;align-items:center;gap:14px;color:var(--p-fg2);font-size:12px}
|
||||
.p-dot{width:7px;height:7px;border-radius:50%;background:var(--p-green);display:inline-block;margin-right:5px}
|
||||
.p-body{padding:20px 22px;overflow-y:auto}
|
||||
.p-intro{color:var(--p-fg2);font-size:13.5px;margin:0 0 14px}
|
||||
/* channel / item cards */
|
||||
.p-card{display:flex;align-items:center;gap:13px;padding:13px 15px;background:var(--p-surface);border:1px solid var(--p-border);border-radius:13px;margin-bottom:10px;cursor:pointer;transition:border-color .12s,background .12s}
|
||||
.p-card:hover{border-color:var(--p-accent-border);background:var(--p-surface2)}
|
||||
.p-ic{width:38px;height:38px;flex:none;border-radius:10px;background:var(--p-bg-subtle);display:flex;align-items:center;justify-content:center;font-size:18px;color:var(--p-fg2)}
|
||||
.p-ic.accent{background:var(--p-accent-subtle);color:var(--p-accent)}
|
||||
.p-tx{flex:1;min-width:0}
|
||||
.p-tx .nm{color:var(--p-fg1);font-weight:600;font-size:14.5px;display:flex;align-items:center;gap:6px}
|
||||
.p-tx .sb{color:var(--p-fg3);font-size:12.5px;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.p-chev{color:var(--p-fg3);font-size:17px;flex:none}
|
||||
.p-verify{color:var(--p-accent);font-size:12px}
|
||||
.p-count{color:var(--p-fg3);font-size:11.5px;flex:none;display:flex;align-items:center;gap:4px}
|
||||
.p-btn{flex:none;font-size:12.5px;font-weight:600;padding:6px 15px;border-radius:8px;border:1px solid var(--p-accent-border);color:var(--p-accent);background:transparent}
|
||||
.p-btn.solid{background:var(--p-accent);color:var(--p-fg-on-accent);border-color:transparent}
|
||||
.p-sechd{color:var(--p-fg3);font-size:11px;font-weight:700;letter-spacing:1.4px;margin:18px 2px 9px}
|
||||
.p-back{display:inline-flex;align-items:center;gap:8px;color:var(--p-fg2);font-size:13px;cursor:pointer;padding:2px 0;margin-bottom:2px}
|
||||
.p-back:hover{color:var(--p-fg1)}
|
||||
.p-detail-hd{display:flex;align-items:center;gap:12px;margin:6px 0 4px}
|
||||
.p-detail-hd .p-ic{width:44px;height:44px;font-size:21px}
|
||||
.p-detail-hd .nm{font-size:18px;font-weight:700;color:var(--p-fg1)}
|
||||
.p-detail-hd .sb{color:var(--p-fg3);font-size:12.5px}
|
||||
.hide{display:none!important}
|
||||
.p-card.disabled{opacity:.5;cursor:not-allowed}
|
||||
.p-card.disabled:hover{border-color:var(--p-border);background:var(--p-surface)}
|
||||
.p-soon{font-size:10px;font-weight:600;padding:1px 7px;border-radius:999px;background:var(--p-bg-subtle);color:var(--p-fg3);border:1px solid var(--p-border)}
|
||||
.flag{margin-left:auto;color:var(--p-fg3);font-size:12px}
|
||||
.toggles{display:flex;gap:8px;margin:10px 0 2px}
|
||||
.toggles button{background:var(--panel2);color:var(--fg2);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer}
|
||||
.toggles button:hover{color:var(--fg)}
|
||||
.capt{color:var(--fg2);font-size:12.5px;text-align:center;margin-top:-4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 返回文档索引</a>
|
||||
|
||||
<h1>联系我们 · 渠道二级页</h1>
|
||||
<p class="sub">交互设计 · Telegram/LINE 多频道 · 群组 · 内容 DB 配置 · <span class="tag info">前端 + 后端 + 数据库</span></p>
|
||||
|
||||
<div class="lead">
|
||||
现「联系我们」把每个渠道当<b>单一入口</b>(Telegram 只挂一个 <code>@PangolinVPN_bot</code>)。
|
||||
需求:点 Telegram 进入<b>二级页</b>,列出该平台下的<b>多个频道 / 群组 / Bot</b>,
|
||||
每项含名称、@handle、一句说明、可选成员数、认证标记与「打开 / 加入」动作,
|
||||
<b>全部由数据库配置</b>(运营可随时增删改,客户端不写死)。本页给出交互原型 + 数据模型 + 接口,待你统一后再开发。
|
||||
</div>
|
||||
|
||||
<h2>可点原型(点 Telegram 卡片进二级,← 返回)</h2>
|
||||
<p class="small">下方为内嵌可交互原型,配色用 App 真实暗色 token(clay/espresso)。点 <b>Telegram</b> 或 <b>LINE</b> 卡进详情;「邮箱客服 / 自助发卡商店」为单链接,点即直接打开(原型里仅提示)。</p>
|
||||
|
||||
<div class="proto" id="proto">
|
||||
<!-- sidebar -->
|
||||
<div class="p-side">
|
||||
<div class="p-brand"><div class="p-logo">🐾</div><div><b>穿山甲</b><span>PANGOLIN</span></div></div>
|
||||
<div class="p-nav"><span class="ic">⏻</span>连接</div>
|
||||
<div class="p-nav"><span class="ic">🌐</span>节点</div>
|
||||
<div class="p-nav"><span class="ic">📊</span>统计</div>
|
||||
<div class="p-nav"><span class="ic">⚙</span>设置</div>
|
||||
<div class="p-nav active"><span class="ic">💬</span>联系我们</div>
|
||||
</div>
|
||||
<!-- main -->
|
||||
<div class="p-main">
|
||||
<div class="p-top">
|
||||
<span class="ttl" id="p-title">联系我们</span>
|
||||
<span class="rt"><span><span class="p-dot"></span>US</span><span>🌙</span></span>
|
||||
</div>
|
||||
|
||||
<!-- L1: channel list -->
|
||||
<div class="p-body" id="view-l1">
|
||||
<p class="p-intro">遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。</p>
|
||||
|
||||
<div class="p-card" onclick="showTg()">
|
||||
<div class="p-ic accent">✈</div>
|
||||
<div class="p-tx"><div class="nm">Telegram</div><div class="sb">官方频道 · 交流群 · 客服 Bot</div></div>
|
||||
<span class="p-chev">›</span>
|
||||
</div>
|
||||
|
||||
<div class="p-card disabled" title="即将开放,先灰置">
|
||||
<div class="p-ic">💬</div>
|
||||
<div class="p-tx"><div class="nm">LINE <span class="p-soon">即将开放</span></div><div class="sb">官方账号 · 中文/日文群</div></div>
|
||||
<span class="p-chev">›</span>
|
||||
</div>
|
||||
|
||||
<div class="p-card" title="单链接:直接打开邮件">
|
||||
<div class="p-ic">✉</div>
|
||||
<div class="p-tx"><div class="nm">邮箱客服</div><div class="sb">support@pangolin.vpn</div></div>
|
||||
<span class="p-chev">›</span>
|
||||
</div>
|
||||
|
||||
<div class="p-card" title="单链接:直接打开商店">
|
||||
<div class="p-ic">🛍</div>
|
||||
<div class="p-tx"><div class="nm">自助发卡商店</div><div class="sb">shop.pangolin.vpn</div></div>
|
||||
<span class="p-chev">›</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- L2: Telegram detail -->
|
||||
<div class="p-body hide" id="view-tg">
|
||||
<div class="p-back" onclick="showList()">‹ 返回</div>
|
||||
<div class="p-detail-hd">
|
||||
<div class="p-ic accent">✈</div>
|
||||
<div><div class="nm">Telegram</div><div class="sb">加入官方频道获取更新,进群与用户互助</div></div>
|
||||
</div>
|
||||
|
||||
<div class="p-sechd">频道 · CHANNELS</div>
|
||||
<div class="p-card">
|
||||
<div class="p-ic accent">📣</div>
|
||||
<div class="p-tx"><div class="nm">穿山甲 · 官方频道 <span class="p-verify">✔</span></div><div class="sb">产品更新与公告 · @PangolinVPN</div></div>
|
||||
<button class="p-btn">打开</button>
|
||||
</div>
|
||||
<div class="p-card">
|
||||
<div class="p-ic accent">📶</div>
|
||||
<div class="p-tx"><div class="nm">穿山甲 · 节点状态</div><div class="sb">节点/故障实时播报 · @PangolinStatus</div></div>
|
||||
<button class="p-btn">打开</button>
|
||||
</div>
|
||||
|
||||
<div class="p-sechd">群组 · GROUPS</div>
|
||||
<div class="p-card">
|
||||
<div class="p-ic">👥</div>
|
||||
<div class="p-tx"><div class="nm">穿山甲 · 用户交流群</div><div class="sb">使用问题互助交流 · @PangolinChat</div></div>
|
||||
<button class="p-btn solid">加入</button>
|
||||
</div>
|
||||
<div class="p-card">
|
||||
<div class="p-ic">🌏</div>
|
||||
<div class="p-tx"><div class="nm">Pangolin · English Group</div><div class="sb">English support & chat · @PangolinEN</div></div>
|
||||
<button class="p-btn solid">加入</button>
|
||||
</div>
|
||||
|
||||
<div class="p-sechd">客服机器人 · BOT</div>
|
||||
<div class="p-card">
|
||||
<div class="p-ic accent">🤖</div>
|
||||
<div class="p-tx"><div class="nm">客服机器人</div><div class="sb">自动答疑 / 提交工单 · @PangolinVPN_bot</div></div>
|
||||
<button class="p-btn">打开</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- L2: LINE detail (示意同构) -->
|
||||
<div class="p-body hide" id="view-line">
|
||||
<div class="p-back" onclick="showList()">‹ 返回</div>
|
||||
<div class="p-detail-hd">
|
||||
<div class="p-ic">💬</div>
|
||||
<div><div class="nm">LINE</div><div class="sb">官方账号与交流群</div></div>
|
||||
</div>
|
||||
<div class="p-sechd">官方账号 · OFFICIAL</div>
|
||||
<div class="p-card">
|
||||
<div class="p-ic">💬</div>
|
||||
<div class="p-tx"><div class="nm">Pangolin 官方账号 <span class="p-verify">✔</span></div><div class="sb">公告与客服 · @pangolinvpn</div></div>
|
||||
<button class="p-btn">打开</button>
|
||||
</div>
|
||||
<div class="p-sechd">群组 · GROUPS</div>
|
||||
<div class="p-card">
|
||||
<div class="p-ic">👥</div>
|
||||
<div class="p-tx"><div class="nm">中文交流群</div><div class="sb">使用互助 · openchat</div></div>
|
||||
<button class="p-btn solid">加入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggles">
|
||||
<button onclick="showList()">L1 渠道列表</button>
|
||||
<button onclick="showTg()">L2 · Telegram</button>
|
||||
<button onclick="showLine()">L2 · LINE(预留结构预览)</button>
|
||||
</div>
|
||||
<p class="capt">同一原型内切换三态 · 真机为路由 push(移动端整页)/ 内容区替换(桌面)。
|
||||
L1 上 LINE 为<b>灰置「即将开放」不可点</b>;「L2 · LINE」按钮仅用于预览其<b>预留的二级结构</b>(去灰后即此样式)。</p>
|
||||
|
||||
<h2>交互规则</h2>
|
||||
<ul>
|
||||
<li><b>进入二级的条件</b>:某平台配置了 <b>>1 条</b>链接 → 卡片带 <code>›</code>,点击进二级页(Telegram / LINE)。
|
||||
仅 <b>1 条</b>链接的平台(邮箱、发卡商店)→ 点击<b>直接执行该链接动作</b>(打开邮件 / 浏览器),不进二级。
|
||||
规则统一由「该平台 link 条数」驱动,无需前端写死哪个进二级。</li>
|
||||
<li><b>二级页结构</b>:顶部 <code>‹ 返回</code> + 平台头(图标 + 名 + 一句副标题);下方按 <b>kind 分组</b>展示
|
||||
——<code>频道 CHANNELS</code> / <code>群组 GROUPS</code> / <code>客服机器人 BOT</code>(分组标题仅在该组有内容时出现,顺序固定)。</li>
|
||||
<li><b>每一项</b>:图标/头像 · 标题(可带 <span class="p-verify" style="color:var(--accent)">✔</span> 认证)· 一句说明 + <code>@handle</code> ·
|
||||
可选成员数(<b>展示用缓存文本</b>,不实时拉 Telegram)· 动作按钮。</li>
|
||||
<li><b>动作</b>:频道/Bot → 「打开」(描边按钮);群组 → 「加入」(实心强调按钮)。点按钮或点整行都触发。
|
||||
优先 <code>tg://resolve?domain=…</code> 唤起已装 Telegram,失败回退 <code>https://t.me/…</code>(浏览器)。</li>
|
||||
<li><b>桌面 vs 移动</b>:桌面在右侧内容区做<b>视图替换</b>(标题栏文案随之切到「Telegram」,← 返回回列表);
|
||||
移动端为<b>整页 push 路由</b>,系统返回手势/返回键回列表。两端同一份数据与卡片组件。</li>
|
||||
<li><b>空/禁用</b>:某平台所有 link <code>enabled=0</code>(或库里无该平台行)→ 默认 L1 <b>不展示</b>该平台;二级页某分组为空 → 不渲染该分组标题。</li>
|
||||
<li><b>「即将开放」灰置</b>:前端 platform 注册表有一个 <code>comingSoon</code> 列表(当前 = <code>[line]</code>)。列在其中且<b>暂无 enabled 链接</b>的平台,L1 <b>置灰 + 「即将开放」角标、不可点进</b>(占位预告,不隐藏)。
|
||||
运营在库里配好该平台链接后,从 <code>comingSoon</code> 列表移除该项(一行代码)→ 自动变可点二级页。LINE 即走此路:结构与 Telegram 同构,先灰、内容就绪即上。</li>
|
||||
</ul>
|
||||
|
||||
<h2>数据模型(DB 配置)· 全渠道统一一张表</h2>
|
||||
<p><b>一张表覆盖所有联系方式</b> —— Telegram 频道/群组/Bot、LINE、邮箱、发卡商店都是 <code>contact_link</code> 里的一行,
|
||||
差别只在 <code>platform</code>(分到哪个 L1 卡)+ <code>kind</code>(二级分到哪组 & 动作样式)+ <code>url</code> 协议。
|
||||
一行 = 一条可点链接;L1 按 <code>platform</code> 聚合,L2 按 <code>kind</code> 分组。</p>
|
||||
<pre>contact_link
|
||||
─────────────────────────────────────────────────────────────
|
||||
id INTEGER PK
|
||||
platform TEXT -- telegram | line | email | store | whatsapp | ...(L1 分组键)
|
||||
kind TEXT -- channel | group | bot | link(L2 分组键 & 动作样式)
|
||||
title TEXT -- "穿山甲 · 官方频道" / "邮箱客服" / "自助发卡商店"
|
||||
handle TEXT -- "@PangolinVPN"(展示;可空,邮箱/商店留空)
|
||||
url TEXT -- 点击目标(见下表);App 侧对 telegram 优先转 tg://
|
||||
description TEXT -- 一句说明(可空)
|
||||
verified INTEGER -- 0/1 认证勾
|
||||
sort_order INTEGER -- 组内排序
|
||||
enabled INTEGER -- 0/1 下线开关
|
||||
locale TEXT -- "zh"|"en"|NULL(全部) 可选按语言过滤
|
||||
</pre>
|
||||
<p><b>邮箱之类怎么进这张表 —— 就是把 <code>url</code> 换个协议、<code>kind=link</code>:</b></p>
|
||||
<table>
|
||||
<tr><th>渠道</th><th>platform</th><th>kind</th><th>url 示例</th><th>动作</th></tr>
|
||||
<tr><td>Telegram 频道</td><td><code>telegram</code></td><td><code>channel</code></td><td><code>https://t.me/PangolinVPN</code>(App 转 <code>tg://</code>)</td><td>打开</td></tr>
|
||||
<tr><td>Telegram 群组</td><td><code>telegram</code></td><td><code>group</code></td><td><code>https://t.me/PangolinChat</code></td><td>加入</td></tr>
|
||||
<tr><td>Telegram Bot</td><td><code>telegram</code></td><td><code>bot</code></td><td><code>https://t.me/PangolinVPN_bot</code></td><td>打开</td></tr>
|
||||
<tr><td>LINE 账号</td><td><code>line</code></td><td><code>link</code></td><td><code>https://line.me/R/ti/p/@pangolinvpn</code></td><td>打开</td></tr>
|
||||
<tr><td>邮箱客服</td><td><code>email</code></td><td><code>link</code></td><td><code>mailto:support@pangolin.vpn</code></td><td>打开(拉起邮件)</td></tr>
|
||||
<tr><td>自助发卡商店</td><td><code>store</code></td><td><code>link</code></td><td><code>https://shop.pangolin.vpn</code></td><td>打开(浏览器)</td></tr>
|
||||
<tr><td>WhatsApp(将来)</td><td><code>whatsapp</code></td><td><code>link</code></td><td><code>https://wa.me/…</code></td><td>打开</td></tr>
|
||||
</table>
|
||||
<p class="small">动作按钮文案由 <code>kind</code> 决定:<code>group</code> → <b>「加入」</b>(实心强调);其余(<code>channel/bot/link</code>)→ <b>「打开」</b>(描边)。
|
||||
点击一律「用系统方式打开 <code>url</code>」,App 侧仅对 <code>telegram</code> 平台做 <code>https://t.me/x → tg://resolve?domain=x</code> 的唤起优化,失败回退原 url。</p>
|
||||
<p class="small"><b>唯一不入库的是「皮」</b>:L1 平台卡的<b>图标 / 强调色 / 默认显示名</b>由前端一个小 <b>platform 注册表</b>按 <code>platform</code> 键内置
|
||||
(<code>telegram</code>=✈+强调色、<code>line</code>=💬、<code>email</code>=✉、<code>store</code>=🛍…),避免把图标资源塞进数据库;未知 platform 用通用图标兜底。
|
||||
库里只配<b>内容</b>。加已有平台的新频道/群 = 纯 DB,无需发版;加一个全新平台类型(新图标)= 注册表加一行 + 发版。</p>
|
||||
|
||||
<h2>接口</h2>
|
||||
<pre>GET /v1/contact # 无需登录亦可(客户端普通请求)
|
||||
→ 200
|
||||
{
|
||||
"platforms": [
|
||||
{ "platform":"telegram",
|
||||
"links":[
|
||||
{"kind":"channel","title":"穿山甲 · 官方频道","handle":"@PangolinVPN",
|
||||
"url":"https://t.me/PangolinVPN","description":"产品更新与公告","verified":true},
|
||||
{"kind":"group","title":"穿山甲 · 用户交流群","handle":"@PangolinChat",
|
||||
"url":"https://t.me/PangolinChat","description":"使用问题互助"},
|
||||
{"kind":"bot","title":"客服机器人","handle":"@PangolinVPN_bot",
|
||||
"url":"https://t.me/PangolinVPN_bot","description":"自动答疑/提交工单"}
|
||||
]},
|
||||
{ "platform":"line", "links":[ ... ] },
|
||||
{ "platform":"email", "links":[{"kind":"link","title":"邮箱客服","url":"mailto:support@pangolin.vpn"}] },
|
||||
{ "platform":"store", "links":[{"kind":"link","title":"自助发卡商店","url":"https://shop.pangolin.vpn"}] }
|
||||
]
|
||||
}</pre>
|
||||
<ul>
|
||||
<li>后端按 <code>enabled=1</code> 过滤、按 <code>sort_order</code> 排序、按 <code>platform</code>→<code>kind</code> 分组返回;客户端只渲染。</li>
|
||||
<li>客户端<b>缓存</b>上次结果(离线/弱网仍可展示),启动或进联系页时后台刷新。</li>
|
||||
<li>迁移:<code>server/migrations/{mysql,sqlite}/</code> 各加建表 + 种子数据(把现有 4 渠道灌入,Telegram 先补真实频道/群)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>已定(本轮拍板)</h2>
|
||||
<ul>
|
||||
<li><span class="tag ok">定</span> <b>不展示成员数</b> —— 已从模型与 UI 移除 <code>member_hint</code>。</li>
|
||||
<li><span class="tag ok">定</span> 群组动作叫 <b>「加入」</b>(<code>kind=group</code>,实心强调);频道/Bot/普通链接 <b>「打开」</b>(描边)。</li>
|
||||
<li><span class="tag ok">定</span> 接口走 <b>独立 <code>GET /v1/contact</code></b>(不并进 <code>/me</code> 引导)。</li>
|
||||
<li><span class="tag ok">定</span> <b>全渠道统一一张 <code>contact_link</code> 表</b>:邮箱/发卡/LINE 与 Telegram 同表,靠 <code>url</code> 协议区分(<code>mailto:</code> / <code>https:</code> / <code>tg://</code>)。</li>
|
||||
<li><span class="tag ok">定</span> <b>LINE 也做二级</b>(与 Telegram 同构),但<b>先灰置</b>——L1 上 LINE 卡置灰 + 「即将开放」,不可点进;二级页结构预留,等运营在库里配好 LINE 链接、去灰即用。</li>
|
||||
</ul>
|
||||
|
||||
<h2>仍可再定(不阻塞,先给默认)</h2>
|
||||
<ul>
|
||||
<li><b>认证勾 <span class="p-verify" style="color:var(--accent)">✔</span> 与分组标题</b>:默认<b>保留</b>(频道/群组/Bot 分组 + 官方项带勾)。若想更简可拍平成单列表 —— 说一声即可。</li>
|
||||
<li><b>成员数字段是否保留在库里(仅不展示)</b>:默认<b>删列</b>,需要时再加回不迟。</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
<script>
|
||||
function _hideAll(){ ['view-l1','view-tg','view-line'].forEach(id=>document.getElementById(id).classList.add('hide')); }
|
||||
function showList(){ _hideAll(); document.getElementById('view-l1').classList.remove('hide'); document.getElementById('p-title').textContent='联系我们'; }
|
||||
function showTg(){ _hideAll(); document.getElementById('view-tg').classList.remove('hide'); document.getElementById('p-title').textContent='Telegram'; }
|
||||
function showLine(){ _hideAll(); document.getElementById('view-line').classList.remove('hide'); document.getElementById('p-title').textContent='LINE'; }
|
||||
</script>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:24px 0 8px;color:var(--fg)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
|
||||
.card h3{margin-top:0;color:var(--accent2)}
|
||||
.files{font-family:var(--mono);font-size:12px;color:var(--fg2);margin:6px 0 10px}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
ul,ol{padding-left:22px;margin:8px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.phase{font-size:19px;margin:40px 0 6px;color:var(--accent);font-weight:700}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划</h1>
|
||||
<p class="sub">2026-07-06 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(带 checkbox)</p>
|
||||
|
||||
<div class="lead">
|
||||
<b>目标:</b>把 pangolin-server 控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到
|
||||
<code>https://api.yanmeiai.com</code>,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书)。
|
||||
数据面 sing-box REALITY <code>:443</code> 全程不动。
|
||||
</div>
|
||||
|
||||
<h2>架构</h2>
|
||||
<p>
|
||||
pangolin1 上跑 <code>cloudflared</code> <b>出站</b>隧道(不监听任何入站端口 → 与 sing-box 独占的
|
||||
<code>:443</code> 零冲突),CF 边缘把 <code>api.yanmeiai.com</code> 的请求经隧道回送到
|
||||
<code>127.0.0.1:8080</code>。客户端(Flutter 四端共享 <code>kApiBaseUrl</code>)默认改 https 域名;
|
||||
控制面下发给客户端 sing-box 的 <code>.srs</code> 规则集下载基址(<code>PANGOLIN_PUBLIC_URL</code>)
|
||||
同步改 https。最后一步把 <code>:8080</code> 收回 loopback 并关防火墙,彻底退役明文口——该步有
|
||||
<b>上线顺序闸</b>(须待现网客户端更新后再做)。
|
||||
</p>
|
||||
|
||||
<h2>端口 / URL 布局</h2>
|
||||
<table>
|
||||
<tr><th>用途</th><th>对外</th><th>源站/绑定</th><th>本轮变更</th></tr>
|
||||
<tr><td>控制面 HTTP API</td><td><code>https://api.yanmeiai.com</code>(CF Tunnel)</td><td><code>127.0.0.1:8080</code></td><td>新增 CF Tunnel 前置 + 收 loopback</td></tr>
|
||||
<tr><td>数据面 sing-box REALITY</td><td><code>:443</code>(节点公网 IP)</td><td>同端口</td><td>不动</td></tr>
|
||||
<tr><td>gRPC agent(mTLS)</td><td>—(仅节点内)</td><td><code>:9443</code></td><td>不动</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>全局约束</h2>
|
||||
<ul>
|
||||
<li>Bash 禁 <code>$()</code> 命令替换、禁 <code>set -a</code>/<code>set +a</code>;需捕获输出拆多步或用管道。</li>
|
||||
<li>凭证走 Bitwarden/rbw,不写 <code>~/.env</code>/明文配置/git。Cloudflare 用 <code>cf-api</code> 封装(token 内部从 Bitwarden 取)。隧道 token 等密钥一律不入 git,只落 <code>/etc/pangolin/*</code>(已 gitignore)+ Bitwarden。</li>
|
||||
<li>改机器(装包/改配置/重启服务)前必须先问用户(只读操作除外)。</li>
|
||||
<li>pangolin1 = <code>103.119.13.48</code>,ssh 别名 <code>pangolin1</code>。数据面 sing-box REALITY 独占入站 <code>:443</code>,<b>不得触碰</b>;gRPC agent mTLS <code>:9443</code> 不动。</li>
|
||||
<li><b>上线顺序铁律:</b>现网客户端硬编码 <code>http://103.119.13.48:8080</code>。隧道与 https 端点必须<b>加法上线</b>(与旧口并存),客户端切 https 发版后,收 loopback 才能做,否则旧客户端全挂。</li>
|
||||
</ul>
|
||||
|
||||
<h2>6 个任务</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 1 · Cloudflare Tunnel 供给</h3>
|
||||
<div class="files">Create: deploy/single-node/systemd/cloudflared.service · Modify: deploy/single-node/deploy.sh</div>
|
||||
CF 账户侧(cf-api)建 remotely-managed 隧道 + ingress(<code>api.yanmeiai.com</code> → <code>http://localhost:8080</code>)+ 代理 CNAME;pangolin1 装 cloudflared(Debian apt 源)+ committed systemd unit(token 经 <code>EnvironmentFile</code> 注入,不入 unit 本体)。验证:<code>https://api.yanmeiai.com/healthz</code> 与旧的 <code>http://103.119.13.48:8080/healthz</code> <b>并存可用</b>(加法,不破坏现网)。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 2 · 客户端控制面基址切 https + Android 去明文</h3>
|
||||
<div class="files">Modify: client/lib/services/api_config.dart · client/android/.../AndroidManifest.xml · Create: client/test/unit/api_config_test.dart</div>
|
||||
先写守护测试(断言 <code>kApiBaseUrl</code> 必须 <code>https://</code> 且不含节点 IP)→ 确认失败 → 把 <code>kApiBaseUrl</code> 默认值改为 <code>https://api.yanmeiai.com</code>(仍保留 <code>String.fromEnvironment</code> 可本地覆盖)→ 测试转绿。同步移除 Android manifest 的 <code>android:usesCleartextTraffic="true"</code>(控制面已 https,不再需要明文豁免;iOS/macOS 无 ATS 配置,无需改动)。跑 <code>flutter analyze</code> + 全量单测。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 3 · CI 守护:Android release manifest 禁明文</h3>
|
||||
<div class="files">Create: ci/scan-cleartext.sh · Modify: .gitea/workflows/ci.yml</div>
|
||||
新增扫描脚本:manifest 一旦重新出现 <code>usesCleartextTraffic="true"</code> 就 CI 失败(防止将来有人把明文开关加回来,退回到 #25 之前的不安全态)。接入 <code>ci.yml</code> 新 job + shellcheck 列表。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 4 · <code>PANGOLIN_PUBLIC_URL</code> 切 https</h3>
|
||||
<div class="files">Modify: deploy/single-node/deploy.sh</div>
|
||||
该变量被嵌进客户端 sing-box 配置当 <code>.srs</code> 分流规则集下载基址(<code>clientconfig.go</code>)。不改的话新客户端仍去明文 IP 拉。改为 <code>https://api.yanmeiai.com</code>;与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。pangolin1 上应用 + 重启 server,验证规则集经隧道可 200 下载。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 5 · 退役明文口(收 loopback + 关防火墙 + 修健康检查)<span class="tag warn">上线顺序闸</span></h3>
|
||||
<div class="files">Modify: deploy/single-node/deploy.sh · scripts/ci/deploy-server.sh</div>
|
||||
<b>此 Task 会切断外部 <code>http://103.119.13.48:8080</code>,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行</b>,执行前需与用户确认「旧客户端可弃」。内容:<code>ADDR</code> 收 <code>127.0.0.1:8080</code>;不再 ufw 放行 8080;<code>deploy-server.sh</code> 健康检查从「runner 远程 curl 公网 IP」改为「ssh 内本地 curl loopback」+「经隧道 curl https 域名」双路验证。验证:明文口不可达、隧道仍活、<code>ss</code> 显示 8080 仅监听 <code>127.0.0.1</code>。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Task 6 · 文档更新(本任务)</h3>
|
||||
<div class="files">Modify: CLAUDE.md · docs/index.html · docs/control-plane-tls-tunnel.html(本页)</div>
|
||||
CLAUDE.md 补充端口/URL 布局说明;生成本 HTML 阅读版并登记 <code>docs/index.html</code>「实现计划」分类;顺带修正 <code>deploy/single-node/deploy.sh</code> 摘要 echo 里残留的旧明文口描述(Task 4/5 落地后的措辞漂移)。
|
||||
</div>
|
||||
|
||||
<h2>上线顺序</h2>
|
||||
<p>
|
||||
Task 1(隧道加法)→ Task 4(<code>PANGOLIN_PUBLIC_URL</code>,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→
|
||||
<b>待客户端更新</b> → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合,可随时并行推进。
|
||||
</p>
|
||||
|
||||
<h2>不在本轮</h2>
|
||||
<ul class="small">
|
||||
<li>#32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。</li>
|
||||
<li>控制面 API 的 CF WAF/rate-limit 规则精调。</li>
|
||||
<li>usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>免费版 10 分钟卡控 + 累加式看广告加时(设计 · #21)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 返回文档索引</a>
|
||||
|
||||
<h1>免费版 10 分钟卡控 + 累加式看广告加时</h1>
|
||||
<p class="sub">设计 · todo #21 · <span class="tag info">后端 + 前端 + 数据库</span> · 额度全账户共享</p>
|
||||
|
||||
<div class="lead">
|
||||
免费版此前<b>形同虚设</b>:连接页无倒计时、时间到不卡控、按钮永远可点。根因在服务端——
|
||||
<code>ConnectNode</code> 免费门每次连接都发<b>固定 <code>daily_minutes×1min</code> TTL、从不扣减已用</b>,
|
||||
重连即崭新 10 分钟,日额度从未真正强制。本次将其改为<b>账户级(全设备共享)真卡控</b> +
|
||||
<b>累加式看广告加时</b>:连接期倒计时、到点自动切断、耗尽按钮灰化、点击弹广告、看完 +N 分钟。
|
||||
</div>
|
||||
|
||||
<h2>目标与口径(已确认)</h2>
|
||||
<ul>
|
||||
<li><b>连接期倒计时</b>:连上后显示剩余 mm:ss,到 0 自动切断隧道。</li>
|
||||
<li><b>耗尽卡控</b>:额度用完 → 连接按钮变灰不可点。</li>
|
||||
<li><b>看广告加时(累加式)</b>:点灰按钮弹广告,看完 <b>+10 分钟</b>(可重复,每日封顶 <b>120 分钟</b>)。</li>
|
||||
<li><b>占位广告 SDK</b>:先跑通假流程(dialog→“播放中”→奖励),服务端用放行式 <code>DevVerifier</code>(nonce 仍防重放),接真 AdMob 时替换。</li>
|
||||
<li><b>桌面(Windows/macOS)</b>:免费 = <b>硬 10 分钟/天不可延</b>,无广告;到点→切断+灰按钮+提示“去移动端看广告或升级”。</li>
|
||||
<li><b>额度全账户共享</b>:非每设备。服务端 <code>usage_daily</code> 本就按 <code>user_id</code> 聚合所有设备的 <code>minutes_used</code>,天然账户级;客户端倒计时仅本地近似,权威始终以 <code>quota_today_min</code> 为准。</li>
|
||||
</ul>
|
||||
|
||||
<h2>数据模型</h2>
|
||||
<div class="card root">
|
||||
<h3>migration 000020_ad_bonus_minutes(sqlite + mysql)</h3>
|
||||
<pre>ALTER TABLE usage_daily ADD COLUMN ad_bonus_minutes INT NOT NULL DEFAULT 0;</pre>
|
||||
<p>当日免费额度 = <code>plans.daily_minutes</code>(free=10)+ <code>usage_daily.ad_bonus_minutes</code>(看广告累加);
|
||||
剩余 = 额度 − <code>minutes_used</code>。历史列 <code>ad_unlocked_at</code>(布尔式当日解锁)保留但不再用于卡控。</p>
|
||||
</div>
|
||||
|
||||
<h2>后端</h2>
|
||||
<table>
|
||||
<tr><th>层</th><th>改动</th></tr>
|
||||
<tr><td><code>usage/store.go</code></td><td><code>AddAdBonusMinutes(uid,day,add,ceiling)</code>:事务内 <code>LockForUpdate</code> 读旧值 → 累加封顶 → upsert,返回<b>新总额 + 本次实际加时</b>。<code>DailyUsage</code>/<code>GetDay</code>/<code>GetUsageRange</code> 补读 <code>ad_bonus_minutes</code>。<code>MarkAdUnlocked</code> 标 deprecated。</td></tr>
|
||||
<tr><td><code>usage/service.go</code></td><td>常量 <code>adBonusPerAd=10</code> / <code>adDailyBonusCeiling=120</code>。<code>TodaySummary</code> 加 <code>MinutesCap</code>(=daily+bonus)/ <code>AdBonusMinutes</code>;<code>remaining=cap−used</code>。<code>UnlockAd</code> 改累加:verify+nonce 后 <code>+adBonusPerAd</code> 封顶,返回 <code>(granted, remaining)</code>。</td></tr>
|
||||
<tr><td><code>usage/ads.go</code> + <code>main.go</code></td><td>放行式 <code>DevVerifier</code>(<code>Verify</code> 恒 nil)。<code>main.go</code> 按 <code>ADS_DEV_MODE</code>/默认装配(替换现在的 <code>nil</code>——否则 <code>UnlockAd</code> 直接 <code>ErrInternal</code>,占位流程走不通)。nonce 防重放仍生效。</td></tr>
|
||||
<tr><td><code>httpapi/nodes.go</code> ConnectNode</td><td>新增 <code>nodes.store.AccountDayMinutes(uid,day)→(used,bonus)</code>。免费门:<code>allowance=daily+bonus</code>,<code>remaining=allowance−used</code>;<code>remaining≤0</code>→拒 <code>QUOTA_EXHAUSTED</code>;否则 <b><code>TTL=remaining×1min</code></b>(凭证到点硬切断兜底,防绕过客户端)。</td></tr>
|
||||
<tr><td><code>httpapi/account.go</code> /me</td><td>补读 <code>ad_bonus_minutes</code>;<code>quota_today_min</code> 分母改 <code>daily+bonus</code>;新增 <code>quota_cap_min</code>(=allowance,客户端进度条分母)。</td></tr>
|
||||
<tr><td><code>usage/handler.go</code></td><td><code>POST /v1/ads/unlock</code>:204 → <b>200</b> 返回 <code>{granted_minutes, minutes_remaining}</code>。</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>前端(四端共享 Dart)</h2>
|
||||
<table>
|
||||
<tr><th>层</th><th>改动</th></tr>
|
||||
<tr><td><code>models/me.dart</code></td><td>加 <code>quotaCapMin</code>(当日总额度)。</td></tr>
|
||||
<tr><td><code>state/quota_provider.dart</code></td><td><code>total=me.quotaCapMin</code>;<code>isExhausted</code>;<code>markExhausted()</code>(倒计时归零本地置耗尽 + 登录态拉 me 校准);<code>watchAd()</code> async 调 <code>/ads/unlock</code>(占位 ad_token=uuid)→ 刷新 me,返回 granted。</td></tr>
|
||||
<tr><td><code>state/connection_provider.dart</code></td><td>连接时锁定 <code>_freeRemainingSec</code>(会员为 null 不倒计时)。复用 elapsed 计时器每 tick 算 <code>countdown=cap−elapsed</code>,写入 <code>ConnectionState.freeCountdown</code>;<b>归零→自动切断</b>(<code>_onFreeQuotaExhausted</code>:主动断开不报节点异常 + <code>markExhausted</code>)。倒计时用墙上时钟,后台漏跳回前台补上、准时切。连接遇后端 <code>QUOTA_EXHAUSTED</code> 兜底置耗尽。</td></tr>
|
||||
<tr><td><code>widgets/connect_button.dart</code></td><td>加 <code>enabled</code>/<code>onDisabledTap</code>:off 态额度耗尽 → 灰化(锁图标),点击走加时流程。</td></tr>
|
||||
<tr><td><code>widgets/quota_card.dart</code></td><td>三态:连接中显示倒计时 mm:ss + 进度收缩;未连接有余额显示剩余分钟;耗尽显示“今日已用完”。移动端「看广告加时」/ 桌面「升级会员」。</td></tr>
|
||||
<tr><td><code>widgets/ad_reward_dialog.dart</code>(新)</td><td>移动端占位广告:“广告播放中…”→3s→调 <code>watchAd</code>→显示“已加 N 分钟”自动关闭。桌面版:升级/移动端提示弹窗(无广告)。</td></tr>
|
||||
<tr><td>l10n(zh/en)</td><td>倒计时/今日已用完/看广告加时/占位广告播放/奖励/桌面升级提示 双语文案。</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>时序</h2>
|
||||
<div class="card">
|
||||
<h3>移动端典型流</h3>
|
||||
<pre>连接 → /me remaining=10 → 倒计时 10:00 …… 00:00
|
||||
→ 客户端 _onFreeQuotaExhausted:切断隧道 + 按钮灰化 + markExhausted
|
||||
点灰按钮 → 占位广告 dialog(3s)→ POST /ads/unlock(DevVerifier 放行 + nonce)
|
||||
→ 服务端 ad_bonus_minutes += 10(封顶 120)→ 返回 granted=10, remaining=10
|
||||
→ 刷新 /me(quota_cap_min=20, quota_today_min=10)→ 按钮恢复可连
|
||||
再次连接 → ConnectNode remaining=allowance−used → TTL=remaining(服务端硬切断兜底)</pre>
|
||||
</div>
|
||||
<p class="small"><b>桌面</b>:同样倒计时 + 到点切断 + 灰按钮,但点击弹“去移动端看广告或升级会员”,<b>无加时路径</b>(硬 10 分钟/天)。</p>
|
||||
|
||||
<h2>验证</h2>
|
||||
<ul>
|
||||
<li>后端单测:<code>AddAdBonusMinutes</code> 累加+封顶(SQLite 实库);<code>TodaySummary</code> cap/remaining;<code>UnlockAd</code> 走 DevVerifier 加时;ConnectNode remaining≤0 拒 / TTL=remaining(集成测试)。</li>
|
||||
<li>客户端:<code>flutter analyze</code> + <code>flutter test</code>(quota 倒计时/耗尽/加时;额度卡三态;/me 契约含 <code>quota_cap_min</code>)。</li>
|
||||
<li>真机:免费连接→倒计时→到 0 自动断+灰按钮;点灰→移动弹占位广告→+10 分钟→恢复可连;桌面到点→灰+升级提示(无广告);同账户两设备共享同一剩余。</li>
|
||||
</ul>
|
||||
|
||||
<h2>不在本轮</h2>
|
||||
<ul>
|
||||
<li>真 AdMob/激励视频 SDK 接入(<code>DevVerifier</code> 占位替换)。</li>
|
||||
<li>广告频次风控细化(现仅每日封顶 <code>adDailyBonusCeiling</code>)。</li>
|
||||
<li>客户端倒计时与服务端 <code>minutes_used</code> 聚合延迟的精确对账(以服务端 TTL 硬切断兜底)。</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>前端设计系统治理重构(ds-flow)· 实现计划</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:29px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 28px}
|
||||
h2{font-size:20px;margin:38px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:22px 0 8px;color:var(--accent)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
a{color:var(--accent2);text-decoration:none} a:hover{text-decoration:underline}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 20px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
.phase{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
|
||||
.phase h2{margin-top:4px;border:none;padding:0;font-size:18px}
|
||||
ul{margin:8px 0;padding-left:22px} li{margin:5px 0}
|
||||
.box{overflow-x:auto;margin:14px 0}
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:560px}
|
||||
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--panel2);color:var(--fg)}
|
||||
.ok{color:var(--ok);font-weight:700} .bad{color:var(--bad);font-weight:700} .warn{color:var(--warn);font-weight:700}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;margin-left:6px}
|
||||
.pill.big{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;background:rgba(94,194,122,.14);color:var(--ok);margin-left:8px;vertical-align:middle}
|
||||
.back{color:var(--fg2);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<p class="back"><a href="index.html">← 文档索引</a></p>
|
||||
<h1>前端设计系统治理重构 <span class="tag">ds-flow</span></h1>
|
||||
<p class="sub">用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源 · 代码镜像 · 静态闸拦漂移 · 双级像素验收兜底」</p>
|
||||
|
||||
<div class="lead">
|
||||
<strong>阅读版</strong>;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。<br>
|
||||
<strong>关键前提</strong>:pangolin <b>不是从零 bootstrap,已约 65% 达标</b>——token 单源(含暗色)、Flutter codegen + drift 闸、golden + CI 闸、pre-commit(写好未启用)都在。本计划是<b>补缺口 + Web 共享原子层去重</b>,非推倒重来。主题保持 <b>light / dark 双主题</b>。
|
||||
</div>
|
||||
|
||||
<h2>现状盘点</h2>
|
||||
<div class="box">
|
||||
<table>
|
||||
<tr><th>维度</th><th>现状</th><th>缺口</th></tr>
|
||||
<tr><td>Token 单源</td><td class="ok">✓ colors_and_type.css(含 [data-theme=dark])</td><td>迁为 ds-flow 原型结构</td></tr>
|
||||
<tr><td>Flutter codegen / 主题层</td><td class="ok">✓ gen 层/实现层分离 + drift 闸</td><td>—</td></tr>
|
||||
<tr><td>Flutter UI 硬编码</td><td class="ok">✓ 零裸 hex(唯 1 处裸色 adaptive_menu)</td><td>清 1 处</td></tr>
|
||||
<tr><td>Flutter golden</td><td class="ok">✓ 36 张 + harness + 真字体 + CI</td><td class="warn">6 张 failure;缺 CJK 测试字体;覆盖不全</td></tr>
|
||||
<tr><td>website / usercenter</td><td class="ok">✓ 179 / 171 处 var(--token)</td><td class="bad">零前端测试</td></tr>
|
||||
<tr><td>跨端共享组件</td><td class="bad">✗ 下拉/按钮/卡片两端各写一遍</td><td>抽 atoms 对齐</td></tr>
|
||||
<tr><td>原型三件套</td><td class="warn">⚠ 有 _ds_manifest/preview/ui_kits</td><td>缺 atoms.css / icons.js / index.html</td></tr>
|
||||
<tr><td>静态闸</td><td class="ok">✓ redline / analyze+test / codegen-drift / golden</td><td class="bad">✗ 硬编码色扫描 / fidelity / Web 同源</td></tr>
|
||||
<tr><td>pre-commit</td><td class="warn">⚠ .githooks 写好</td><td class="bad">默认未启用</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>已定决策</h2>
|
||||
<ul>
|
||||
<li><b>Web 去重</b>:两端<b>各自实现 + 同源闸</b>(不建跨端组件包;成本低、风险小,符合 jiu 取舍)</li>
|
||||
<li><b>ui_kits</b>:jsx/css 端原型<b>收敛为纯 HTML 原型</b>并删副本(消除与「禁向 design/ 提组件代码副本」的冲突)</li>
|
||||
<li><b>节奏</b>:<b>先定稿本计划,再逐刀执行</b>(每刀 commit,tier-1 大改走确认闸)</li>
|
||||
</ul>
|
||||
|
||||
<h2>执行阶段(6 阶段)</h2>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 0 — 更新 CLAUDE.md + 计划落库</h2>
|
||||
<ul>
|
||||
<li>CLAUDE.md 补「前端设计系统治理(ds-flow)」章节:原型单源位置、codegen 命令、L1/L2/L3 三层规则、四道静态闸 +「违规谁拦」对照表、golden/fidelity 双闸定位</li>
|
||||
<li>本计划 .md 定稿 + HTML 阅读版 + 登记 docs/index.html</li>
|
||||
<li>/todo 建 tier-1 条目 + 6 子任务</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 1 — 原型单源三件套(design/prototype/)</h2>
|
||||
<p class="small">把散在 ui_kits / preview / _ds_manifest.json 的东西收敛为 ds-flow 标准三件套。</p>
|
||||
<ul>
|
||||
<li><code>serve.mjs</code> 照搬 jiu(零依赖热重载)</li>
|
||||
<li><code>tokens.css</code>:现有 token <b>数值不变</b>,重排为「基础 :root 标量 + [data-theme=dark] 颜色覆盖」结构</li>
|
||||
<li><code>atoms.css</code>:按钮/卡片/输入/<b>语言下拉</b>/徽章/状态药丸公用原子(只引 var(--token))</li>
|
||||
<li><code>icons.js</code>:SVG sprite 单源,收敛 website / usercenter / Flutter 三处图标集</li>
|
||||
<li><code>index.html</code>:活登记页——light/dark 切换 + 声明式色板 + 全组件/图标展示卡(每 atom 必登记)</li>
|
||||
<li>ui_kits jsx 副本提炼后<b>删除</b>;屏级布局参考迁 <code>prototype/screens/</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 2 — Web token 升为一等公民 + 同源闸</h2>
|
||||
<ul>
|
||||
<li>两端 token 落点统一指向原型 tokens.css(website→tokens.gen.css / usercenter→public/colors_and_type.css)</li>
|
||||
<li><code>tools/check-l1-sync.mjs</code>(照搬 jiu 裁剪):website / usercenter token 值逐值同源 + icons 同集 + Web hex 白名单扫描</li>
|
||||
<li>build-tokens 幂等:重跑零 diff(纳入 CI)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 3 — Web 共享原子层对齐 <span class="pill big">工作量最大</span></h2>
|
||||
<p class="small">各自实现 + 同源闸:两端对齐同一 atoms.css,靠闸防漂移。</p>
|
||||
<ul>
|
||||
<li>抽公共原子:langsel / button / card / input / badge / pill → atoms.css canonical</li>
|
||||
<li>website:website.css/site-extra.css 对齐 atoms 语义,非白/黑/logo 硬编码清零</li>
|
||||
<li>usercenter:shared.tsx 的 card/input/LangSeg 对齐 atoms 语义,13 处硬编码核对</li>
|
||||
<li>两端 langsel 一致性纳入登记;更新 CONTRACT.md(Web 原子清单 + 屏级三态台账)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 4 — Flutter 收尾 + golden 补齐</h2>
|
||||
<ul>
|
||||
<li>清 adaptive_menu.dart 唯 1 处裸色 → token</li>
|
||||
<li>测试字体补 CJK 子集(make-cjk-subset.sh → client/test/fonts + flutter_test_config 注册)</li>
|
||||
<li>处理现存 6 张 failure diff,逐张确认后 --update-goldens 重录</li>
|
||||
<li>golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵</li>
|
||||
<li>harness 对齐 jiu:多主题循环 + 钉死 viewport/dpr + ProviderScope 固定数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检</h2>
|
||||
<ul>
|
||||
<li>硬编码色扫描:Flutter <code>check_ds_code.mjs</code>(含 --changed)+ Web hex 并入 check-l1-sync</li>
|
||||
<li>原型校验 <code>check-ds.mjs</code>(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)</li>
|
||||
<li>CI 串起来:原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(补 mobile+主题)</li>
|
||||
<li>启用 pre-commit:install-hooks 纳入文档,增挂 check-ds --changed(条件触发)</li>
|
||||
<li>fidelity 像素闸(本地体检,不进 CI):screens.mjs + fidelity.mjs,逐屏阈值=实测残差+2pp</li>
|
||||
<li>全景文档 docs/frontend-overview.html(照搬 jiu 十节)+ 登记索引</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Verification(端到端)</h2>
|
||||
<ul>
|
||||
<li><b>原型</b>:serve.mjs 逐屏目检 light/dark;check-ds 12 道全绿</li>
|
||||
<li><b>同源</b>:check-l1-sync 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)</li>
|
||||
<li><b>Flutter</b>:analyze + test(golden ×双主题);check_ds_code 绿;codegen 重跑零 diff</li>
|
||||
<li><b>Web</b>:两端 build 通过;token 同源绿;langsel/button/card 对齐 atoms</li>
|
||||
<li><b>fidelity</b>:逐屏残差在阈内(首次校准记录实测值)</li>
|
||||
<li><b>闸生效</b>:install-hooks 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下</li>
|
||||
</ul>
|
||||
|
||||
<h2>不在本轮</h2>
|
||||
<ul>
|
||||
<li>新功能 / 新屏开发(本轮是治理重构)</li>
|
||||
<li>iOS/iPad 专属布局深度优化(响应式已覆盖)</li>
|
||||
<li>三主题扩展(保持 light/dark)</li>
|
||||
</ul>
|
||||
|
||||
<p class="small" style="margin-top:32px">真相源(含 checkbox 执行跟踪):<code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pangolin 前端全景(ds-flow 设计系统治理)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:44px 24px 96px}
|
||||
h1{font-size:28px;margin:0 0 6px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);margin:0 0 26px}
|
||||
h2{font-size:19px;margin:36px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:15.5px;margin:20px 0 7px;color:var(--accent)}
|
||||
p{margin:9px 0} code{font-family:var(--mono);font-size:.85em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
a{color:var(--accent2);text-decoration:none} a:hover{text-decoration:underline}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:0 0 22px}
|
||||
ul{margin:8px 0;padding-left:22px} li{margin:5px 0}
|
||||
.box{overflow-x:auto;margin:12px 0}
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:600px}
|
||||
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--panel2)} .ok{color:var(--ok);font-weight:700} .warn{color:var(--warn);font-weight:700}
|
||||
.flow{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 18px;font-family:var(--mono);font-size:13px;white-space:pre;overflow-x:auto;color:var(--fg2)}
|
||||
.back{color:var(--fg2);font-size:13px}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;background:rgba(94,194,122,.14);color:var(--ok);margin-left:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<p class="back"><a href="index.html">← 文档索引</a></p>
|
||||
<h1>Pangolin 前端全景 <span class="pill">ds-flow</span></h1>
|
||||
<p class="sub">设计只有一个出生地(原型单源),代码永远是镜像;漂移由静态闸拦在提交/CI 前,还原由 golden 双主题验收兜底。</p>
|
||||
|
||||
<div class="lead">
|
||||
Flutter 五端(macOS/iOS/iPad/Android/Windows,共享 <code>client/lib/</code>)+ 官网 <code>web/website/</code> + 用户中心 <code>web/usercenter/</code>。主题:<b>light / dark 双主题</b>。治理落地见 <a href="frontend-ds-refactor-plan.html">实现计划</a>。
|
||||
</div>
|
||||
|
||||
<h2>① 一次 UI 改动的标准路径</h2>
|
||||
<div class="flow">加/改令牌 → 只改 design/prototype/tokens.css → codegen(Flutter gen_flutter_tokens / Web gen:tokens)
|
||||
加/改原子 → design/prototype/atoms.css 定义 + index.html 登记 → 落 canonical 实现(client/lib/widgets 或 web/*)
|
||||
加/改图标 → design/prototype/icons.js sprite 登记 → 三端只从此集取
|
||||
改完自检 → check-codegen-drift · check-l1-sync · check_ds_code · check-ds 四道闸 + flutter test(golden)
|
||||
评审原型 → node design/prototype/serve.mjs → http://localhost:5180/(给 URL,不截图)</div>
|
||||
|
||||
<h2>② 目录地图</h2>
|
||||
<div class="box"><table>
|
||||
<tr><th>层</th><th>位置</th><th>角色</th></tr>
|
||||
<tr><td>原型单源(L1 真源)</td><td><code>design/prototype/</code></td><td>tokens.css · atoms.css · icons.js · index.html 登记页 · serve.mjs</td></tr>
|
||||
<tr><td>令牌 codegen</td><td><code>design/codegen/gen_flutter_tokens.mjs</code> · <code>web/*/scripts/build-tokens.mjs</code></td><td>tokens.css → Flutter .gen.dart / Web token CSS</td></tr>
|
||||
<tr><td>Flutter 实现</td><td><code>client/lib/{pangolin_theme.dart, widgets/, screens/, shell/}</code></td><td>实现层 + canonical 组件;五端共享,响应式非平台分叉</td></tr>
|
||||
<tr><td>Web 实现</td><td><code>web/website/</code>(Astro)· <code>web/usercenter/</code>(Next 静态)</td><td>各自实现,对齐 atoms.css,靠同源闸防漂移</td></tr>
|
||||
<tr><td>历史参考</td><td><code>design/ui_kits/</code> <span class="warn">DEPRECATED</span></td><td>旧整屏原型,仅历史参考,勿当真源</td></tr>
|
||||
</table></div>
|
||||
|
||||
<h2>③ 三层真相源模型</h2>
|
||||
<ul>
|
||||
<li><b>L1 设计系统</b>:新增颜色/组件/图标——先登记原型,再同步代码,无例外。</li>
|
||||
<li><b>L2 屏级三态</b>(台账 <code>design/CONTRACT.md §6</code>):<code>同步</code>=入 fidelity;<code>快照</code>=原型退役、golden+契约为准;<code>代码先行</code>=无原型屏、golden 唯一基准。当前:Flutter 屏=快照,Web 屏=代码先行,原子层=同步。</li>
|
||||
<li><b>L3 新屏/改版</b>:design-first——原型 → serve 评审 → 契约 → 实现 → 验收 → 入同步态。</li>
|
||||
</ul>
|
||||
|
||||
<h2>④ 令牌 codegen(颜色单源落地)</h2>
|
||||
<p><code>design/prototype/tokens.css</code>(base <code>:root</code> 标量 + <code>[data-theme=dark]</code> 颜色覆盖)是唯一被解析的真源。<code>colors_and_type.css</code> 已降级为薄 <code>@import</code> 别名。Flutter 生成 <code>pangolin_tokens.gen.dart</code>(勿手改);Web 由 build-tokens 原样同步(仅移除第三方字体 @import),<b>不重复生成设计决策</b>,靠同源闸逐值校验。</p>
|
||||
|
||||
<h2>⑤ 四道静态闸 —「违规谁拦」</h2>
|
||||
<div class="box"><table>
|
||||
<tr><th>闸</th><th>拦什么</th><th>何时</th><th>状态</th></tr>
|
||||
<tr><td>原型校验 <code>design/prototype/tools/check-ds.mjs</code></td><td>硬编码色(atoms.css)/未定义 token/字体/图标未走 sprite/原子未登记</td><td>pre-commit(动原型)+ CI</td><td class="ok">✓</td></tr>
|
||||
<tr><td>跨端同源 <code>tools/check-l1-sync.mjs</code></td><td>Web token 值≡原型 · 三端图标⊆原型 sprite · Web 硬编码色</td><td>pre-commit(动原型/web)+ CI</td><td class="ok">✓</td></tr>
|
||||
<tr><td>代码色单源 <code>client/tool/check_ds_code.mjs</code></td><td>Flutter 裸 <code>Color(0x)</code>/具名 <code>Colors.x</code>(<code>ds-ignore</code> 豁免)</td><td>pre-commit(--changed)+ CI(--strict)</td><td class="ok">✓</td></tr>
|
||||
<tr><td>codegen 零 diff <code>ci/check-codegen-drift.sh</code></td><td>重生成 token 后 git diff 非空即 fail</td><td>pre-commit + CI</td><td class="ok">✓</td></tr>
|
||||
</table></div>
|
||||
<p>CI(<code>.gitea/workflows/ci.yml</code>)的 <code>ds-flow</code> job 串起前三道;<code>codegen-drift</code> job 管第四道。pre-commit(<code>.githooks/pre-commit</code>,一次性 <code>bash ci/install-hooks.sh</code> 启用)跑条件化快子集。</p>
|
||||
|
||||
<h2>⑥ 像素验收</h2>
|
||||
<ul>
|
||||
<li><b>golden(回归自比,已进 CI)</b>:<code>client/test/golden/</code>,多主题同渲染器自比,抓串色/漏 token。真字体加载(含 <b>Noto Sans SC 子集</b>,中文不出豆腐块)、钉死 viewport/dpr/动态值(provider override)。基线在权威 Linux 容器生成:<code>bash scripts/update-goldens.sh</code>。当前 34 tests 全绿(components/auth/desktop/tablet × 双主题,tablet 含 zh/en)。</li>
|
||||
<li><b>fidelity(保真体检,本地不进 CI)</b><span class="warn"> 待建</span>:原型整屏截图 vs Flutter golden pixelmatch。<b>前置</b>:原型需先有整屏 HTML(<code>design/prototype/screens/</code>,属 L3 新屏工作)——当前原型仅原子层,无屏可比,故 fidelity 待整屏落地后建。</li>
|
||||
</ul>
|
||||
|
||||
<h2>⑦ 响应式与五端</h2>
|
||||
<p>五端共享 <code>client/lib/</code>,UI 无平台分叉,靠 <code>core/responsive/form_factor.dart</code>(<code>mobile/tablet/desktop</code> 按宽度+平台判定)。平台差异隔离在 bridge/update/tray 等系统集成层,非 UI。</p>
|
||||
|
||||
<h2>⑧ 规则速查(硬红线)</h2>
|
||||
<ul>
|
||||
<li>颜色只走语义 token;<code>colors_and_type.css</code> 勿加变量(改 <code>prototype/tokens.css</code>)。</li>
|
||||
<li>加原子/图标先登记原型再落代码;勿向 <code>design/</code> 提 Dart/TS 组件副本。</li>
|
||||
<li>文案脱敏:禁 VPN/翻墙/科学上网等红线词(<code>ci/scan-redline.sh</code> 守护)。</li>
|
||||
<li>硬编码色例外(<code>#fff/#000</code>/品牌 logo 色)加 <code>// ds-ignore: 理由</code> 或列白名单。</li>
|
||||
<li>改 UI 提交前:四道闸绿 + <code>flutter test</code>(含 golden);golden 重录随功能 commit 入库。</li>
|
||||
</ul>
|
||||
|
||||
<h2>⑨ 文档索引</h2>
|
||||
<ul>
|
||||
<li><a href="frontend-ds-refactor-plan.html">前端设计系统治理重构 · 实现计划</a>(真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>)</li>
|
||||
<li>根 <code>CLAUDE.md</code>「## 前端设计系统治理(ds-flow)」· <code>design/CLAUDE.md</code>(设计铁律 + 真源对照)· <code>design/CONTRACT.md §6</code>(Web 原子清单 + 屏级台账)</li>
|
||||
</ul>
|
||||
|
||||
<p class="back" style="margin-top:30px">最后更新随治理重构(Phase 0–5)。fidelity(⑥)与 mobile golden 扩容为后续项。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -44,6 +44,21 @@
|
||||
</div>
|
||||
|
||||
<h2>设计方案 / Specs</h2>
|
||||
<a class="doc" href="cicd-design.html">
|
||||
<div class="t">CI/CD 全流程(tag 触发编译/发版/部署)<span class="tag html">HTML</span></div>
|
||||
<div class="d">#30。参考 jiu 的 scripts/ci + .gitea/workflows:tag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。</div>
|
||||
<div class="path">docs/cicd-design.html · 真相源 docs/superpowers/specs/2026-07-05-cicd-design.md</div>
|
||||
</a>
|
||||
<a class="doc" href="contact-telegram-channels-design.html">
|
||||
<div class="t">联系我们 · 渠道二级页(Telegram 频道/群组,DB 配置)<span class="tag html">HTML</span></div>
|
||||
<div class="d">点 Telegram 进二级页,按 kind 分组(频道/群组/Bot)列多条链接,含 @handle/说明/认证/「打开·加入」动作,全渠道(含邮箱/发卡)统一一张 contact_link 表(靠 url 协议区分 mailto/https/tg)。独立 GET /v1/contact。规则:平台 >1 条链接才进二级,否则点击直达。LINE 同构但先灰置「即将开放」(registry comingSoon 开关,配好即去灰)。不展示成员数、群组=加入。含可点原型。</div>
|
||||
<div class="path">docs/contact-telegram-channels-design.html</div>
|
||||
</a>
|
||||
<a class="doc" href="free-quota-ad.html">
|
||||
<div class="t">免费版 10 分钟卡控 + 累加式看广告加时 <span class="tag html">HTML</span></div>
|
||||
<div class="d">免费版真卡控(账户级/全设备共享):连接期倒计时 + 到点自动切断 + 耗尽按钮灰化 + 点击弹广告看完 +10 分钟(累加,每日封顶 120)。桌面硬 10 分钟不可延。服务端 ad_bonus_minutes 累加模型 + ConnectNode 按 remaining 卡控 + TTL 硬切断;占位 DevVerifier。#21。</div>
|
||||
<div class="path">docs/free-quota-ad.html</div>
|
||||
</a>
|
||||
<a class="doc" href="device-limit-design.html">
|
||||
<div class="t">设备数量限制 + 超限 UX <span class="tag html">HTML</span></div>
|
||||
<div class="d">真正启用套餐设备上限(free 1 / pro 3 / team 10):卡在登录(非硬拒登,返回 device_limit 信号)+ 选择移除/一键踢最旧 + 服务端按 last_seen 自动清理久不活跃。复用现成 DeleteDevice/ResolvePlan。无 DB schema 变更。#16。</div>
|
||||
@@ -71,6 +86,21 @@
|
||||
</a>
|
||||
|
||||
<h2>实现计划 / Plans</h2>
|
||||
<a class="doc" href="frontend-ds-refactor-plan.html">
|
||||
<div class="t">前端设计系统治理重构(ds-flow 全端)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。<b>非从零 bootstrap(已约 65% 达标)</b>:补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。</div>
|
||||
<div class="path">docs/frontend-ds-refactor-plan.html · 真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
|
||||
</a>
|
||||
<a class="doc" href="control-plane-tls-tunnel.html">
|
||||
<div class="t">控制面 TLS(Cloudflare Tunnel 前置)实现计划 <span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(含 checkbox)。把控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到 <code>https://api.yanmeiai.com</code>(cloudflared 出站隧道前置,源站仅绑 127.0.0.1,数据面 sing-box REALITY :443 全程不动)。6 任务:CF Tunnel 供给 → 客户端切 https/Android 去明文 → CI 守护禁明文 → PANGOLIN_PUBLIC_URL 切 https → 退役明文口(收 loopback,带上线顺序闸)→ 文档。</div>
|
||||
<div class="path">docs/control-plane-tls-tunnel.html · 真相源 docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</div>
|
||||
</a>
|
||||
<a class="doc" href="cicd-plan.html">
|
||||
<div class="t">CI/CD 全流程 实现计划(#30)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。</div>
|
||||
<div class="path">docs/cicd-plan.html · 真相源 docs/superpowers/plans/2026-07-05-cicd.md</div>
|
||||
</a>
|
||||
<a class="doc" href="device-session-management-plan.html">
|
||||
<div class="t">设备 & 会话管理 + 每设备流量归因 实现计划(P1–P6)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-29-device-session-management.md</code>(含 checkbox)。P1 设备注册打通 → P2 sessions表+在线/最后登录 → P3 强制退出/清除 → P4 每设备流量 → P5 2FA信任(future) → P6 UI重做。</div>
|
||||
@@ -103,6 +133,16 @@
|
||||
<div class="d">Task 8 终验:server/client 全量测试矩阵结果(含新增 SQLite 文件库带数据升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM)、OpenAPI 新端点登记、Self-Review 取舍、联调 checklist、部署附录(pay 种子/biz 配置/pangolin env/迁移顺序)。附带发现一处既有的 <code>wangjia/codes</code> 本地路径依赖会阻断异机构建,登记为部署前置阻断项。</div>
|
||||
<div class="path">docs/pay-v2-integration-delivery.html</div>
|
||||
</a>
|
||||
<a class="doc" href="frontend-overview.html">
|
||||
<div class="t">前端全景(ds-flow 设计系统治理)<span class="tag html">HTML</span></div>
|
||||
<div class="d">Flutter 五端 + 官网 + 用户中心的设计系统治理全景:一次 UI 改动标准路径、目录地图、三层真相源模型、令牌 codegen、四道静态闸「违规谁拦」、像素验收(golden 双主题 + fidelity 待建)、响应式五端、规则速查。原型单源 design/prototype/(tokens/atoms/icons/index.html)、check-ds/check-l1-sync/check_ds_code/codegen-drift 四闸进 CI、golden 全量 34 绿含 CJK。</div>
|
||||
<div class="path">docs/frontend-overview.html</div>
|
||||
</a>
|
||||
<a class="doc" href="code-review-2026-07.html">
|
||||
<div class="t">全栈设计审查 2026-07(前端/后端/数据库)<span class="tag html">HTML</span></div>
|
||||
<div class="d">核心链路精读式审查,13 项发现分 P0/P1/P2:明文 HTTP、SQLite 零备份(P0);同机换账号 403 死结、disconnect 撤错凭证、用量取走即焚、Redis 重启全员掉线、argon2id OOM(P1);留存/UTC 日界/三时钟口径等(P2)。附「做得好的」与处理顺序建议。</div>
|
||||
<div class="path">docs/code-review-2026-07.html</div>
|
||||
</a>
|
||||
<a class="doc" href="dev-conventions.html">
|
||||
<div class="t">开发规范 · 可测试性五支柱 <span class="tag html">HTML</span></div>
|
||||
<div class="d">「怎么写才好测」——开发规范作为可测试性前置条件。五支柱(接缝即接口/契约单源/纯逻辑分离/错误是值/可观测)+ 支柱↔测试层咬合矩阵图 + 反例→真实bug→对应支柱对照表。与测试框架文档咬合。</div>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Pangolin CI/CD 全流程 Implementation Plan(#30)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** tag 触发的「编译 → 测试 → 发版(Gitea release)→ 部署」全自动流水线,覆盖官网 + 服务端 + Android/macOS/Windows 客户端。
|
||||
|
||||
**Architecture:** 镜像 jiu 的 `scripts/ci/*.sh`(逻辑)+ `.gitea/workflows/*.yml`(编排)。逻辑放脚本便于本地复现,工作流只调脚本。runner 混合:nas(官网+服务端,容器化 `node:20`/`golang:1.25`)、mac(Android+macOS)、windows(Windows)。
|
||||
|
||||
**Tech Stack:** Gitea Actions(act_runner,host-mode)、Bash、Astro/Node、Go 交叉编译、Flutter、gomobile libbox、Xcode notarytool、Inno Setup、Forgejo release API。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 参考真相源:`docs/superpowers/specs/2026-07-05-cicd-design.md`;jiu 的 `~/code/jiu/.gitea/workflows/*` + `~/code/jiu/scripts/ci/*`(proven,copy+adapt)。
|
||||
- runner label:`nas` / `mac` / `windows`;nas 上每 job `docker run` 官方镜像(`node:20`、`golang:1.25`),不装宿主工具链。
|
||||
- 国内镜像:`GOPROXY=https://goproxy.cn,direct`;`PUB_HOSTED_URL=https://pub.flutter-io.cn`;`FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn`。
|
||||
- Secrets(已建,命名对齐 jiu):账户级 `FORGEJO_TOKEN`/`FORGEJO_URL`/`MACOS_DEVELOPER_ID_CERT_P12_BASE64`/`MACOS_DEVELOPER_ID_CERT_PASSWORD`/`APPSTORE_API_KEY_P8_BASE64`/`APPSTORE_API_KEY_ID`/`APPSTORE_API_ISSUER_ID`;pangolin 仓库级 `DEPLOY_SSH_KEY`/`ANDROID_KEYSTORE_BASE64`/`ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASSWORD`/`MACOS_APP_PROVISION_PROFILE_BASE64`/`MACOS_SYSEXT_PROVISION_PROFILE_BASE64`。
|
||||
- 客户端铁律:macOS 每次构建递增 `CURRENT_PROJECT_VERSION`;Android NDK≥28、gomobile JDK17、libbox 包名 `io.nekohasekai.libbox`。
|
||||
- Bash:禁 `$()` 命令替换(拆分/管道);提交 footer 带 `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`。
|
||||
- **CI 脚本的「测试」= `workflow_dispatch` 手动触发跑一遍 + 观察产物/部署结果**(非经典单元 TDD);每条流水线先手动 dispatch 跑通再依赖 tag。
|
||||
- 部署机 pangolin1 别名 `pangolin1`(103.119.13.48);官网域名 `pangolin.yanmeiai.com`。
|
||||
|
||||
---
|
||||
|
||||
# Phase 1 —— 基座 + 官网 + 服务端(无签名,可立即上线)
|
||||
|
||||
### Task 1: `scripts/ci/` 基座(env + forgejo 库 + 通知)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/ci/_env.sh`、`scripts/ci/lib-forgejo.sh`、`scripts/ci/notify.sh`
|
||||
- 参照:`~/code/jiu/scripts/ci/_env.sh`、`~/code/jiu/scripts/ci/lib-forgejo.sh`、`~/code/jiu/scripts/ci/notify.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:`_env.sh` 导出 `GOPROXY`/`PUB_HOSTED_URL`/`FLUTTER_STORAGE_BASE_URL` + `ver_from_tag <prefix> <ref>`(解析 `server-v1.2.3` → `1.2.3`);`lib-forgejo.sh` 提供 `forgejo_release_ensure <tag> <title>`、`forgejo_upload_asset <tag> <file>`(用 `FORGEJO_TOKEN`/`FORGEJO_URL`,curl+API);`notify.sh` 提供 `notify_ok`/`notify_fail`。
|
||||
|
||||
- [ ] Step 1:抄 jiu 三个脚本到 `scripts/ci/`,把 ali/jiu 专属值替换为 pangolin(仓库名、域名);`ver_from_tag` 用 `${ref#refs/tags/${prefix}-v}` 参数展开(不 `$()`)。
|
||||
- [ ] Step 2:`chmod +x scripts/ci/*.sh`;本地 `bash -n` 语法检查每个脚本。Expected:无输出(语法 OK)。
|
||||
- [ ] Step 3:`shellcheck scripts/ci/*.sh`。Expected:0 告警(或仅可接受的 info)。
|
||||
- [ ] Step 4:Commit `feat(ci): scripts/ci 基座(_env/lib-forgejo/notify)`。
|
||||
|
||||
### Task 2: checks 工作流(保留现有)
|
||||
|
||||
**Files:** Modify(可选 rename): `.gitea/workflows/ci.yml`
|
||||
|
||||
- [ ] Step 1:确认现有 `ci.yml`(nas,shellcheck/openapi/redline/flutter analyze+test/go test)仍覆盖需求;把新增的 `scripts/ci/*.sh` 纳入 shellcheck job 的扫描路径。
|
||||
- [ ] Step 2:push 一个无关小改到分支,观察 checks 全绿。Expected:所有 job pass。
|
||||
- [ ] Step 3:Commit(若有改动)`ci(checks): shellcheck 覆盖 scripts/ci`。
|
||||
|
||||
### Task 3: 官网发布(compile + deploy + workflow)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/ci/compile-site.sh`、`scripts/ci/deploy-site.sh`、`.gitea/workflows/deploy-site.yml`
|
||||
- 参照:`~/code/jiu/scripts/ci/compile-site.sh`、`deploy-site.sh`、`.gitea/workflows/deploy-site.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes:`_env.sh`;secret `DEPLOY_SSH_KEY`。
|
||||
- Produces:`pangolin.yanmeiai.com` 静态站上线。
|
||||
|
||||
**前置(基础设施,需先做 / 确认——改机器前问用户):**
|
||||
- pangolin1 上装 nginx(或 caddy),配 `pangolin.yanmeiai.com` vhost,web 根如 `/var/www/pangolin-site`。
|
||||
- CF DNS:`pangolin.yanmeiai.com` A/CNAME → 103.119.13.48(用 `cf-api`,记 baize)。
|
||||
- TLS:先 HTTP 起,证书并入 #25 或用 CF proxy 橙云。
|
||||
|
||||
- [ ] Step 1:写 `compile-site.sh` —— 在 `node:20` 容器内 `cd web/website && npm ci && SITE_URL=https://pangolin.yanmeiai.com npm run build`,产物 `web/website/dist/`。
|
||||
- [ ] Step 2:写 `deploy-site.sh` —— 用 `DEPLOY_SSH_KEY` 起 ssh agent,`rsync -az --delete web/website/dist/ pangolin1:/var/www/pangolin-site/`;远端 `nginx -s reload` 非必需(静态文件即时生效)。
|
||||
- [ ] Step 3:写 `deploy-site.yml` —— `on.push.tags: ['site-v[0-9]*.[0-9]*.[0-9]*']` + `workflow_dispatch`;`runs-on: nas`;并发组 `deploy-site`;steps: checkout → `docker run --rm -v $PWD:/w -w /w node:20 bash scripts/ci/compile-site.sh` → `bash scripts/ci/deploy-site.sh`。
|
||||
- [ ] Step 4:**手动验证** —— `workflow_dispatch` 触发 deploy-site;`curl -I https://pangolin.yanmeiai.com/` 返回 200,页面 canonical 正确。Expected:站点可访问。
|
||||
- [ ] Step 5:Commit `feat(ci): 官网 site-v* 构建+部署到 pangolin.yanmeiai.com`。
|
||||
|
||||
### Task 4: 服务端发布(compile + release + deploy + workflow)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/ci/compile-backend.sh`、`scripts/ci/release-server.sh`、`scripts/ci/deploy-server.sh`、`.gitea/workflows/deploy-server.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes:`_env.sh`、`lib-forgejo.sh`;secret `DEPLOY_SSH_KEY`、`FORGEJO_TOKEN`/`FORGEJO_URL`。
|
||||
- Produces:pangolin1 上 `pangolin-server`/`pangolin-agent`/`pangolin-migrate` 更新到 tag 版本,migrate 已应用。
|
||||
|
||||
- [ ] Step 1:写 `compile-backend.sh` —— `golang:1.25` 容器内 `cd server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-server ./cmd/server`(同样出 agent、migrate);产物 `server/out/`。
|
||||
- [ ] Step 2:写 `release-server.sh` —— `forgejo_release_ensure "$TAG" "server $TAG"` + 逐个 `forgejo_upload_asset`。
|
||||
- [ ] Step 3:写 `deploy-server.sh`(固化 F3/F4 手动次序,**带回滚**):
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
DB=/var/lib/pangolin/pangolin.db; BIN=/usr/local/bin; TAG="$1"
|
||||
scp server/out/pangolin-server server/out/pangolin-agent server/out/pangolin-migrate pangolin1:/tmp/
|
||||
ssh pangolin1 "bash -s" <<REMOTE
|
||||
set -euo pipefail
|
||||
systemctl stop pangolin-server
|
||||
runuser -u pangolin -- sqlite3 "$DB" 'PRAGMA wal_checkpoint(TRUNCATE);'
|
||||
cp -p "$DB" "$DB.bak-pre-$TAG"
|
||||
if ! runuser -u pangolin -- env DB_DRIVER=sqlite DB_DSN=$DB /tmp/pangolin-migrate up; then
|
||||
echo "!! migrate 失败,回滚"; cp -p "$DB.bak-pre-$TAG" "$DB"; systemctl start pangolin-server; exit 1
|
||||
fi
|
||||
cp -p "$BIN/pangolin-server" "$BIN/pangolin-server.bak-$TAG" || true
|
||||
install -m755 /tmp/pangolin-server "$BIN/pangolin-server"
|
||||
install -m755 /tmp/pangolin-agent "$BIN/pangolin-agent"
|
||||
install -m755 /tmp/pangolin-migrate "$BIN/pangolin-migrate"
|
||||
systemctl start pangolin-server
|
||||
systemctl is-active pangolin-server
|
||||
REMOTE
|
||||
curl -fsS -m 10 --retry 5 --retry-connrefused http://103.119.13.48:8080/healthz >/dev/null && echo "healthz OK"
|
||||
```
|
||||
- [ ] Step 4:写 `deploy-server.yml` —— tag `server-v*` + dispatch;`runs-on: nas`;并发组 `deploy-server`;steps: checkout → compile(golang 容器)→ `test.sh server`(见 Task 5)→ `release-server.sh` → `deploy-server.sh $VER`。
|
||||
- [ ] Step 5:**手动验证** —— 打 tag `server-v0.0.1-ci`(或 dispatch)→ 观察:migrate 版本前进、`/healthz` 200、`sqlite3 nodes` 行数守恒(同 F3 核对法)。Expected:部署成功、无数据丢失。
|
||||
- [ ] Step 6:Commit `feat(ci): 服务端 server-v* 编译+release+部署(备份/迁移/回滚)`。
|
||||
|
||||
### Task 5: `scripts/ci/test.sh`
|
||||
|
||||
**Files:** Create `scripts/ci/test.sh`
|
||||
|
||||
- [ ] Step 1:`test.sh server` → `golang:1.25` 容器 `cd server && go test ./...`;`test.sh client` → `flutter test`(容器或 nas flutter)。
|
||||
- [ ] Step 2:接入 deploy-server.yml 的 test 步骤;dispatch 跑通。Expected:go test 全绿才继续部署。
|
||||
- [ ] Step 3:Commit `ci: test.sh(go test / flutter test)`。
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 —— D Android(解锁官网下载链接)
|
||||
|
||||
### Task 6: Android gradle 接 release 签名
|
||||
|
||||
**Files:** Modify `client/android/app/build.gradle(.kts)`、Create `client/android/keystore.properties`(gitignore,占位)
|
||||
|
||||
**Interfaces:** Consumes secrets `ANDROID_KEYSTORE_BASE64`/`ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASSWORD`。
|
||||
|
||||
- [ ] Step 1:`build.gradle` 加 `signingConfigs.release`,从环境变量/`keystore.properties` 读 keystore 路径与三密码;`buildTypes.release.signingConfig = signingConfigs.release`。参照 jiu 的 android 签名接法。
|
||||
- [ ] Step 2:本机用真 keystore(你已建)`flutter build apk --release` 验证签名生效:`apksigner verify --print-certs build/app/outputs/flutter-apk/app-release.apk` 显示 CN=Pangolin。Expected:release 签名(非 debug)。
|
||||
- [ ] Step 3:Commit `build(android): release keystore 签名接线`。
|
||||
|
||||
### Task 7: Android CI(compile + release + workflow)
|
||||
|
||||
**Files:** Create `scripts/ci/compile-android.sh`、`scripts/ci/release-client.sh`、`.gitea/workflows/build-android.yml`;参照 jiu `compile-android.sh`/`release-client.sh`/`deploy-client.yml`。
|
||||
|
||||
- [ ] Step 1:`compile-android.sh` —— `bash scripts/build-libbox.sh android`(JDK17/NDK≥28)→ 把 secrets 落成 keystore 文件 + `keystore.properties` → `flutter build apk --release --split-per-abi --dart-define=PANGOLIN_API_URL=...` → 产物 `app-arm64-v8a-release.apk`。
|
||||
- [ ] Step 2:`release-client.sh` —— `forgejo_release_ensure client-$VER` + 上传该端资产(多端共用一个 `client-v*` release,各自 upload)。
|
||||
- [ ] Step 3:`build-android.yml` —— tag `client-v*` + dispatch;`runs-on: mac`;steps:provision-mac → compile-android → release-client。
|
||||
- [ ] Step 4:**手动验证** —— dispatch → release 出现 arm64 apk → 真机 `adb install -r` 成功、能连。Expected:签名 apk 可装可用。
|
||||
- [ ] Step 5:Commit `feat(ci): Android client-v* 构建+release`。
|
||||
|
||||
### Task 8: 官网下载链接接 Android release
|
||||
|
||||
**Files:** Modify `web/website/src/config/site.ts`(加 `downloads.android`)、`web/website/src/components/Download.astro`。
|
||||
|
||||
- [ ] Step 1:`site.ts` 加 `downloads: { android: '<Forgejo latest-download 稳定 URL 或构建期烘焙>' }`;`Download.astro` android 按钮 `href={SITE.downloads.android}`。先探 Forgejo 是否支持 `/releases/latest/download/<asset>`;不支持则 `deploy-site.sh` 构建期用 `FORGEJO_TOKEN` 查最新 `client-v*` 版本注入。
|
||||
- [ ] Step 2:重部署官网,点 Android 下载按钮落到最新 apk。Expected:下载可用。
|
||||
- [ ] Step 3:Commit `feat(website): Android 下载按钮接 release 资产`;更新 todo 子任务 30A(Android 部分)。
|
||||
|
||||
---
|
||||
|
||||
# Phase 3 —— E macOS + F Windows
|
||||
|
||||
### Task 9: macOS CI(签名+公证 dmg)
|
||||
|
||||
**Files:** Create `scripts/ci/compile-macos.sh`、`.gitea/workflows/build-macos.yml`;参照 jiu `compile-macos.sh`(证书导入 + notarytool 部分可几乎直接复用)。
|
||||
|
||||
**Interfaces:** Consumes 账户级 Apple secrets + 仓库级 `MACOS_APP_PROVISION_PROFILE_BASE64`/`MACOS_SYSEXT_PROVISION_PROFILE_BASE64`。
|
||||
|
||||
- [ ] Step 1:`compile-macos.sh` —— 建临时 keychain,`MACOS_DEVELOPER_ID_CERT_P12_BASE64` 解码导入(复用 jiu)→ 两个 provisioning profile 解码装入 `~/Library/MobileDevice/Provisioning Profiles/` → 递增 `CURRENT_PROJECT_VERSION` → Xcode Developer ID 构建 app+sysext(`scripts/local_test.sh build` 逻辑)→ `notarytool submit --key <p8> --key-id $APPSTORE_API_KEY_ID --issuer $APPSTORE_API_ISSUER_ID --wait` → `stapler` → 打 dmg。
|
||||
- [ ] Step 2:`build-macos.yml` —— tag `client-v*` + dispatch;`runs-on: mac`;provision → compile-macos → release-client(上传 dmg)。
|
||||
- [ ] Step 3:**手动验证** —— dispatch → dmg 出现 → 另一台 mac `spctl -a -vv` 通过、`stapler validate` OK、装上能连。Expected:公证 dmg 可分发。
|
||||
- [ ] Step 4:Commit `feat(ci): macOS client-v* 签名+公证 dmg`;下载链接接 macOS。
|
||||
|
||||
### Task 10: Windows CI(exe/installer)
|
||||
|
||||
**Files:** Create `scripts/ci/compile-windows.sh`(或 .ps1)、`.gitea/workflows/build-windows.yml`;参照 jiu `compile-windows.sh`/`install-innosetup.ps1`/`build-windows.yml`。
|
||||
|
||||
- [ ] Step 1:`compile-windows.sh` —— `flutter build windows --release --dart-define=PANGOLIN_API_URL=...` → Inno Setup 打安装包(`install-innosetup.ps1` 装 ISCC)。**先不做代码签名**(用户首装 SmartScreen 提示,可接受)。
|
||||
- [ ] Step 2:`build-windows.yml` —— tag `client-v*` / `winbuild*` + dispatch;`runs-on: windows`;compile → release-client(上传 exe)。参考记忆:Windows 出包复制到桌面 pangolin 目录。
|
||||
- [ ] Step 3:**手动验证** —— dispatch → installer 出现 → windows 机装上能连。Expected:安装包可用。
|
||||
- [ ] Step 4:Commit `feat(ci): Windows client-v* 安装包`;下载链接接 Windows。
|
||||
|
||||
### Task 11: 下载链接全端闭环 + 文档
|
||||
|
||||
**Files:** Modify `web/website/src/config/site.ts`(downloads.macos/windows)、`docs/index.html`(若有)。
|
||||
|
||||
- [ ] Step 1:`site.ts.downloads` 补齐 macos/windows;Download.astro 三端按钮全部接 release 资产。
|
||||
- [ ] Step 2:客户端发版触发官网重部署(`build-*` 成功后 dispatch `deploy-site`,或用 latest-download URL 免重建)。
|
||||
- [ ] Step 3:**验证** —— 官网三端下载按钮均落到最新 release。Commit;关掉 todo 子任务 30A/30B。
|
||||
|
||||
---
|
||||
|
||||
## 验证(整体)
|
||||
|
||||
- 每条流水线先 `workflow_dispatch` 跑通再依赖 tag。
|
||||
- 服务端:migrate 版本 + `/healthz` + 行数守恒。官网:站点可访问 + canonical + redline。客户端:各端资产可装可连。
|
||||
- 下载链接:官网按钮落到最新 release 资产。
|
||||
|
||||
## 风险(见 spec §9)
|
||||
|
||||
nas 内存(容器化单 job 缓解)、migrate 生产出错(备份+回滚)、keystore/公证凭据(Bitwarden+不落盘)、下载链接刷新(build-* 触发 deploy-site 或 latest-download)。
|
||||
|
||||
## 不在本计划
|
||||
|
||||
iOS(G)、备份/容灾(#26)、TLS(#25,官网 TLS 先 CF 橙云或并入 #25)、Windows 代码签名、上架商店。
|
||||
@@ -0,0 +1,499 @@
|
||||
# 控制面 TLS(Cloudflare Tunnel 前置)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 pangolin-server 控制面 API 从明文 `http://103.119.13.48:8080` 迁到 `https://api.yanmeiai.com`,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书),数据面 sing-box REALITY:443 完全不动。
|
||||
|
||||
**Architecture:** pangolin1 上跑 `cloudflared` **出站**隧道(不监听任何入站端口 → 与 sing-box 独占的 :443 零冲突),CF 边缘把 `api.yanmeiai.com` 的请求经隧道回送到 `127.0.0.1:8080`。客户端(Flutter 四端共享 `kApiBaseUrl`)默认改 https 域名;控制面下发给客户端 sing-box 的 `.srs` 规则集下载基址(`PANGOLIN_PUBLIC_URL`)同步改 https。最后一步把 `:8080` 收回 loopback 并关防火墙,彻底退役明文口——该步有上线顺序闸(须待现网客户端更新后再做)。
|
||||
|
||||
**Tech Stack:** Cloudflare Tunnel(remotely-managed / token 模式)、cloudflared(Debian 12 apt)、systemd、Go(pangolin-server,仅 env 变更零代码)、Flutter/Dart(`api_config.dart`)、Android manifest、Gitea Actions(`deploy-server.sh` 健康检查)、cf-api 封装(Bitwarden token)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Bash 禁 `$()` 命令替换**;禁 `set -a`/`set +a`。需捕获输出拆多步或用管道。
|
||||
- **凭证走 Bitwarden/rbw**,不写 `~/.env`/明文配置/git。Cloudflare 用 `cf-api` 封装(token 脚本内部从 Bitwarden 取,禁引用 `$CF_API_TOKEN`)。**隧道 token、私钥等 PII/密钥一律不入 git**,只落 `/etc/pangolin/*`(已 gitignore)+ Bitwarden。
|
||||
- **改机器(装包/改配置/重启服务)前必须先问用户**(只读操作除外)。本方案 Task 1B/2/5 会 ssh 改 pangolin1 与 CF 账户配置,执行到那几步先征得确认。
|
||||
- 回复中文件路径**一律绝对路径**。
|
||||
- git commit 结尾附:`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` 与 `Claude-Session:` 行;**不 force-push、不推 main**。
|
||||
- CF 账户 id `e585821c881c4cd23bc2530986edea9e`;zone `yanmeiai.com` id `2325730de45276d87180a8b66bd4cca0`。
|
||||
- pangolin1 = `103.119.13.48`,ssh 别名 `pangolin1`(root 免密)。数据面 sing-box REALITY 独占入站 `:443`,**不得触碰**。gRPC agent mTLS `:9443` 不动。
|
||||
- **上线顺序铁律**:现网客户端硬编码 `http://103.119.13.48:8080`。隧道与 https 端点必须**加法上线**(与旧口并存),客户端切 https 发版后,**Task 5(收 loopback + 关防火墙)才能做**,否则旧客户端全挂。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**新建:**
|
||||
- `deploy/single-node/systemd/cloudflared.service` — cloudflared 的 systemd unit(committed,`install -m 644` 到位,token 从 `/etc/pangolin/cloudflared.env` 经 `EnvironmentFile` 注入,不入 unit 本体)。
|
||||
- `client/test/unit/api_config_test.dart` — 守护测试:控制面基址必须 https(防回退明文)。
|
||||
- `ci/scan-cleartext.sh` — CI 守护:Android release manifest 不得含 `usesCleartextTraffic="true"`。
|
||||
|
||||
**修改:**
|
||||
- `deploy/single-node/deploy.sh` — server.env 里 `PANGOLIN_PUBLIC_URL` 改 https(Task 4);`ADDR` 改 loopback + 去掉 ufw 放行 8080(Task 5);新增 cloudflared 安装/enable(Task 1B)。
|
||||
- `client/lib/services/api_config.dart:6-9` — `kApiBaseUrl` 默认值改 `https://api.yanmeiai.com`(Task 3)。
|
||||
- `client/android/app/src/main/AndroidManifest.xml:30` — 移除 `android:usesCleartextTraffic="true"`(Task 3)。
|
||||
- `scripts/ci/deploy-server.sh:57` — 健康检查从「runner 远程 curl `http://IP:8080`」改为 ssh 内本地 `curl http://127.0.0.1:8080/healthz`(Task 5)。
|
||||
- `.gitea/workflows/ci.yml` — shellcheck 列表加 `ci/scan-cleartext.sh`;新增 cleartext-scan job(Task 3)。
|
||||
- `CLAUDE.md` + `docs/` — 端口/URL 布局更新(Task 6)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Cloudflare Tunnel 供给(CF 账户侧 + pangolin1 装 cloudflared)
|
||||
|
||||
把隧道建起来、DNS 指过去、cloudflared 在 pangolin1 上连通,`https://api.yanmeiai.com/healthz` 与旧的 `http://103.119.13.48:8080/healthz` **并存可用**(加法,不破坏现网)。
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/single-node/systemd/cloudflared.service`
|
||||
- Modify: `deploy/single-node/deploy.sh`(安装/enable cloudflared)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 隧道域名 `https://api.yanmeiai.com` → `127.0.0.1:8080`;隧道 token 存于 Bitwarden item `pangolin-cloudflared-tunnel` 字段 `TUNNEL_TOKEN` + pangolin1 `/etc/pangolin/cloudflared.env`。后续 Task 3/4 依赖此域名可达。
|
||||
|
||||
### 1A — CF 侧:创建隧道 + ingress + DNS(cf-api,只读账户外均属改配置,先确认)
|
||||
|
||||
- [ ] **Step 1: 建 remotely-managed 隧道,取 token**
|
||||
|
||||
先确认 rbw 已解锁(`rbw unlock`)。运行:
|
||||
|
||||
```bash
|
||||
cf-api -X POST "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel" \
|
||||
--data '{"name":"pangolin-api","config_src":"cloudflare"}'
|
||||
```
|
||||
|
||||
Expected: JSON `success:true`,`result.id`(隧道 UUID)、`result.token`(base64 长串)。**记下 `result.id` 为 `TUNNEL_ID`,`result.token` 为 `TUNNEL_TOKEN`。token 是密钥,不要落 git/明文文档。**
|
||||
|
||||
- [ ] **Step 2: 配 ingress(hostname → 本机 8080,兜底 404)**
|
||||
|
||||
```bash
|
||||
cf-api -X PUT "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel/<TUNNEL_ID>/configurations" \
|
||||
--data '{"config":{"ingress":[{"hostname":"api.yanmeiai.com","service":"http://localhost:8080"},{"service":"http_status:404"}]}}'
|
||||
```
|
||||
|
||||
Expected: `success:true`,`result.config.ingress` 含上面两条。
|
||||
|
||||
- [ ] **Step 3: 建代理 CNAME `api` → 隧道**
|
||||
|
||||
```bash
|
||||
cf-api -X POST "/zones/2325730de45276d87180a8b66bd4cca0/dns_records" \
|
||||
--data '{"type":"CNAME","name":"api","content":"<TUNNEL_ID>.cfargotunnel.com","proxied":true,"comment":"pangolin 控制面 API(CF Tunnel → pangolin1:8080)"}'
|
||||
```
|
||||
|
||||
Expected: `success:true`,`result.name` = `api.yanmeiai.com`,`result.proxied` = true。
|
||||
|
||||
- [ ] **Step 4: token 存入 Bitwarden(留档)**
|
||||
|
||||
把 `TUNNEL_TOKEN` 存进 Bitwarden item `pangolin-cloudflared-tunnel`(字段 `TUNNEL_TOKEN`)。验证:
|
||||
|
||||
```bash
|
||||
rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | head -c 12
|
||||
```
|
||||
|
||||
Expected: 打印 token 前 12 字符(证明可取回)。
|
||||
|
||||
### 1B — pangolin1:装 cloudflared + systemd 常驻(改机器,先确认)
|
||||
|
||||
- [ ] **Step 5: 写 committed systemd unit**
|
||||
|
||||
创建 `deploy/single-node/systemd/cloudflared.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Pangolin cloudflared (control-plane API tunnel → 127.0.0.1:8080)
|
||||
Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
||||
After=network-online.target pangolin-server.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
# TUNNEL_TOKEN 从此文件注入(不入 unit 本体、不进 ps);cloudflared 自动读取 env TUNNEL_TOKEN。
|
||||
EnvironmentFile=/etc/pangolin/cloudflared.env
|
||||
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel run
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# 出站隧道,无需 root:用非特权用户即可(与 pangolin-server 同用户)。
|
||||
User=pangolin
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
- [ ] **Step 6: deploy.sh 里安装 cloudflared 二进制 + unit + enable**
|
||||
|
||||
在 `deploy/single-node/deploy.sh` 的 systemd 安装段(现有 `install -m 644 .../pangolin-server.service` 一带,约 251-252 行)后追加。先加安装函数(Debian apt,无 `$()`):
|
||||
|
||||
```bash
|
||||
# ── cloudflared(控制面 API 出站隧道)──────────────────────────────
|
||||
if ! command -v cloudflared >/dev/null 2>&1; then
|
||||
log "安装 cloudflared(Cloudflare apt 源)"
|
||||
install -m 0755 -d /usr/share/keyrings
|
||||
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
||||
-o /usr/share/keyrings/cloudflare-main.gpg
|
||||
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \
|
||||
> /etc/apt/sources.list.d/cloudflared.list
|
||||
apt-get update -qq && apt-get install -y -qq cloudflared
|
||||
# apt 装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。
|
||||
[ -x /usr/local/bin/cloudflared ] || ln -sf "$(command -v cloudflared)" /usr/local/bin/cloudflared
|
||||
fi
|
||||
install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/
|
||||
```
|
||||
|
||||
> 注:上面为示意锚点;`$(command -v cloudflared)` 违反禁 `$()` 规则——落地时改为:`CFD_BIN=/usr/bin/cloudflared` 后 `ln -sf "$CFD_BIN" /usr/local/bin/cloudflared`(apt 固定装到 `/usr/bin`)。
|
||||
|
||||
- [ ] **Step 7: 在 pangolin1 落 token env 文件 + 起服务**(ssh,改机器,先确认)
|
||||
|
||||
token 经用户剪贴板落地(不经过我、不入 git):
|
||||
|
||||
```bash
|
||||
# 本机把 token 通过 ssh 写到远端受限权限文件(避免出现在 ps/history):
|
||||
rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | \
|
||||
ssh pangolin1 'install -m 600 -o pangolin -g pangolin /dev/stdin /etc/pangolin/cloudflared.env.tmp && \
|
||||
printf "TUNNEL_TOKEN=" | cat - /etc/pangolin/cloudflared.env.tmp > /etc/pangolin/cloudflared.env && \
|
||||
rm -f /etc/pangolin/cloudflared.env.tmp && chmod 600 /etc/pangolin/cloudflared.env'
|
||||
```
|
||||
|
||||
> 落地时若上面拼接别扭,改为本机 `printf 'TUNNEL_TOKEN=%s\n' "<token>"` 结果 ssh 管道写入;核心要求:`/etc/pangolin/cloudflared.env` 内容为单行 `TUNNEL_TOKEN=<token>`,mode 600,owner pangolin。
|
||||
|
||||
装 unit 并启动:
|
||||
|
||||
```bash
|
||||
scp deploy/single-node/systemd/cloudflared.service pangolin1:/etc/systemd/system/
|
||||
ssh pangolin1 'systemctl daemon-reload && systemctl enable --now cloudflared.service && sleep 3 && systemctl is-active cloudflared'
|
||||
```
|
||||
|
||||
Expected: `active`。
|
||||
|
||||
- [ ] **Step 8: 验证隧道连通(加法上线,不破坏旧口)**
|
||||
|
||||
```bash
|
||||
curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道 OK"
|
||||
curl -fsS -m 10 http://103.119.13.48:8080/healthz && echo " <= 旧口仍在(预期)"
|
||||
```
|
||||
|
||||
Expected: 两条都返回 `/healthz` 成功体。证明 https 端点上线、旧明文口并存(现网客户端不受影响)。
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/single-node/systemd/cloudflared.service deploy/single-node/deploy.sh
|
||||
git commit -m "feat(deploy): cloudflared 出站隧道前置控制面 API(api.yanmeiai.com→127.0.0.1:8080)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 客户端控制面基址切 https + Android 去明文(含守护测试)
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/lib/services/api_config.dart:6-9`
|
||||
- Modify: `client/android/app/src/main/AndroidManifest.xml:30`
|
||||
- Create: `client/test/unit/api_config_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 产出的 `https://api.yanmeiai.com`(须已可达)。
|
||||
- Produces: 全 Flutter 端(auth/nodes/account/connection providers 共享的)`kApiBaseUrl` 默认 = `https://api.yanmeiai.com`。
|
||||
|
||||
- [ ] **Step 1: 写守护测试(先失败)**
|
||||
|
||||
创建 `client/test/unit/api_config_test.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin/services/api_config.dart';
|
||||
|
||||
void main() {
|
||||
test('控制面基址默认走 https(禁止回退明文 http)', () {
|
||||
expect(kApiBaseUrl, startsWith('https://'),
|
||||
reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http');
|
||||
expect(kApiBaseUrl, isNot(contains('103.119.13.48')),
|
||||
reason: '不得再硬编码节点 IP 作控制面基址');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `cd client && flutter test test/unit/api_config_test.dart`
|
||||
Expected: FAIL —— 当前默认 `http://103.119.13.48:8080` 两条断言都不满足。
|
||||
|
||||
- [ ] **Step 3: 改默认值为 https 域名**
|
||||
|
||||
`client/lib/services/api_config.dart:6-9`,把:
|
||||
|
||||
```dart
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://103.119.13.48:8080',
|
||||
);
|
||||
```
|
||||
|
||||
改为(保留 `String.fromEnvironment` 让本地联调仍可 `--dart-define` 覆盖,只换默认值并更新注释):
|
||||
|
||||
```dart
|
||||
// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名;
|
||||
// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'https://api.yanmeiai.com',
|
||||
);
|
||||
```
|
||||
|
||||
同时删掉第 5 行「TODO(联调临时)…发版前改回」那条注释(已落地)。
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `cd client && flutter test test/unit/api_config_test.dart`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: 移除 Android 全局明文开关**
|
||||
|
||||
`client/android/app/src/main/AndroidManifest.xml:30`,把 `<application>` 上的:
|
||||
|
||||
```
|
||||
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
|
||||
```
|
||||
|
||||
改为(去掉该属性,闭合标签接到上一属性行;控制面已 https,不再需要明文豁免):
|
||||
|
||||
```
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
```
|
||||
|
||||
> iOS/macOS 无 ATS 配置(已确认),https 天然满足 ATS,**无需改任何 plist**。
|
||||
|
||||
- [ ] **Step 6: analyze + 全量单测**
|
||||
|
||||
Run: `cd client && flutter analyze --no-fatal-infos && flutter test test/unit test/widget test/contract`
|
||||
Expected: analyze 无 error;测试全绿(含新 `api_config_test`)。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add client/lib/services/api_config.dart client/android/app/src/main/AndroidManifest.xml client/test/unit/api_config_test.dart
|
||||
git commit -m "feat(client): 控制面基址默认 https://api.yanmeiai.com + 移除 Android 明文开关"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CI 守护 —— Android release manifest 禁明文
|
||||
|
||||
防止将来有人把 `usesCleartextTraffic="true"` 加回来(回退明文)。
|
||||
|
||||
**Files:**
|
||||
- Create: `ci/scan-cleartext.sh`
|
||||
- Modify: `.gitea/workflows/ci.yml`(新增 job + shellcheck 列表)
|
||||
|
||||
- [ ] **Step 1: 写扫描脚本**
|
||||
|
||||
创建 `ci/scan-cleartext.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# scan-cleartext.sh — 禁止 Android manifest 重新开启全局明文(控制面已 https/CF Tunnel)。
|
||||
# usesCleartextTraffic="true" 会让全 app 允许明文 HTTP,退回 #25 之前的不安全态。
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="client/android/app/src/main/AndroidManifest.xml"
|
||||
if grep -q 'usesCleartextTraffic="true"' "$MANIFEST"; then
|
||||
echo "❌ $MANIFEST 含 usesCleartextTraffic=\"true\":控制面已 https,禁止全局明文。" >&2
|
||||
echo " 如个别调试域名确需明文,请用 res/xml/network_security_config.xml 按域白名单,勿开全局。" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Android manifest 未开启全局明文"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 本地跑一遍(应通过,因 Task 2 已移除)**
|
||||
|
||||
Run: `bash ci/scan-cleartext.sh`
|
||||
Expected: `✅ Android manifest 未开启全局明文`。
|
||||
|
||||
- [ ] **Step 3: 反向自测(临时加回应失败)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
sed -i.bak 's#android:icon="@mipmap/ic_launcher">#android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">#' client/android/app/src/main/AndroidManifest.xml
|
||||
bash ci/scan-cleartext.sh; echo "exit=$?"
|
||||
mv client/android/app/src/main/AndroidManifest.xml.bak client/android/app/src/main/AndroidManifest.xml
|
||||
```
|
||||
Expected: 打印 ❌ 且 `exit=1`;还原后文件复原。
|
||||
|
||||
- [ ] **Step 4: 接入 CI**
|
||||
|
||||
`.gitea/workflows/ci.yml`:(a)在 lint job 的「shellcheck CI 脚本」列表(约 42-58 行)加 `/mnt/... ` 对应项前,先把 `ci/scan-cleartext.sh` 纳入 shellcheck——注意该文件在 `ci/` 非 `scripts/ci/`,复用已有的 redline-scan 挂载方式即可;(b)新增 job:
|
||||
|
||||
```yaml
|
||||
cleartext-scan:
|
||||
name: Cleartext Scan — Android 禁明文
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: scan Android manifest for global cleartext
|
||||
run: bash ci/scan-cleartext.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ci/scan-cleartext.sh .gitea/workflows/ci.yml
|
||||
git commit -m "ci: 守护 Android manifest 禁全局明文(#25 控制面已 https)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 服务端 `PANGOLIN_PUBLIC_URL` 切 https(规则集下载基址)
|
||||
|
||||
`PANGOLIN_PUBLIC_URL` 被嵌进**客户端 sing-box 配置**当 `.srs` 分流规则集下载基址(`clientconfig.go:150-161`,`download_detour:"direct"`)。不改的话新客户端仍去 `http://103.119.13.48:8080` 拉。此步与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/single-node/deploy.sh:178`
|
||||
|
||||
- [ ] **Step 1: 改 deploy.sh 的 server.env 默认**
|
||||
|
||||
`deploy/single-node/deploy.sh:178`,把:
|
||||
|
||||
```
|
||||
PANGOLIN_PUBLIC_URL=http://$VPS_IP:$HTTP_PORT
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```
|
||||
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 pangolin1 应用 + 重启 server**(ssh,改机器,先确认)
|
||||
|
||||
```bash
|
||||
ssh pangolin1 "sed -i 's#^PANGOLIN_PUBLIC_URL=.*#PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server"
|
||||
```
|
||||
|
||||
Expected: `active`。
|
||||
|
||||
- [ ] **Step 3: 验证下发配置里规则集基址已是 https**
|
||||
|
||||
用一个测试账号取一份客户端配置(经隧道),断言规则集 URL 走 https:
|
||||
|
||||
```bash
|
||||
curl -fsS -m 10 https://api.yanmeiai.com/v1/rules/geoip-cn.srs -o /dev/null -w '%{http_code}\n'
|
||||
```
|
||||
|
||||
Expected: `200`(规则集经隧道可下载)。并在有测试 token 时抓一份 `/v1/...` 客户端配置,确认内嵌 `route.rule_set[].url` 前缀为 `https://api.yanmeiai.com`。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/single-node/deploy.sh
|
||||
git commit -m "feat(deploy): PANGOLIN_PUBLIC_URL 改 https://api.yanmeiai.com(客户端规则集走隧道)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 退役明文口 —— 8080 收 loopback + 关防火墙 + 修健康检查
|
||||
|
||||
> **⚠️ 上线顺序闸:此 Task 会切断外部 `http://103.119.13.48:8080`,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行。** 执行前与用户确认「旧客户端可弃」。做完后一切经隧道/loopback,数据面 :443 不受影响。
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/single-node/deploy.sh:167`(ADDR 收 loopback)、`:272-275`(去掉 ufw 放行 8080)
|
||||
- Modify: `scripts/ci/deploy-server.sh:57`(健康检查改本地)
|
||||
|
||||
- [ ] **Step 1: deploy.sh — ADDR 绑 loopback**
|
||||
|
||||
`deploy/single-node/deploy.sh:167`,把 `ADDR=:$HTTP_PORT` 改为:
|
||||
|
||||
```
|
||||
ADDR=127.0.0.1:$HTTP_PORT
|
||||
```
|
||||
|
||||
- [ ] **Step 2: deploy.sh — 不再放行 8080(loopback 后无需外开)**
|
||||
|
||||
`deploy/single-node/deploy.sh:272-275` 的 ufw 放行段删除或改注释(8080 已 loopback,外部本就不可达):
|
||||
|
||||
```bash
|
||||
# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: deploy-server.sh — 健康检查改 ssh 内本地 curl**
|
||||
|
||||
`scripts/ci/deploy-server.sh:57`,把 runner 远程:
|
||||
|
||||
```
|
||||
curl -fsS -m 10 --retry 5 --retry-connrefused "http://${DEPLOY_HOST}:8080/healthz" >/dev/null && echo "healthz OK"
|
||||
```
|
||||
|
||||
改为经隧道校验对外可达 + ssh 内本地兜底(二选一或都留,推荐经隧道最贴近真实客户端路径):
|
||||
|
||||
```bash
|
||||
$SSH "root@${DEPLOY_HOST}" 'curl -fsS -m 10 --retry 5 --retry-connrefused http://127.0.0.1:8080/healthz >/dev/null && echo "healthz(local) OK"'
|
||||
curl -fsS -m 10 --retry 5 "https://api.yanmeiai.com/healthz" >/dev/null && echo "healthz(tunnel) OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 pangolin1 应用 loopback 绑定**(ssh,改机器,先确认客户端已迁移)
|
||||
|
||||
```bash
|
||||
ssh pangolin1 "sed -i 's#^ADDR=.*#ADDR=127.0.0.1:8080#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server"
|
||||
```
|
||||
|
||||
Expected: `active`。
|
||||
|
||||
- [ ] **Step 5: 验证明文口已死、隧道仍活**
|
||||
|
||||
```bash
|
||||
curl -fsS -m 8 http://103.119.13.48:8080/healthz && echo "!! 不该还通" || echo "旧明文口已不可达(预期)"
|
||||
curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道仍 OK"
|
||||
ssh pangolin1 'ss -ltnp | grep ":8080" | grep 127.0.0.1 && echo "8080 已仅 loopback"'
|
||||
```
|
||||
|
||||
Expected: 明文口失败;隧道成功;`ss` 显示 8080 仅监听 `127.0.0.1`。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/single-node/deploy.sh scripts/ci/deploy-server.sh
|
||||
git commit -m "feat(deploy): 8080 收 loopback + 关 8080 防火墙 + 健康检查改本地/隧道(退役明文控制口)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 文档更新(端口/URL 布局)
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`(项目根,worktree 内那份)— 端口布局说明
|
||||
- Modify: `docs/index.html` — 登记本方案 HTML 阅读版
|
||||
|
||||
- [ ] **Step 1: 更新 CLAUDE.md 端口/URL 描述**
|
||||
|
||||
在 `deploy/ 结构` 或 server 段补一句:控制面 API 对外经 **CF Tunnel** `https://api.yanmeiai.com`(源站 `127.0.0.1:8080`,不外露);数据面 sing-box REALITY 仍独占 `:443`;gRPC agent mTLS `:9443`。
|
||||
|
||||
- [ ] **Step 2: 生成本方案 HTML 阅读版并登记 index**
|
||||
|
||||
按既有深色 HTML 家族样式,把本 plan 同内容生成 `docs/control-plane-tls-tunnel.html`,登记进 `docs/index.html` 的「实现计划」分类。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/control-plane-tls-tunnel.html docs/index.html
|
||||
git commit -m "docs: 控制面 CF Tunnel/端口布局说明 + 方案 HTML 登记 index"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ CF Tunnel 前置控制面 → Task 1。
|
||||
- ✅ 客户端默认 http→https → Task 2。
|
||||
- ✅ Android 移除 usesCleartextTraffic → Task 2(iOS/macOS 无 ATS 需改,已核实)。
|
||||
- ✅ server 8080 收 loopback → Task 5(带上线顺序闸)。
|
||||
- ✅ `PANGOLIN_PUBLIC_URL` 同步 https(Explore 发现的隐藏依赖)→ Task 4。
|
||||
- ✅ 健康检查随 loopback 调整 → Task 5。
|
||||
- ✅ 数据面 :443 不动 → 全程未触碰 sing-box(约束显式声明)。
|
||||
- ✅ fallback(域名被封退直连 IP)→ 明确拆到 #32,不在本轮。
|
||||
|
||||
**上线顺序验证:** Task 1(隧道加法)→ Task 4(PUBLIC_URL,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→ **待客户端更新** → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合。
|
||||
|
||||
**Placeholder / 一致性:** Task 1B Step 6 的 `$(command -v cloudflared)` 已在注释显式提示落地时改为无 `$()` 写法(禁 `$()` 全局约束);token 全程不落 git;`kApiBaseUrl` 名称跨 Task 2/守护测试一致。
|
||||
|
||||
## 不在本轮
|
||||
- #32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。
|
||||
- 控制面 API 的 CF WAF/rate-limit 规则精调。
|
||||
- usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。
|
||||
@@ -0,0 +1,132 @@
|
||||
# 前端设计系统治理重构(ds-flow 落地全端)
|
||||
|
||||
> 用 ds-flow 方法论把 pangolin 全部前端(Flutter 五端 + 官网 website + 用户中心 usercenter)
|
||||
> 收口到「设计只有一个出生地(原型单源),代码永远是镜像;漂移由静态闸在提交/CI 前拦截,
|
||||
> 走样由 golden/fidelity 双级像素验收兜底」。
|
||||
>
|
||||
> **关键前提(摸底结论)**:pangolin 不是从零 bootstrap,已约 65% 达标——
|
||||
> token 单源(`design/colors_and_type.css` 含 `[data-theme=dark]`)、Flutter codegen + drift 闸、
|
||||
> golden + CI 闸、pre-commit(写好未启用)都在。本计划是**补缺口 + Web 共享原子层去重**,
|
||||
> 不是推倒重来。
|
||||
>
|
||||
> 主题模型:pangolin 用 **light / dark 两主题**(非 jiu 的 a/b/c 三主题),全程保持。
|
||||
>
|
||||
> 已定决策:① Web 两端**各自实现 + 同源闸**(不建跨端共享组件包);
|
||||
> ② `design/ui_kits/` 的 jsx/css 端原型**收敛为纯 HTML 原型**并删副本;
|
||||
> ③ **先定稿本计划,再逐刀执行**(每刀 commit)。
|
||||
>
|
||||
> 参考样板:`~/code/jiu`(`design/prototype/` + `tools/` + `client/lib/core/theme/` + `docs/frontend-overview.html`)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 更新 CLAUDE.md + 计划落库
|
||||
|
||||
- [x] 0.1 CLAUDE.md 新增「## 前端设计系统治理(ds-flow)」章节:
|
||||
- 原型单源位置(`design/prototype/`:tokens/atoms/icons/index.html 登记簿)+ 只读约定
|
||||
- codegen 命令(Flutter `gen_flutter_tokens.mjs`;Web `build-tokens.mjs` 同源)
|
||||
- 三层治理 L1/L2/L3 规则速查
|
||||
- 四道静态闸清单 + 「违规谁拦」对照表(原型校验 / 跨端同源 / 代码色单源 / codegen 零 diff)
|
||||
- golden(多主题回归自比)/ fidelity(对原型 pixelmatch,本地体检不进 CI)双闸定位
|
||||
- [x] 0.2 本 `.md` 定稿 + 生成 HTML 阅读版 `docs/frontend-ds-refactor-plan.html`,登记进 `docs/index.html`「实现计划」
|
||||
- [x] 0.3 `/todo` 建 tier-1 条目跟踪本重构,拆 6 个子任务(对应 Phase 1-5 + 收尾)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 原型单源三件套(design/prototype/)
|
||||
|
||||
把散在 `ui_kits/`(6 端 jsx/css 原型)+ `preview/`(20 规格 HTML)+ `_ds_manifest.json`(登记簿)
|
||||
的东西收敛成 ds-flow 标准三件套。
|
||||
|
||||
- [x] 1.1 建 `design/prototype/` 目录;`serve.mjs` 照搬 jiu(零依赖热重载,默认端口按 jiu)
|
||||
- [x] 1.2 `design/prototype/tokens.css`:从现有 `colors_and_type.css` 迁移/规整为
|
||||
「基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块」结构。
|
||||
**保持数值不变**,只重排为 ds-flow 结构;`colors_and_type.css` 作为兼容别名或迁移为薄封装(不破坏现有 codegen)
|
||||
- [x] 1.3 `design/prototype/atoms.css`:把按钮/卡片/输入/**语言下拉**/徽章/状态药丸等公用原子类沉淀为
|
||||
只引 `var(--token)` 的 CSS(镜像 `design/preview/` 现有规格 + client widgets 实现语义)
|
||||
- [x] 1.4 `design/prototype/icons.js`:SVG sprite 单源(`<symbol id="i-*">`),
|
||||
收敛现有分散图标(website Icon.astro / usercenter icons.tsx / Flutter pangolin_icons.dart 三处的图标集)
|
||||
- [x] 1.5 `design/prototype/index.html`:活登记页——三…两主题(light/dark)切换 + `data-swatches` 声明式色板 +
|
||||
字号梯度 + 圆角/间距/阴影 + 全部公用组件原子展示卡 + 图标库全展示。**每个 atom 必须在此登记**
|
||||
- [x] 1.6 `design/ui_kits/` 的 jsx/css 端原型:提炼进 prototype 后**删除 jsx 组件副本**(消除与
|
||||
「禁向 design/ 提组件代码副本」的冲突 + 漂移源);保留必要的屏级 HTML 布局参考迁进 `prototype/screens/`
|
||||
- [x] 1.7 更新/退役 `_ds_manifest.json` + `_ds_bundle.js`:登记簿职责交给 `index.html`,
|
||||
manifest 若仍被消费则保留为派生产物(记清谁是真源)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Web token 升为一等公民 + 同源闸
|
||||
|
||||
现状:Web 的 `build-tokens.mjs` 只是「原样拷 css,删 Google Fonts 行」。升级为受闸守护的同源关系。
|
||||
|
||||
- [x] 2.1 确认两端 token 落点与生成链:website→`src/styles/tokens.gen.css`、
|
||||
usercenter→`public/colors_and_type.css`;源统一指向 `design/prototype/tokens.css`(Phase 1 后)
|
||||
- [x] 2.2 建 `tools/check-l1-sync.mjs`(照搬 jiu 裁剪):
|
||||
- ① website `tokens.gen.css` token 值 ≡ 原型 tokens.css(逐值)
|
||||
- ② usercenter `public/colors_and_type.css` ≡ 原型(逐值)
|
||||
- ③ icons 同源:website / usercenter / Flutter 三处图标集 ⊆ 原型 icons.js sprite
|
||||
- ④ Web 硬编码色扫描(白名单 `#fff/#000/logo 固定色`,其余报警)
|
||||
- [x] 2.3 codegen 幂等:重跑 `build-tokens.mjs` 后 `git diff` 零差异(纳入 CI,见 Phase 5)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Web 共享原子层对齐(各自实现 + 同源闸)★工作量最大
|
||||
|
||||
不建跨端组件包;两端各自实现,但都对齐 `design/prototype/atoms.css`,靠闸保证不漂移。
|
||||
|
||||
- [x] 3.1 抽公共原子清单:langsel(语言下拉,刚修的两套合规范)/ button / card / input / badge / pill。
|
||||
对每个原子在 `atoms.css` 定义 canonical 样式
|
||||
- [x] 3.2 website:`website.css` + `site-extra.css` 里的按钮/卡片/下拉 class 对齐 atoms.css 语义,
|
||||
残留 `#fff/#000`/logo 外的硬编码色清零(当前业务硬编码 ~30 处,多为可保留的白/黑/logo)
|
||||
- [x] 3.3 usercenter:`shared.tsx` 的 `card/input/LangSeg` 内联对象对齐 atoms.css 语义;
|
||||
残留 13 处硬编码(基本 `#fff`)核对,非白/黑/logo 的清零
|
||||
- [x] 3.4 两端 langsel 行为/样式一致性核对(此前刚统一为自定义下拉,纳入 atoms 登记)
|
||||
- [x] 3.5 更新 `design/CONTRACT.md`:Web 原子清单 + 屏级台账(同步/快照/代码先行三态)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Flutter 收尾 + golden 补齐
|
||||
|
||||
Flutter 已很干净(UI 层零裸 hex),只需收尾。
|
||||
|
||||
- [x] 4.1 清 `client/lib/widgets/adaptive_menu.dart` 唯 1 处裸 Material 色 → 走 token
|
||||
- [x] 4.2 测试字体补 CJK 子集:用 `tools/fonts/make-cjk-subset.sh` 生成 Noto Sans SC 子集放
|
||||
`client/test/fonts/`,`flutter_test_config.dart` 注册——消除 golden 中文与生产渲染差异
|
||||
- [x] 4.3 处理现存 6 张 `client/test/golden/failures/` diff:逐张确认「原型对得上」后 `--update-goldens` 重录入库
|
||||
- [ ] 4.4 (延后·非阻塞) golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵
|
||||
(现有 `desktop_pages/tablet_pages/components/auth` → 补 mobile + 主题维度)
|
||||
- [x] 4.5 `client/test/helpers/harness.dart` 对齐 jiu `golden_harness.dart` 手法:
|
||||
多主题循环辅助 + 钉死 viewport/dpr + ProviderScope 固定数据(防动态值翻车)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检
|
||||
|
||||
- [x] 5.1 硬编码色扫描闸:
|
||||
- Flutter `client/tool/check_ds_code.mjs`(照搬 jiu,含 `--changed` 供 pre-commit):禁 `Color(0x..)`/裸 `Colors.x`
|
||||
- Web hex 扫描并入 `check-l1-sync.mjs` ④
|
||||
- [x] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)
|
||||
- [x] 5.3 CI 串起来(`.gitea/workflows/ci.yml` 增补):
|
||||
原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(已有,补 mobile+主题)
|
||||
- [x] 5.4 启用 pre-commit:`ci/install-hooks.sh` 纳入 onboarding 文档 + CLAUDE.md,
|
||||
`.githooks/pre-commit` 增挂 `check-ds --changed`(只在动了 `design/prototype/` 时跑,轻量条件触发)
|
||||
- [ ] 5.5 (延后·前置=原型整屏 screens/,属 L3) fidelity 像素闸(本地体检,不进 CI):`tools/screens.mjs` 屏注册表 + `tools/fidelity.mjs`
|
||||
(原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp,两边统一注入 CJK 字体)
|
||||
- [x] 5.6 全景文档 `docs/frontend-overview.html`(照搬 jiu 十节):一次 UI 改动标准路径 + 目录地图 +
|
||||
三层分治 + 闸全景 + 像素验收体系 + 响应式范式 + 规则速查,登记进 docs/index.html
|
||||
|
||||
---
|
||||
|
||||
## Verification(端到端)
|
||||
|
||||
- **原型**:`node design/prototype/serve.mjs` 起服务,浏览器逐屏目检 light/dark;`check-ds.mjs` 12 道全绿
|
||||
- **同源**:`node tools/check-l1-sync.mjs` 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)
|
||||
- **Flutter**:`flutter analyze` + `flutter test`(含 golden ×双主题);`check_ds_code.mjs` 全绿;codegen 重跑零 diff
|
||||
- **Web**:两端 `npm run build` 通过;token 同源闸绿;langsel/button/card 对齐 atoms
|
||||
- **fidelity**:`node tools/fidelity.mjs` 逐屏残差在阈内(首次校准记录各屏实测值)
|
||||
- **闸生效**:`ci/install-hooks.sh` 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下
|
||||
|
||||
## 不在本轮
|
||||
|
||||
- 新功能/新屏开发(本轮是治理重构,不加业务)
|
||||
- iOS/iPad 专属布局深度优化(响应式已覆盖,超阈再单独立项)
|
||||
- 三主题扩展(保持 light/dark 双主题)
|
||||
@@ -0,0 +1,145 @@
|
||||
# Pangolin CI/CD 全流程 —— 设计方案(#30)
|
||||
|
||||
> 状态:设计定稿待审 · 日期 2026-07-05 · 范围 A~F(排除 iOS、备份#26、TLS#25)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
pangolin 现有 CI 仅 `.gitea/workflows/ci.yml`(nas,只校验无部署)+ `web/website/.gitea/workflows/website.yml`。
|
||||
服务端部署靠手动(F3/F4 那次我手动 scp+ssh+migrate),客户端出包靠本地脚本,官网未部署,
|
||||
下载链接是死链。目标:**tag 触发的编译 → 测试 → 发版(Gitea release)→ 部署** 全自动,
|
||||
参考 jiu 的 `.gitea/workflows` + `scripts/ci/*.sh` 结构,适配 pangolin 的部署目标与产物。
|
||||
|
||||
## 2. 范围
|
||||
|
||||
| 子块 | 内容 |
|
||||
|---|---|
|
||||
| A 基座 | `scripts/ci/*`(env/provision/test/release/notify/lib-forgejo)+ checks 保留 |
|
||||
| B 官网 | Astro 构建 → 部署 `pangolin.yanmeiai.com` |
|
||||
| C 服务端 | 交叉编译 server/agent/migrate → release → ssh pangolin1(备份→migrate→换二进制→重启→健康检查) |
|
||||
| D Android | apk(arm64,release keystore 签名)→ release 资产 |
|
||||
| E macOS | 公证 dmg(Developer ID + notarytool)→ release 资产 |
|
||||
| F Windows | exe/installer(Inno Setup)→ release 资产 |
|
||||
|
||||
**排除**:iOS(G,未来)、SQLite 备份/容灾(#26)、控制面 TLS(#25)。
|
||||
|
||||
## 3. 已锁定决策
|
||||
|
||||
| 维度 | 决定 | 理由 |
|
||||
|---|---|---|
|
||||
| runner | nas=官网+服务端(容器化)· mac=Android+macOS · windows=Windows | nas 常在线且 Astro/Go 轻量(非 Flutter Web);mac/windows 做必须它们的活 |
|
||||
| 触发 | tag `site-v*` / `server-v*` / `client-v*` + `manual.yml` 手动派发 | 同 jiu,发版即部署,可手动重放 |
|
||||
| macOS 签名 | mac runner 自动 Developer ID 签名 + notarytool 公证 + staple | 凭据入 Gitea secret(见 §7) |
|
||||
| Android 签名 | 正式 release keystore | app 级专属签名身份 |
|
||||
| 下载链接 | 官网 href 指向 Gitea release 资产的稳定 URL | 发版即更新,见 §6 |
|
||||
| 镜像 | GOPROXY=goproxy.cn、PUB_HOSTED_URL/FLUTTER_STORAGE_BASE_URL=flutter-io.cn | 国内网络 |
|
||||
|
||||
## 4. 架构
|
||||
|
||||
### 4.1 共享基座 `scripts/ci/`(镜像 jiu)
|
||||
|
||||
- `_env.sh` —— 公共环境(镜像源、路径、版本号解析 `${tag#prefix-v}`)
|
||||
- `lib-forgejo.sh` —— Gitea/Forgejo release 建/查 + 资产上传(用 `FORGEJO_TOKEN`)
|
||||
- `provision-mac.sh` —— mac 幂等装 flutter / xcode-select / gomobile / Android NDK+JDK17
|
||||
- `test.sh <server|client>` —— `go test` / `flutter test`
|
||||
- `notify.sh` —— 成功/失败 Telegram 通知(可选,复用节点监控 bot)
|
||||
- `compile-site.sh` / `compile-backend.sh` / `compile-android.sh` / `compile-macos.sh` / `compile-windows.sh`
|
||||
- `deploy-site.sh`(wrangler → CF Pages)/ `deploy-server.sh`(ssh pangolin1,复用 lib-ssh)
|
||||
- `release-<x>.sh` —— 建 release + 挂产物
|
||||
|
||||
> 每个 `compile-*` 内部封装该端已验证的构建命令(如 Android 走
|
||||
> `scripts/build-libbox.sh android` + `flutter build apk --split-per-abi`;macOS 走
|
||||
> Xcode Developer ID 签名 + `notarytool submit --wait` + `stapler`)。工作流只调脚本,
|
||||
> 逻辑在脚本里,便于本地复现。
|
||||
|
||||
### 4.2 工作流 `.gitea/workflows/`
|
||||
|
||||
| 工作流 | 触发 | runner | 步骤 |
|
||||
|---|---|---|---|
|
||||
| `checks.yml`(现 ci.yml) | push 分支 | nas | 保留:shellcheck / openapi / redline / flutter analyze+test / go test |
|
||||
| `deploy-site.yml` | `site-v*` | nas | `node:20` 容器构建 Astro(`SITE_URL` 注入)→ `deploy-site.sh` |
|
||||
| `deploy-server.yml` | `server-v*` | nas | `golang:1.25` 交叉编译 → `test.sh server` → `release-server.sh` → `deploy-server.sh` |
|
||||
| `build-android.yml` | `client-v*` | mac | provision → `compile-android.sh`(签名 apk)→ `release-client.sh` |
|
||||
| `build-macos.yml` | `client-v*` | mac | provision → `compile-macos.sh`(签名+公证 dmg)→ `release-client.sh` |
|
||||
| `build-windows.yml` | `client-v*` / `winbuild*` | windows | `compile-windows.sh`(exe/installer)→ `release-client.sh` |
|
||||
|
||||
并发组按 jiu:`deploy-site` / `deploy-server` / `deploy-client` 各自 `cancel-in-progress: false`。
|
||||
|
||||
### 4.3 服务端部署(deploy-server.sh)—— 把手动那套固化
|
||||
|
||||
复刻 F3/F4 手动部署的安全次序(带回滚):
|
||||
1. scp `pangolin-server` / `pangolin-agent` / `pangolin-migrate` 到 pangolin1 `/tmp`
|
||||
2. `systemctl stop pangolin-server`
|
||||
3. `sqlite3 wal_checkpoint(TRUNCATE)` → `cp` 备份 `pangolin.db.bak-pre-<tag>`
|
||||
4. `pangolin-migrate up`(以 pangolin 用户);**失败即恢复备份 + 重启旧 server + 退出非零**
|
||||
5. `install` 新二进制到 `/usr/local/bin`(旧的备份为 `.bak-<tag>`)
|
||||
6. `systemctl start pangolin-server` + `/healthz` 健康检查;agent 随连接自恢复
|
||||
|
||||
### 4.4 官网部署(deploy-site.sh)—— Cloudflare Pages
|
||||
|
||||
> **架构变更(2026-07-06 实施):** 原计划 rsync 到 pangolin1 的 nginx。但节点 :443 被 sing-box
|
||||
> (VPN 数据面)占用,而 CF 免费套餐 proxied 回源只能打 :80/:443、改回源端口需 Enterprise ——
|
||||
> 无法在同机同 IP 上让官网 HTTPS 与 VPN 共存。**故官网改由 Cloudflare Pages 托管**:纯静态、
|
||||
> 全程 HTTPS、`_headers`/CSP 原生生效、不落 VPS,从根上无 :443 冲突,也不拖累 VPN 机器。
|
||||
|
||||
Astro `npm ci && npm run build`(`SITE_URL=https://pangolin.yanmeiai.com`)→ `dist/` 经
|
||||
`npx wrangler pages deploy` 发布到 CF Pages 项目 **`pangolin-site`**(自定义域
|
||||
`pangolin.yanmeiai.com`,CNAME → `pangolin-site.pages.dev`,proxied)。
|
||||
需 secret:`CLOUDFLARE_API_TOKEN`(带 Account>Pages>Edit)+ `CLOUDFLARE_ACCOUNT_ID`(账户级)。
|
||||
deploy 步骤在 `node:20` 容器内跑 wrangler。**灾备**:构建产物仍是纯静态,可另 rsync 到任意镜像。
|
||||
|
||||
## 5. 下载链接闭环(30A)
|
||||
|
||||
`web/website/src/config/site.ts` 增 `downloads: { android, macos, windows }`,值为 Gitea release 的
|
||||
**稳定 latest 资产 URL**(Forgejo 支持 `…/releases/latest/download/<asset>` 则直接用;
|
||||
不支持则 `deploy-site.sh` 构建期用 `FORGEJO_TOKEN` 查最新 `client-v*` release 版本、烘焙进 href)。
|
||||
`Download.astro` 各平台按钮读 `SITE.downloads.<platform>`。客户端发版后官网重部署即刷新
|
||||
(或 `build-*` 完成触发 `deploy-site`)。
|
||||
|
||||
## 6. 密钥与作用域(solo / wangjia,命名对齐 jiu 以共用)
|
||||
|
||||
| Secret | 作用域 | 说明 |
|
||||
|---|---|---|
|
||||
| `FORGEJO_TOKEN` / `FORGEJO_URL` | 账户级(wangjia) | 建 release + 传产物,jiu 复用 |
|
||||
| `MACOS_DEVELOPER_ID_CERT_P12_BASE64` / `MACOS_DEVELOPER_ID_CERT_PASSWORD` | 账户级 | Developer ID 证书(账号级),与 jiu 共用;续期改一处。证书在钥匙串,导出一次 .p12 |
|
||||
| `APPSTORE_API_KEY_P8_BASE64` / `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` | 账户级 | 公证 API key(KEY_ID=`3PZTHR8YMJ`),与 jiu 同一把,`.p8` 现成 |
|
||||
| `DEPLOY_SSH_KEY` | pangolin 仓库级 | 授权到 pangolin1,最小权限 |
|
||||
| `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | pangolin 仓库级 | Android app 级专属签名(**pangolin 自己的 keystore,不复用 jiu**) |
|
||||
| `MACOS_APP_PROVISION_PROFILE_BASE64` / `MACOS_SYSEXT_PROVISION_PROFILE_BASE64` | pangolin 仓库级 | 主 app + PacketTunnel sysext 描述文件(pangolin bundle 专属,签名期落盘嵌入) |
|
||||
|
||||
命名对齐 jiu(`MACOS_*`/`APPSTORE_*`/`ANDROID_*`):**Apple 那套放账户级 → jiu/pangolin 共用一份**,
|
||||
compile-macos 脚本可复用 jiu 的;Android keystore 虽同命名规范但**各 app 独立、不共享**。
|
||||
工作流用 `secrets.XXX` 引用,作用域对写法透明。
|
||||
|
||||
## 7. 实现顺序(单仓库内分阶段落地)
|
||||
|
||||
范围虽是 A~F,实现按风险/依赖递增:
|
||||
1. **A 基座** + `checks` 迁移(`ci.yml` → `checks.yml` 复用现有,抽 `scripts/ci` 骨架)
|
||||
2. **B 官网**(最简,验证 release/deploy 骨架跑通)
|
||||
3. **C 服务端**(固化手动部署,告别手动)
|
||||
4. **D Android**(解锁下载链接;需 keystore 就绪 + gradle 接签名)
|
||||
5. **E macOS**(最复杂:证书+2 描述文件+公证)
|
||||
6. **F Windows**(windows runner + Inno Setup)
|
||||
|
||||
每阶段独立可发、独立验收。
|
||||
|
||||
## 8. 验证
|
||||
|
||||
- 每条流水线先 `workflow_dispatch` 手动跑通、产物/部署核对,再依赖 tag。
|
||||
- 服务端:`server-v*` → 看 pangolin1 migrate 版本 + `/healthz` + 行数守恒(同 F3 部署核对)。
|
||||
- 官网:`site-v*` → `pangolin.yanmeiai.com` 可访问 + canonical 正确 + redline 扫描。
|
||||
- 客户端:release 资产可下载安装(Android 侧载 / macOS 公证校验 `spctl` / Windows 安装)。
|
||||
- 下载链接:官网按钮点击落到最新 release 资产。
|
||||
|
||||
## 9. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| nas 内存(3.8G)构建 OOM | 容器化单 job、Astro/Go 轻量;必要时该端移 mac |
|
||||
| migrate 在生产出错 | 部署前备份 + 失败自动回滚(§4.3),已在 F3/F4 手动验证 |
|
||||
| Android keystore 丢失 | 存 Bitwarden(文件+密码);终身签名身份 |
|
||||
| macOS 公证凭据泄露 | 账户级 secret,不落盘;`.p8`/`.p12` 用完即删临时文件 |
|
||||
| 客户端发版后下载链接不刷新 | `build-*` 成功触发 `deploy-site` 重烘焙,或用 latest-download 稳定 URL |
|
||||
|
||||
## 10. 不在本方案
|
||||
|
||||
iOS 流水线(G)、SQLite 备份/容灾(#26)、TLS(#25)、Android/上架 Play、Windows 代码签名(先不签)。
|
||||
Reference in New Issue
Block a user