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

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
+193
View File
@@ -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 双主题)