承 [[prd/v0.7]] · 决策已定:镜像双路 / compose 只 app+MySQL / 两路并存+迁移脚本。 本文重点两块:① Docker 具体实现 ② 历史 systemd 部署如何兼容。每决策给 选项 / 取舍 / 选定 / 为何不选其他。
选项
- 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.sh 靠 mysql 客户端灌 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 在宿主机直装 + 现编,正是要被取代的重路径。
服务定义要点:
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=prod、UPLOAD_ROOT=/data/uploads、TZ=Asia/Shanghai;卷uploads:/data/uploads、backups:/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-A entrypoint 跑 apply.sh 再 exec java · 72-B 独立 init/job 容器跑迁移 · 72-C 让 Spring 启动时自己迁移
选定 72-A。entrypoint.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。
.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-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:手动易忘,丢了自动备份这条安全网。
- 同一套迁移 + 同一张
schema_history:Docker 的 entrypoint 和 systemd 的 deploy.sh 跑的是完全相同的db/apply.sh+db/migration/V*.sql。所以一个从 systemd 迁过来、schema_history里已标记 V1..V29 全应用的库,Docker 这边 apply.sh 一看「都应用过」→ 不重放。未来 V30+ 无论哪条路都只应用一次。这是迁移安全的根本。 - 同一份 env 契约:
application.yml只认DB_*/UPLOAD_ROOT/REMEMBER_ME_KEY占位 —— systemd 从/etc/finance.env注入,Docker 从.env注入,app 本身对部署方式无感。迁移脚本只是把/etc/finance.env翻译成.env。 - 数据格式同源:MySQL dump 可移植;uploads 是普通文件;两路无任何 schema/格式分叉。
选项: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 的好处就没路了。
对一台现有 systemd 机器,一条命令完成(每步失败即停、可回滚):
- 预检:确认
/etc/finance.env+ finance 服务在跑 + 宿主 MySQL 可连;读出DB_NAME/DB_USER/DB_PASS/REMEMBER_ME_KEY/SERVER_PORT。 - 强制备份:
mysqldump宿主库 → 时间戳.sql.gz(安全网,失败可回 systemd)。 - 生成
.env:沿用DB_NAME/DB_USER,原样携带REMEMBER_ME_KEY(否则所有登录态失效)、SERVER_PORT;DB_PASS沿用或新生成;生成MYSQL_ROOT_PASSWORD。 - 停 systemd app 腾端口:
systemctl stop finance(只停 app,不删任何东西;宿主 MySQL 不动)。避免和 Docker app 撞SERVER_PORT;Docker MySQL 不发布 3306 → 不撞宿主 MySQL。 - 起 db 容器 + 灌数据:
docker compose up -d db→ 等 healthy → 把第 2 步的 dump 导入容器 MySQL(含schema_history,所以版本状态完整搬过去)。 - 搬 uploads:
/var/finance/uploads/*→uploads卷。 - 起 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(39.107)保持 systemd 不动 —— 在通过域名给真实/演示用户服务,不拿它冒险。
- beta 作迁移演练台:在 beta 上跑
migrate-to-docker.sh验证「数据零丢 + 版本不重放 + /health 通」,作为验收基线 #3。验完可回滚回 systemd(beta 仍要继续当 Demo)。
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;大陆访问慢用「加速 + 源码构建」兜底。
compose 只把 app 发布到 127.0.0.1:20000。文档给两段照抄式片段:① nginx + certbot(存量用户现成,把 proxy_pass 指 127.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 同步- 全新机
docker compose up -d→ 登录/填报/AI 体检全通(决策 70-73) docker compose down && up -d数据不丢(卷)·compose pull && up -d自动增量迁移- beta 跑 migrate-to-docker.sh:账户/周期/上传 logo 零丢,schema_history 不重放,/health 通(决策 77)
- 旧 deploy.sh 迭代路径仍可用(prod 不受影响)
- GHCR 出 amd64+arm64 镜像;源码构建也成功
- 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 端口(不裸奔公网)
- 全新 Docker 部署在 Mac 完全可用:compose 跨平台;基础镜像
maven/eclipse-temurin:21-jre/mysql:8.0均有 arm64,Apple Silicondocker compose build原生构建;决策 78 的 GHCR 镜像出 amd64+arm64 双架构,M-Macpull自动取 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
纯增量、零 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.css加help-fold / btn-ghost / test-res组件类。 - 后端:
IntegrationsController加POST /admin/integrations/llm/test(探测已存 key)。
- 备选 A(选定):复用现有
LlmClient.chat()发最小探测。注入List<LlmClient>(与LlmDiagnoseService同款),按vendor()找到目标 client,调chat(系统="只回复 ok", 用户="ping"),成功即连通。 - 备选 B:给
LlmClient加probe()接口方法,单独发 1-token 请求。 - 备选 C:Controller 直接拼 HTTP 调厂商 API。
- 为什么选 A:① 测的是「这条真实调用链(key→端点→模型→解析)通不通」,与体检实际走的路径完全一致,最有诊断价值;② 零接口改动、不重复 QwenClient 已有的多模型/熔断/分类逻辑;③ 成本可忽略(一次小 prompt;坏 key 最多在 Qwen 侧重试几个模型即 401)。为什么不选 B/C:B 要改接口 + 两个实现 + 绕过已有熔断分类,收益不抵成本;C 重复造轮子、且和真实调用链不一致,测过未必体检也过。
- 选定:表单 POST → flash → redirect,与现有「一键测试短信」(
/admin/reminders/sms-test)完全同款。 - 为什么:站内已有这个模式、用户已熟悉;无需新增 JS/HTMX 片段端点;实现最小、回归面最低。preview 里的内联结果态只是演示,落地用 flash 一行更省。
- 形态:LLM 卡内 每个 vendor 一个独立 mini-form(
vendor=qwen/vendor=deepseek),各自 POST,可分别验。
测试结果文案在 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。
每个 key 字段下一个 <details class="help-fold">(默认收起),内含 3-4 步申请步骤 + 控制台直链 + 链 docs/configuration.md。纯 CSS 控制展开标记(+/−),零 JS,移动端原生可用。承 [[feedback_no_emoji]]:测试结果图标用 inline SVG(check/cross),不用 emoji。
- 探测最小开销:prompt 极短;不改
K_LLM_MAX_TOKENS(输出上限本就在,实际输出 "ok" 极小)。 - 纯增量:不动
family_runtime_configschema、不动配置读取链(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
HomeController 的 / 无脑 redirect:/dashboard,而 DashboardController.anchorPeriod() 在零周期时抛 IllegalStateException;deploy.sh step10 又把 period + account 都 TRUNCATE。结论:全新部署首次登录 / → /dashboard → 500——正砸在 v0.7 目标的开源新用户身上(beta/prod 有数据未暴露)。首次引导必须连这个一起修。
- PRD 倾向「dashboard 内联面板」,但 dashboard 零周期直接崩,没法在崩溃的页面里内联。改为:
/(HomeController)智能路由:未初始化(周期数==0 或 账户数==0)→ 渲染 onboarding 页;否则 → redirect:/dashboard(原行为不变)。/本就是落地页,符合 PRD「落地页引导」。/dashboard兜底:periodMapper.findLatest(fid,1).isEmpty() → redirect:/(回引导页),anchorPeriod不再有机会抛异常。两边互不死循环(/在零周期时不会再转 dashboard)。
- 备选(否):改 dashboard 让 anchorPeriod 返回「空 Period」+ 内联面板 —— 要改一堆 factview 空值路径,风险大、收益低。
- 备选(否):弹窗/向导 —— PRD 已排除。
initialized = 周期数>0 && 自建账户数>0,全用现成查询:
- 周期:
periodMapper.countByFamily(fid)(已有)。 - 账户:
accountMapper.findActiveByFamily(fid).size()(已有,单家庭账户数少,开销可忽略)。 两者都满足 → dashboard 有锚点周期、有账户可展示,不崩、不空。判定只在/跑一次,轻。
- 复用
fragments/layout :: head+fragments/nav(nav由GlobalModelAdvice全局注入,无需 controller 额外塞)。 - 内容:欢迎语 + 一句话周期流程(开周期 → 各成员填余额 → 关周期 → 出净资产/收益报告)+ 3 步:
- 加账户 →
/accounts/new(done if 账户数>0) - 开本期周期 →
/admin/periods(done if 周期数>0) - 填本期余额 →
/entry(收尾步;①②齐后/自动转 dashboard,本步在 dashboard/填报页完成) 每步带状态(✓ 已完成 / 待办,inline SVG 不用 emoji),高亮下一未完成。
- 加账户 →
- 底部「顺手」软链:改家庭名
/admin/family· 改成员名/admin/members· 接 AI/短信docs/configuration.md。 - 文案不用「实例/初始化/OPEN」技术词(承 [[feedback_user_friendly_naming]])。
entry/index.html 顶部(有周期时才渲染)加一行注解:周期流程:开 → 填余额 → 关 → 出报告 · 本页是「填余额」这步。轻提示,不改逻辑。
- 纯增量:不改 schema、不动 factview/计算;
/dashboard改动只加一行「无周期则 redirect:/」;存量(有数据)→/照旧 redirect dashboard,零变化。 - QA:qa-run 加守护(HomeController 有初始化判定分支 + onboarding 模板存在 + dashboard 有无周期兜底 redirect);新增
OnboardingRoutingTest验「零周期/零账户→onboarding,有数据→redirect dashboard」。 - mvn test 全绿 + 部署 beta(beta 有数据 → 仍直接 dashboard;可临时造一个零周期家庭或本地验空态)。
症状:种子账号首登强制改密,改完被无限弹回改密页,手动改库 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/login见me != null→redirect:/dashboard(不给登录表单)。MustChangePasswordInterceptor读me.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 作废。下次/login即me==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。
触发: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 编排变更。
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。
- 选定:
$DC pull失败后,用pull_one mysql:8.0(timeout/gtimeout50s 包裹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()封装。
- 选定:仅当
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(覆盖重启命令)。
cn_hub_blocked_guide用uname -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.yaml的docker.registry-mirrors+colima restart;command -v orb→orb config docker+orb restart docker;并始终给 Docker Desktop 的 Settings → Docker Engine。
- Linux(
- 为何 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 镜像不受影响),三端机制统一加这一段即可。
改动面:docker/entrypoint.sh(全新库判定 + 调清理)+ 新建 docker/clean-dev-data.sh + Dockerfile(COPY 脚本)+ README/文档。0 Java、0 schema、0 编排端口变更。
- 背景/风险:Docker 全新装会跑 V2/V3/V4(含演示数据),需像 systemd
deploy.shstep10 那样清成空态。但删数据极敏感,必须确保绝不误删:① systemd→Docker 迁移来的真实库(migrate-to-docker.sh灌 dump);② v0.7.0~0.7.4 已用 Docker 的老用户升级库(其 db-data 卷里演示数据与真实数据已混存,truncate 会连真实数据一起删)。 - 选定:在
entrypoint.sh跑apply.sh之前,查information_schema.tables看schema_history表在不在 —— 不存在 = 从未迁移过的全新空卷 = FRESH_DB。只有 FRESH_DB 才在迁移后调clean-dev-data.sh。- 自限:清完后 schema_history 已建,后续重启 FRESH_DB=no,永不再清。
- 迁移库:
migrate-to-docker.shstep4 把含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 路径依赖「先应用再清」。保持一致,采用「迁移后清」。
- 三重防线:①
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兜底,不挡应用启动)。
- 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-run加v07-CLEAN-1/2(脚本逻辑 + Dockerfile/entrypoint 接线 + README 无<your-org>)。- 红线:删数据三重防线;迁移/升级库 FRESH_DB=no 绝不清;0 schema;不动 systemd step10。
- 桩(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-mirrors后mysql:8.0实拉成功(2026-06-22),桩里"配了就能拉"的假设有真机背书。 qa-run.sh加v07-CN-1/2;docker-up.sh仍过v07-DOCKER-4的bash -n。- 红线:自动写
daemon.json三重前置(同意 + 不存在 + 告知重启);公共镜像免登录;0 schema / 0 编排 / 存量零影响。