Skip to content

Latest commit

 

History

History
327 lines (218 loc) · 28.9 KB

File metadata and controls

327 lines (218 loc) · 28.9 KB

Tech Design · v0.7 · 一键 Docker + 兼容存量 systemd

承 [[prd/v0.7]] · 决策已定:镜像双路 / compose 只 app+MySQL / 两路并存+迁移脚本。 本文重点两块:① Docker 具体实现 ② 历史 systemd 部署如何兼容。每决策给 选项 / 取舍 / 选定 / 为何不选其他。


一、Docker 具体实现

决策 70 · 镜像 = 多阶段 Dockerfile,运行层带 mysql client

选项

  • 70-A 多阶段(maven 构建层 → JRE 运行层)· 单 Dockerfile
  • 70-B 宿主机/CI 先 mvn package,Dockerfile 只 COPY jar
  • 70-C 直接用现成 deploy.sh,不做镜像

取舍:70-B 要求构建环境先有 jar(CI 里可以,但用户源码自构建时就得先装 JDK,违背「干净」初衷)。70-A 一个 docker build 全包,用户零依赖。

选定 70-A。运行层基础镜像 eclipse-temurin:21-jre(Debian 底,稳;不用 alpine —— JVM on musl 偶发玄学,不值);运行层额外装 mariadb-client(或 default-mysql-client)+ bash + curl + gzip,因为 db/apply.shmysql 客户端灌 SQL、healthcheck 用 curl、备份用 mysqldump+gzip。构建层 maven:3.9-eclipse-temurin-21,mvn -s deploy/maven-settings.xml -DskipTests package(复用阿里云 mirror 加速)。堆 -Xmx512m(与现状一致)。

为何不 70-C:deploy.sh 在宿主机直装 + 现编,正是要被取代的重路径。

决策 71 · 编排 = docker compose(app + db 两服务)

服务定义要点:

  • db:mysql:8.0(不用 mariadb —— app 依赖 MySQL 8 的 utf8mb4_0900_ai_ci),command 指定 --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci;healthcheck: mysqladmin ping;卷 db-data:/var/lib/mysql;3306 不对宿主发布(只走 compose 内网,避免和宿主已有 MySQL 撞端口)。
  • app:本镜像;depends_on: db: condition: service_healthy;env 注入 DB_HOST=db(走 compose 服务名)、SERVER_ADDRESS=0.0.0.0(关键:容器内必须绑 0.0.0.0,不能像 systemd 那样绑 127.0.0.1,否则发布端口打不通)、SPRING_PROFILES_ACTIVE=prodUPLOAD_ROOT=/data/uploadsTZ=Asia/Shanghai;卷 uploads:/data/uploadsbackups:/data/backups;ports: "127.0.0.1:${SERVER_PORT:-20000}:20000"(默认只发布到宿主 loopback,逼用户走前置反代,不裸奔公网);healthcheck: curl -f localhost:20000/health
  • env_file: .env

取舍:SERVER_ADDRESS 这个坑必须文档化 —— application.yml 里 prod 默认绑 SERVER_ADDRESS(systemd 下是 127.0.0.1 给宿主 nginx loopback),容器里必须覆盖成 0.0.0.0。

决策 72 · 迁移在 app 容器 entrypoint 内跑(复用 db/apply.sh)

选项:72-A entrypoint 跑 apply.sh 再 exec java · 72-B 独立 init/job 容器跑迁移 · 72-C 让 Spring 启动时自己迁移

选定 72-Aentrypoint.sh:① 重试等 db 可连(depends_on healthy 之外再加一层 mysqladmin ping 重试,防 healthy 后短暂抖动)→ ② 跑 db/apply.sh(同一套 V*.sql + schema_history)→ ③ 成功才 exec java -jar app.jar;apply 失败则容器非 0 退出,绝不带未迁移的库启动

为何不 72-B:多一个容器、compose 更复杂,收益不大;entrypoint 串行最简单可靠。为何不 72-C:迁移逻辑已在 db/apply.sh(sha256 幂等、有 schema_history),不重写进 Java。

决策 73 · 配置与密钥 = .env + 首次生成,运营参数仍走管理页

.env.example 给占位;deploy/docker-init.sh(可选)首次用 openssl rand 生成随机 DB_PASS/MYSQL_ROOT_PASSWORD/REMEMBER_ME_KEY 写入 .env(手填也行)。LLM key / 阈值 / 短信 aksk 不进 .env,首次登录后走管理页存进 family_runtime_config(承 [[feedback_admin_runtime_config]] + 私密红线:密钥不烤进镜像、不进日志)。.env git-ignored。

决策 75 · 备份 = 轻量 sidecar

选项:75-A sidecar(mysql client 镜像 + 循环 mysqldump→/data/backups + 按 RETENTION_DAYS prune)· 75-B 文档教 docker compose exec 手动 · 75-C app 内调度

选定 75-A:与现有 systemd timer 行为对齐(每日自动 dump + 保留天数),backups 卷持久化。为何不 75-B:手动易忘,丢了自动备份这条安全网。


二、历史 systemd 部署如何兼容(你的重点)

兼容之所以成立的三块基石(先讲原理)

  1. 同一套迁移 + 同一张 schema_history:Docker 的 entrypoint 和 systemd 的 deploy.sh 跑的是完全相同的 db/apply.sh + db/migration/V*.sql。所以一个从 systemd 迁过来、schema_history 里已标记 V1..V29 全应用的库,Docker 这边 apply.sh 一看「都应用过」→ 不重放。未来 V30+ 无论哪条路都只应用一次。这是迁移安全的根本。
  2. 同一份 env 契约:application.yml 只认 DB_* / UPLOAD_ROOT / REMEMBER_ME_KEY 占位 —— systemd 从 /etc/finance.env 注入,Docker 从 .env 注入,app 本身对部署方式无感。迁移脚本只是把 /etc/finance.env 翻译成 .env
  3. 数据格式同源:MySQL dump 可移植;uploads 是普通文件;两路无任何 schema/格式分叉。

决策 76 · 兼容总策略 = 三路并存(存量不动 + 迁移脚本 + 进阶连外部库)

选项:76-A 强制全迁 Docker(弃 systemd)· 76-B 仅新装用 Docker、存量永不迁 · 76-C 三路并存

选定 76-C:

  • 存量 systemd 原样保留(deploy.sh 迭代路径不动,我们 prod/beta 也是存量 → 最高红线零破坏)
  • migrate-to-docker.sh 给想迁的人(全量迁移,见决策 77)
  • 进阶(文档):Docker app 容器连宿主已有 MySQL(DB_HOST=host.docker.internal + extra_hosts,Linux 需 host-gateway),数据完全不动只把 app 容器化 —— 列为高级选项,不做默认(耦合宿主、网络绕)。

为何不 76-A:迁移有风险,且强迁违背零破坏红线。为何不 76-B:存量(含我们自己)将来想吃 Docker 的好处就没路了。

决策 77 · migrate-to-docker.sh 具体流程

对一台现有 systemd 机器,一条命令完成(每步失败即停、可回滚):

  1. 预检:确认 /etc/finance.env + finance 服务在跑 + 宿主 MySQL 可连;读出 DB_NAME/DB_USER/DB_PASS/REMEMBER_ME_KEY/SERVER_PORT
  2. 强制备份:mysqldump 宿主库 → 时间戳 .sql.gz(安全网,失败可回 systemd)。
  3. 生成 .env:沿用 DB_NAME/DB_USER,原样携带 REMEMBER_ME_KEY(否则所有登录态失效)、SERVER_PORT;DB_PASS 沿用或新生成;生成 MYSQL_ROOT_PASSWORD
  4. 停 systemd app 腾端口:systemctl stop finance(只停 app,不删任何东西;宿主 MySQL 不动)。避免和 Docker app 撞 SERVER_PORT;Docker MySQL 不发布 3306 → 不撞宿主 MySQL。
  5. 起 db 容器 + 灌数据:docker compose up -d db → 等 healthy → 把第 2 步的 dump 导入容器 MySQL(含 schema_history,所以版本状态完整搬过去)。
  6. 搬 uploads:/var/finance/uploads/*uploads 卷。
  7. 起 app:docker compose up -d app → entrypoint 的 apply.sh 见 schema_history 已全 → 不重放 → app 起在迁移好的数据上 → 验 /health

回滚:全程不删 systemd 侧任何东西(jar/库/finance.env 都在)。Docker 不满意 → docker compose down + systemctl start finance 即回到原状。确认满意后用户再自行 systemctl disable finance 释放宿主 MySQL/端口。

我们自己的 prod / beta 怎么办

  • prod(39.107)保持 systemd 不动 —— 在通过域名给真实/演示用户服务,不拿它冒险。
  • beta 作迁移演练台:在 beta 上跑 migrate-to-docker.sh 验证「数据零丢 + 版本不重放 + /health 通」,作为验收基线 #3。验完可回滚回 systemd(beta 仍要继续当 Demo)。

三、其余决策

决策 78 · 镜像发布 = GHCR 多架构 + 源码构建回退 + 国内加速

GitHub Actions:打 tag 时 docker buildx 构建 amd64 + arm64(NAS 多 arm64)→ push ghcr.io/luodi-nate/financial-management:{vX.Y.Z,latest}。compose 默认 image: 拉预构建;docker compose build 可切源码构建(Dockerfile 在)。文档:大陆配 Docker Hub/GHCR 镜像加速(阿里云容器镜像服务),或直接源码构建。

取舍:GHCR vs Docker Hub —— GHCR 跟 GitHub 同源、不用额外账号、无匿名拉取限流,选 GHCR;大陆访问慢用「加速 + 源码构建」兜底。

决策 79 · 反代/HTTPS = 文档片段,compose 不含

compose 只把 app 发布到 127.0.0.1:20000。文档给两段照抄式片段:① nginx + certbot(存量用户现成,把 proxy_pass127.0.0.1:20000 + X-Forwarded-Proto $scheme)② Caddy(reverse_proxy localhost:20000 自动 HTTPS)。承 PRD 决策 B。


四、文件清单

Dockerfile                         多阶段(maven 构建 → temurin-jre 运行 + mysql client)
docker/entrypoint.sh               等 db → db/apply.sh → exec java
docker/backup.sh                   sidecar 每日 mysqldump + 保留天数
docker-compose.yml                 app + db(+ backup sidecar)+ 命名卷
.env.example                       DB/端口/key/TZ 占位 + 注释
.dockerignore                      .git/target/preview/docs 等排除,镜像瘦身
deploy/docker-init.sh              首次生成随机密钥到 .env(可选)
deploy/migrate-to-docker.sh        存量 systemd → Docker 一键迁移(决策 77)
.github/workflows/docker-publish.yml   tag 构建多架构推 GHCR
deploy/README.md                   加「Docker 部署」「从 systemd 迁移」「反代/HTTPS 片段」三节
docs/qa-cases.md / scripts/qa-run.sh  v07-DOCKER-*
prd/v0.7.md / CHANGELOG.md          同步

五、验收基线

  1. 全新机 docker compose up -d → 登录/填报/AI 体检全通(决策 70-73)
  2. docker compose down && up -d 数据不丢(卷)· compose pull && up -d 自动增量迁移
  3. beta 跑 migrate-to-docker.sh:账户/周期/上传 logo 零丢,schema_history 不重放,/health 通(决策 77)
  4. 旧 deploy.sh 迭代路径仍可用(prod 不受影响)
  5. GHCR 出 amd64+arm64 镜像;源码构建也成功
  6. mvn test 全绿 + qa-run v07-DOCKER-*(镜像构建/迁移幂等/卷持久化/entrypoint 迁移)

六、红线

  • 存量 systemd(含 prod+beta)零破坏;迁移前必备份、全程不删 systemd 侧、可回滚
  • 共用 schema_history + 同 V.sql* 防重放;同 env 契约保证 app 对部署方式无感
  • 密钥不烤进镜像、不进日志、不进 git;镜像不含任何家庭数据
  • SERVER_ADDRESS=0.0.0.0(容器内)+ 默认只发布 loopback 端口(不裸奔公网)

七、macOS 覆盖(2026-06-12 补)

  • 全新 Docker 部署在 Mac 完全可用:compose 跨平台;基础镜像 maven / eclipse-temurin:21-jre / mysql:8.0 均有 arm64,Apple Silicon docker compose build 原生构建;决策 78 的 GHCR 镜像出 amd64+arm64 双架构,M-Mac pull 自动取 arm64。Mac 上 host.docker.internal 还开箱即用(Linux 才需 host-gateway)。
  • 存量 macOS 用户迁移:migrate-to-docker.sh 自动识别两种存量 —— systemd(/etc/finance.env)或 macOS 原生(~/.finance/finance.env + ~/finance/uploads,brew MySQL,见 deploy-macos.sh)。差异:macOS 没有 systemctl,脚本会提示用户先手动停掉前台 java / launchd(无法可靠地替它停前台进程),其余(备份/灌库/搬 uploads/起容器/验证)与 systemd 路径一致。
  • 未在真 Mac 验证:beta 是 Linux,Docker 路径与 macOS 迁移分支均为「设计正确 + 静态校验」,真机冒烟留给用户在 Mac + Ubuntu 分别跑(验收基线 #1/#3)。

八、第二批 · 外部服务配置引导(技术设计)

对应 PRD §8(FR-129~132)· 2026-06-16 · UED 见 preview/v0.7/integrations-config.html

8.1 总览 & 改动面

纯增量、零 schema 改动。三块:

  • 文档:新建 docs/configuration.md(总指南),README 加入口;链接已有 llm-api-keys-setup.md / aliyun-sms-setup.md / llm-vendor-comparison.md
  • 模板:admin/integrations.html(LLM 卡:可选 banner + 每 key 折叠帮助 + 测试连接按钮)、admin/notification.html(补文档链 + 可选 banner);static/css/style.csshelp-fold / btn-ghost / test-res 组件类。
  • 后端:IntegrationsControllerPOST /admin/integrations/llm/test(探测已存 key)。

8.2 决策 80 · LLM 测试连接:复用 chat() 探测 vs 新增 probe()

  • 备选 A(选定):复用现有 LlmClient.chat() 发最小探测。注入 List<LlmClient>(与 LlmDiagnoseService 同款),按 vendor() 找到目标 client,调 chat(系统="只回复 ok", 用户="ping"),成功即连通。
  • 备选 B:给 LlmClientprobe() 接口方法,单独发 1-token 请求。
  • 备选 C:Controller 直接拼 HTTP 调厂商 API。
  • 为什么选 A:① 测的是「这条真实调用链(key→端点→模型→解析)通不通」,与体检实际走的路径完全一致,最有诊断价值;② 零接口改动、不重复 QwenClient 已有的多模型/熔断/分类逻辑;③ 成本可忽略(一次小 prompt;坏 key 最多在 Qwen 侧重试几个模型即 401)。为什么不选 B/C:B 要改接口 + 两个实现 + 绕过已有熔断分类,收益不抵成本;C 重复造轮子、且和真实调用链不一致,测过未必体检也过。

8.3 决策 81 · 交互形态:flash 回显 vs HTMX 片段 vs JS fetch

  • 选定:表单 POST → flash → redirect,与现有「一键测试短信」(/admin/reminders/sms-test)完全同款。
  • 为什么:站内已有这个模式、用户已熟悉;无需新增 JS/HTMX 片段端点;实现最小、回归面最低。preview 里的内联结果态只是演示,落地用 flash 一行更省。
  • 形态:LLM 卡内 每个 vendor 一个独立 mini-form(vendor=qwen / vendor=deepseek),各自 POST,可分别验。

8.4 决策 82 · 错误脱敏(私密红线)

测试结果文案在 Controller 内分类生成,绝不回显 key、绝不带 key 进 flash / audit / 日志明文:

  • key 未配置 → 「未配置,请先填好并保存再测试」(连 chat 都不调)。
  • chat() 抛异常 → 按 message 关键字归类成友好且无敏感信息的原因:
    • 欠费/账单/arrearage → 「账户欠费或账单过期」
    • 额度/quota/freetier → 「免费额度已用尽(可换模型或等重置)」
    • 401/403/invalid/incorrect → 「Key 无效或无权限」
    • timeout/超时/ResourceAccess/IO → 「网络不通或超时」
    • 其它 → 「调用失败(已脱敏)」+ 仅 status 码
  • 现有 chat() 异常 message 本就不含 key(key 只在 Bearer header)。仍在 Controller 兜底:result 文案只用归类词,不拼原始 exception message 里可能的 body。
  • audit:测试事件按既有「不记明文」纪律,只记 vendor + 成功/失败 + 归类原因。承 PrivacyIsolationTest + v04-PRIV-1

8.5 决策 83 · 折叠帮助 = 原生 <details>(无 JS)

每个 key 字段下一个 <details class="help-fold">(默认收起),内含 3-4 步申请步骤 + 控制台直链 + 链 docs/configuration.md。纯 CSS 控制展开标记(+/−),零 JS,移动端原生可用。承 [[feedback_no_emoji]]:测试结果图标用 inline SVG(check/cross),不用 emoji。

8.6 验证 / 红线

  • 探测最小开销:prompt 极短;不改 K_LLM_MAX_TOKENS(输出上限本就在,实际输出 "ok" 极小)。
  • 纯增量:不动 family_runtime_config schema、不动配置读取链(DB>env>默认)、不动既有 4 个 POST handler;对已配好的存量用户零影响。
  • QA:qa-run 加 v07-CFG-*(测试端点存在 + 脱敏分支齐 + configuration.md/README 入口齐 + 模板折叠/banner 在);PrivacyIsolationTest 仍绿;mvn test 全绿。

九、第三批 · 系统内首次引导(技术设计)

对应 PRD §9(FR-133~135)· 2026-06-16

9.1 顺带修一个首登崩溃(关键发现)

HomeController/ 无脑 redirect:/dashboard,而 DashboardController.anchorPeriod()零周期时抛 IllegalStateException;deploy.sh step10 又把 period + account 都 TRUNCATE。结论:全新部署首次登录 //dashboard → 500——正砸在 v0.7 目标的开源新用户身上(beta/prod 有数据未暴露)。首次引导必须连这个一起修。

9.2 决策 84 · 形态 = / 智能路由 + 独立引导页(非 dashboard 内联)

  • PRD 倾向「dashboard 内联面板」,但 dashboard 零周期直接崩,没法在崩溃的页面里内联。改为:
    • /(HomeController)智能路由:未初始化(周期数==0 或 账户数==0)→ 渲染 onboarding 页;否则 → redirect:/dashboard(原行为不变)。/ 本就是落地页,符合 PRD「落地页引导」。
    • /dashboard 兜底:periodMapper.findLatest(fid,1).isEmpty() → redirect:/(回引导页),anchorPeriod 不再有机会抛异常。两边互不死循环(/ 在零周期时不会再转 dashboard)。
  • 备选(否):改 dashboard 让 anchorPeriod 返回「空 Period」+ 内联面板 —— 要改一堆 factview 空值路径,风险大、收益低。
  • 备选(否):弹窗/向导 —— PRD 已排除。

9.3 决策 85 · 初始化判定(零新字段、零 schema)

initialized = 周期数>0 && 自建账户数>0,全用现成查询:

  • 周期:periodMapper.countByFamily(fid)(已有)。
  • 账户:accountMapper.findActiveByFamily(fid).size()(已有,单家庭账户数少,开销可忽略)。 两者都满足 → dashboard 有锚点周期、有账户可展示,不崩、不空。判定只在 / 跑一次,轻。

9.4 引导页(onboarding/index.html)

  • 复用 fragments/layout :: head + fragments/nav(navGlobalModelAdvice 全局注入,无需 controller 额外塞)。
  • 内容:欢迎语 + 一句话周期流程(开周期 → 各成员填余额 → 关周期 → 出净资产/收益报告)+ 3 步:
    1. 加账户 → /accounts/new(done if 账户数>0)
    2. 开本期周期 → /admin/periods(done if 周期数>0)
    3. 填本期余额 → /entry(收尾步;①②齐后 / 自动转 dashboard,本步在 dashboard/填报页完成) 每步带状态(✓ 已完成 / 待办,inline SVG 不用 emoji),高亮下一未完成。
  • 底部「顺手」软链:改家庭名 /admin/family · 改成员名 /admin/members · 接 AI/短信 docs/configuration.md
  • 文案不用「实例/初始化/OPEN」技术词(承 [[feedback_user_friendly_naming]])。

9.5 FR-135 · /entry 顶部周期流程一句话

entry/index.html 顶部(有周期时才渲染)加一行注解:周期流程:开 → 填余额 → 关 → 出报告 · 本页是「填余额」这步。轻提示,不改逻辑。

9.6 红线 / 验证

  • 纯增量:不改 schema、不动 factview/计算;/dashboard 改动只加一行「无周期则 redirect:/」;存量(有数据)→ / 照旧 redirect dashboard,零变化。
  • QA:qa-run 加守护(HomeController 有初始化判定分支 + onboarding 模板存在 + dashboard 有无周期兜底 redirect);新增 OnboardingRoutingTest 验「零周期/零账户→onboarding,有数据→redirect dashboard」。
  • mvn test 全绿 + 部署 beta(beta 有数据 → 仍直接 dashboard;可临时造一个零周期家庭或本地验空态)。

十、hotfix v0.7.3 · 改密死循环(issue #1)

症状:种子账号首登强制改密,改完被无限弹回改密页,手动改库 must_change_pw=0 无效。卡死所有全新部署的首登用户。

根因:ProfileController 改密成功后调 SecurityContextHolder.clearContext() 想"强制重登"。但本项目是 Spring Security 6(Spring Boot 3.3),默认 requireExplicitSave=true + SecurityContextHolderFilter 不在请求末尾自动存——clearContext() 只清当前线程的 ThreadLocal,不写回 HttpSession。于是:

  • session 里仍是登录时的旧 MemberPrincipal(must_change_pw=true 快照,principal 只在登录时从库构建)。
  • AuthController /loginme != nullredirect:/dashboard(不给登录表单)。
  • MustChangePasswordInterceptorme.isMustChangePw()(旧快照=true)→ 弹回 /profile/password
  • 循环。库标记早是 0 也没用,循环由 session 旧快照驱动,不是库驱动。 作者注释里写「clearContext 后 me==null 会落到表单」——这是 SS5 心智模型(SS5 的 SecurityContextPersistenceFilter 会在请求末尾把清空持久化回 session = 等于登出);SS6 行为变了,不再成立。

决策 86 · 用 SecurityContextLogoutHandler 真作废 session(而非 clearContext / 实时查库)

  • 选定:改密成功后 new SecurityContextLogoutHandler().logout(request, response, auth) —— 默认 invalidateHttpSession=true + clearAuthentication=true,真把 session 作废。下次 /loginme==null → 给表单 → 用新密码登 → MemberPrincipalService 重新读库得到 must_change_pw=0 的新 principal → 跳出。与现有「redirect /login 重登」意图一致,最小改动。
  • 备选(否)让拦截器实时查库判 must_change_pw:每请求多一次 DB 查询,且治标(其它 session 陈旧数据仍在);不如从根上作废会话。
  • 备选(否)原地重新认证(不重登、构建新 principal 写回 session):UX 更顺但改动更大、要碰 SecurityContextRepository;hotfix 取最小风险。

回归:ProfilePasswordChangeTest(断言 session.invalidate() + 置 0 + 跳登录)+ qa-run v07-FIX-1。纯改一处、0 schema。


十一、v0.7.4 · 国内 Docker 部署网络阻断引导(技术设计)

触发:prod 隔离真机验证(2026-06-22)证明 compose 链路通,但 CN 网络下 docker compose pull 卡死在 mysql:8.0(Docker Hub 被墙),而 GHCR(我们的 app 镜像)直连 OK。改动面:deploy/docker-up.sh step 5 + 三处文档。0 Java、0 schema、0 编排变更。

11.1 改动总览

docker-up.sh 原 step 5 是「$DC pull 失败 → $DC up -d --build」。问题:--build 只构建 app,db 服务仍 image: mysql:8.0、照样要从 Docker Hub 拉,被墙时这个"兜底"根本兜不住。新逻辑见决策 87;镜像源引导 + 可选自动写 daemon.json 见决策 88/89。

11.2 决策 87 · 拉取失败的归因 = 单独探 mysql(Docker Hub),不动 GHCR

  • 选定:$DC pull 失败后,用 pull_one mysql:8.0(timeout/gtimeout 50s 包裹 docker pull)单独探 Docker Hub。拉不到 mysql → 判定 Docker Hub 被墙 → 走镜像源引导;mysql 能拉 → 说明缺的只是预构建 app 镜像(如未发版)→ 才 up -d --build
  • 为何不整体配镜像源:GHCR 不受 Docker Hub registry-mirrors 影响,且实测大陆直连 OK,只有 Docker Hub 的 mysql 需要兜——精准归因比无脑加 mirror 更不易误伤。
  • 为何保留 --build 分支:它解决的是"预构建 app 镜像还没 push"(开发者/抢先用户),与 CN 墙是两回事,归因后各走各路。
  • timeout 可移植:macOS 默认无 timeout,有 gtimeout(coreutils)用之,都没有就直接跑——_to() 封装。

11.3 决策 88 · daemon.json 写入策略 = 不存在才交互式创建,已存在只提示不碰

  • 选定:仅当 daemon.json 不存在时,征同意后写入只含 registry-mirrors 的最小 JSON;已存在则绝不覆盖,只打印片段让用户自行 merge。
  • 为何不 jq 合并进既有文件:不能假设用户装了 jq;且 docker 配置是用户机器的系统级文件,误覆盖代价高。安全优先,宁可让老手手动并。
  • 镜像地址:免登录公共源 docker.m.daocloud.io + docker.1ms.run(实测 2026-06-22 prod 可用)。写阿里云 xxx.mirror.aliyuncs.com——那是每账号登录后才有的专属地址,硬编码必失效。
  • 可测试钩子(也是真实可用的非交互开关):FINANCE_DAEMON_JSON(覆盖路径)、FINANCE_ASSUME_YES(跳过 y/N 询问,供 CI/自动化)、FINANCE_DOCKER_RESTART(覆盖重启命令)。

11.4 决策 89 · 平台分流 = Linux 自动写,Mac 按引擎给精确手动指引(不碰 VM 配置)

  • cn_hub_blocked_guideuname -s 分流:
    • Linux(_cn_guide_linux):/etc/docker/daemon.json 指引 + 自动写前置 = command -v systemctl + daemon.json 不存在 + (交互 tty 或 FINANCE_ASSUME_YES) + (root 或有 sudo),满足才征同意写入+重启+重试。
    • macOS(_cn_guide_mac):不自动改任何配置,按检测到的引擎给精确步骤 —— command -v colima~/.colima/default/colima.yamldocker.registry-mirrors + colima restart;command -v orborb config docker + orb restart docker;并始终给 Docker Desktop 的 Settings → Docker Engine。
  • 为何 Mac 必须分引擎、且不自动写(关键修正,核对过各引擎机制):Mac 引擎跑在虚拟机里,根本不读宿主的 /etc/docker/daemon.json —— 早期把 Mac 也指到该路径是误导(白改)。三种装法落点各不同:colima=colima.yaml(写进 VM 内 /etc/docker/daemon.json)、OrbStack=~/.orbstack/config/docker.json(orb config docker)、Docker Desktop=~/.docker/daemon.json(Settings UI)。重启方式也各异(colima restart 约 1-2 分钟 / orb restart docker / Apply & Restart)。自动改这些代价高、易错,故只给可照抄的步骤。
  • 共识:Docker Hub 的 mysql 阻断是网络层问题,Mac CN 用户同样会撞,与 OS 无关;registry-mirrors 只对 Docker Hub 生效(GHCR app 镜像不受影响),三端机制统一加这一段即可。

十二、v0.7.5 · 全新 Docker 清演示数据(技术设计)

改动面:docker/entrypoint.sh(全新库判定 + 调清理)+ 新建 docker/clean-dev-data.sh + Dockerfile(COPY 脚本)+ README/文档。0 Java、0 schema、0 编排端口变更。

12.1 决策 90 · 「是否全新库」铁信号 = 迁移前 schema_history 表是否存在

  • 背景/风险:Docker 全新装会跑 V2/V3/V4(含演示数据),需像 systemd deploy.sh step10 那样清成空态。但删数据极敏感,必须确保绝不误删:① systemd→Docker 迁移来的真实库(migrate-to-docker.sh 灌 dump);② v0.7.0~0.7.4 已用 Docker 的老用户升级库(其 db-data 卷里演示数据与真实数据已混存,truncate 会连真实数据一起删)。
  • 选定:在 entrypoint.shapply.sh 之前,查 information_schema.tablesschema_history 表在不在 —— 不存在 = 从未迁移过的全新空卷 = FRESH_DB。只有 FRESH_DB 才在迁移后调 clean-dev-data.sh
    • 自限:清完后 schema_history 已建,后续重启 FRESH_DB=no,永不再清。
    • 迁移库:migrate-to-docker.sh step4 把含 schema_history 的 dump 灌进 db,早于 app entrypoint → entrypoint 判定 FRESH_DB=no → 天然不清,migrate 脚本一行都不用改
    • 升级库:db-data 卷已有 schema_history → FRESH_DB=no → 老用户(哪怕轻量)数据 100% 安全。
  • 为何不用 sentinel 文件:sentinel 要落盘在某个卷,而 db-data 与 uploads 是两个卷;老用户可能没传过 logo(uploads 空)→ sentinel 缺失误判。schema_history 与数据同在 db-data 卷,是最贴合的「这库到底新不新」事实源。
  • 为何不用纯互锁(audit>50 或 member.id>2):轻量真实用户(≤2 成员、审计少)会漏网被误删。互锁只作第二道防线保留在 clean-dev-data.sh 里。
  • 为何不让 apply.sh 跳过 V3/V4:会割裂两条部署路径的迁移历史 + schema_history 出现空洞;且 systemd 路径依赖「先应用再清」。保持一致,采用「迁移后清」。

12.2 clean-dev-data.sh 设计

  • 三重防线:① FINANCE_KEEP_DEMO=1 跳过(想看演示填充效果)② 真实数据互锁(与 step10 同口径,audit_log actor 行>50 或 member.id>2 跳过)③ 只 TRUNCATE 演示性表(cash_flow/transfer/period_snapshot/snapshot_todo/period_member_completion/fx_rate/audit_log/backup_log/metrics_recompute_log/period_reopen_log/period/account),与 step10 同表集;保留 family/member/account_template/cash_flow_category/family_runtime_config 等。
  • 由 entrypoint 仅在 FRESH_DB=yes 时调用;清理失败不致命(|| echo 兜底,不挡应用启动)。

12.3 验证 / 红线

  • beta 隔离测试库真验(MySQL 真库、不碰线上 finance):建一次性库 → db/apply.sh 应用全部迁移(含演示数据)→ 跑 clean-dev-data.sh → 断言 period/account=0、member=2/family=1 保留;再建一库注入 member.id>2 → 跑 → 互锁跳过、account 保留;FINANCE_KEEP_DEMO=1 → 跳过;schema_history 存在性查询:空库返回 0(FRESH)、已 apply 库返回 1(非 FRESH)。测完 DROP。
  • qa-runv07-CLEAN-1/2(脚本逻辑 + Dockerfile/entrypoint 接线 + README 无 <your-org>)。
  • 红线:删数据三重防线;迁移/升级库 FRESH_DB=no 绝不清;0 schema;不动 systemd step10。

11.5 验证 / 红线

  • 桩(stub)验证(无需真 Docker,prod 已卸):伪造 docker/systemctl/sudo/curl 置于临时 PATH,docker pull mysql 行为按 daemon.json 是否存在切换(模拟"配了镜像源就能拉")。三条断言:① 无 daemon.json+非自动 → 打印含 docker.m.daocloud.io 的指引;② FINANCE_ASSUME_YES=1 → 写入目标文件(含两个镜像)+ 调重启钩子 + 重试后 up;③ 预置 daemon.json → 跑完内容不变
  • 实测依据:prod 配 registry-mirrorsmysql:8.0 实拉成功(2026-06-22),桩里"配了就能拉"的假设有真机背书。
  • qa-run.shv07-CN-1/2;docker-up.sh 仍过 v07-DOCKER-4bash -n
  • 红线:自动写 daemon.json 三重前置(同意 + 不存在 + 告知重启);公共镜像免登录;0 schema / 0 编排 / 存量零影响。