gongxiangp2/docs/生产数据库安全增量迁移SOP-2026-08-12.md

31 KiB
Raw Blame History

生产数据库安全增量迁移 SOP(2026-08-12)

结论

当前应用发布工具验证三十五份目标 migration。普通部署脚本只读验证,不会自动修改数据库。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 prisma migrate deploy。目标名称以该精确提交的 TARGET_MIGRATIONS 为准,不能沿用历史批次的数量或凭证。

目标 migration:

  • 20260811_add_video_transcode_lease
  • 20260811_update_brand_logo_defaults
  • 20260812_add_product_package_upload_job_lease
  • 20260812_align_material_upload_idempotency_index
  • 20260813_add_material_custody_department
  • 20260813_add_organization_units_and_employment_state
  • 20260813_add_user_employee_binding
  • 20260817_add_material_is_incomplete_shell
  • 20260820_add_ding_workflow_snapshots
  • 20260824_expand_ding_workflow_insights
  • 20260826_add_ding_workflow_demand_type
  • 20260827_add_ding_workflow_detail_fields
  • 20260826_add_pack_video_early_access
  • 20260828_add_pack_item_can_edit_assets
  • 20260828_add_task_detail_change_approvals
  • 20260829_add_recoverable_api_key_secret
  • 20260831_add_private_task_detail
  • 20260902_add_employee_roster_history
  • 20260903_add_work_review_brand
  • 20260908_add_brand_knowledge
  • 20260909_expand_brand_knowledge_upload
  • 20260914_add_brand_knowledge_engagement
  • 20260914_expand_brand_knowledge_download_count
  • 20260914_expand_brand_knowledge_preview_slices
  • 20260915_add_brand_knowledge_soft_delete
  • 20260918_add_performance_conversion_sync
  • 20260923_add_certificate_report_metadata
  • 20260924_add_certificate_tested_on
  • 20261008_permission_group_department_identity
  • 20261008_team_function_configs
  • 20261009_material_purge_outbox
  • 20261009_material_purge_reference_indexes
  • 20261012_detail_generator
  • 20261012_ai_model_registry
  • 20261013_ai_model_capabilities

20261012_detail_generator 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。

20261012_ai_model_registry 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;20261013_ai_model_capabilities 仅新增目录的可空 capability_metadata JSONB 列。受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束;切流前复核这两份 SQL 的精确校验和及四表字段。迁移不写入真实密钥、不自动导入或开放模型。

部署后由当前有效超级管理员显式执行“迁入当前连接”,再“同步目录”。迁入继续引用现有 DETAIL_GENERATOR_API_KEY 和精确原地址,保留已验证的 Gemini 2.5 Flash 分析/文案及 Nano Banana 2 生图默认;同步不会开放新发现模型,也不调用付费生成。应核对迁入审计、目录数量、三阶段默认和普通用户拒绝状态后完成验收。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 API_KEY_ENCRYPTION_KEY,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。

已有标准 drain 能力和稳定代理的生产实例,继续使用当前 direct 发布策略。维护、备份和迁移期间同时持有发布锁与代理 reconcile 锁,验证三个入口 503、作业及连接排空、旧实例停止后再取备份。完成迁移审计才交接给同 SHA 的标准发布 payload;交接前释放锁,避免 payload 重取时自锁。恢复旧实例时按其实际 IP 生成恢复代理配置,保留原配置并验证三个入口的旧 SHA。

20260923_add_certificate_report_metadata 仅为 materials 增加五个可空字段:报告类别、证书到期日、证书编号、证书使用说明和检测报告使用规则说明。不回填或修改已有素材行;旧报告需要编辑时再补录类别和证书信息。

20260924_add_certificate_tested_on 增加独立的可空“证书检测日期”字段。原“到期日”保留为历史数据,但不再作为检测日期展示或写入;由于两者语义不同,不自动回填。已上传的报告需根据原件补录检测日期。

20260918_add_performance_conversion_sync 只新增业绩转化经营数据快照表和每日增量同步状态表。首次同步按已完结美术流程的商品 ID 补齐六个月监控区间,后续每日重刷最近两天;租约索引用于防止多实例重复执行,不改写流程快照或上游报表库。

20260915_add_brand_knowledge_soft_delete 只为品牌知识增加可空删除时间和查询索引。删除操作仅写入删除时间,主文件和预览图继续保留;列表、详情、下载、预览、点赞和收藏均排除已删除内容。

20260914_expand_brand_knowledge_preview_slices 只为品牌知识增加默认空数组的非空预览切片路径列。已有知识继续读取原单图路径;新上传的长预览图保存为有序 WebP 切片并在详情页连续展示,不改写已有文件。

20260914_expand_brand_knowledge_download_count 只为品牌知识增加默认值为 0 的非空下载次数列,用于详情页展示,并在文件流正常结束后原子计数;客户端取消或传输失败不计数,计数写入失败也不阻断文件响应。该迁移不改写知识文件。

20260914_add_brand_knowledge_engagement 只新增品牌知识点赞、收藏关联表及唯一约束、查询索引和级联外键,不改写已有知识内容。

它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 system_settings 品牌两列中的退役 s3.1080p.hk 值改为本地品牌图,并把 materials_uploadId_key 重建为“uploadId 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。20260829_add_recoverable_api_key_secret 只新增 api_keys.secret_ciphertext / secret_nonce / secret_auth_tag 三个可空加密列、api_key_reveal_audits 审计表和 api_key_id+created_at、viewer_user_id+created_at 两个非唯一 btree 索引,不回填旧 key。20260831_add_private_task_detail 只新增 materials.task_detail_demand_type、materials.task_detail_private default false、material_permission_pack_items.can_access_private default false,并加上 materials_private_optimization_check,确保私密任务详情只能落在“任务-详情 + 优化”语义下。20260902_add_employee_roster_history 新增员工完整花名册的时间快照表及查询索引,团队产能按每周结束前最近一份快照统计在职人数;不会用当前花名册回填没有快照的历史周。新增 form_fields 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 uploadId;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。

强制前提

  1. 使用将要发布的精确 40 位 Git SHA;在新建的隔离检出目录构建,不清理或复用现有工作树。
  2. 已用 pg_dump --format=custom 创建生产数据库备份,并在隔离数据库做过真实恢复验证;备份产物与 receipt 一起只读挂载。
  3. 明确生产数据库的配置 host/port/database/user,以及数据库实际返回的 inet_server_addr() / inet_server_port();不允许从旧文档猜测。
  4. 本轮首次旧镜像发布必须先把 runner 与 migrator 固定到同一个精确 SHA/digest;先按 scripts/legacy-worker-bootstrap-deploy.ps1 完成 Audit,再 Apply 进入外部实测 503 维护态,并证明旧容器已停止但仍保留可回退。只有这些证据全部成立,才允许设置 CONFIRM_MAINTENANCE_WINDOW=NO_WRITES_DURING_SNAPSHOT。
  5. 不使用历史 execute-migration.ps1/.sh;它们不登记本轮 Prisma migration。

不能以“当前无人使用”“已经通知大家”或只改一个维护开关代替 503 和旧容器停止证据。正确顺序固定为:

  1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
  2. legacy bootstrap Audit;
  3. legacy bootstrap Apply,外部验收 503,确认旧容器停止且保留;
  4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十五条目标 migration 和迁移后审计;
  5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。

顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。

1. 构建并传输精确 runner 与 migrator

runner 和 migrator 必须来自同一个 TARGET_SHA,但它们是两个不同的镜像,必须分别记录 image ID/digest。普通发布脚本的 -SkipDeploy 只构建并推送 runner,不会构建 migrator。

1.1 先准备 runner

在正式维护窗之前,用普通发布脚本的生产参数和 -SkipDeploy 构建、推送 runner。脚本会固定 Git SHA、基础镜像 digest、最终 image ID 和 registry manifest digest,但不会连接生产机或改流量。记录它输出的 runner repo@sha256:... 和 image ID;生产机只能按该 digest 预拉取并复核,不能按可变 tag 猜测。

1.2 独立构建 migrator

在构建机使用一次性的隔离 clone。下面不会执行 reset --hard 或 clean,因此不会破坏构建机已有工作区:

set -Eeuo pipefail
TARGET_SHA=<40位目标SHA>
SOURCE_REPO=<仓库地址>
BUILD_ROOT="$(mktemp -d /root/gongxiangp2-migrator-build.XXXXXX)"
trap 'rm -rf -- "$BUILD_ROOT"' EXIT
git clone --no-checkout --branch main --single-branch "$SOURCE_REPO" "$BUILD_ROOT/repo"
git -C "$BUILD_ROOT/repo" checkout --detach "$TARGET_SHA"
test "$(git -C "$BUILD_ROOT/repo" rev-parse HEAD)" = "$TARGET_SHA"
test -z "$(git -C "$BUILD_ROOT/repo" status --porcelain --untracked-files=all)"
cd "$BUILD_ROOT/repo"
DOCKER_BUILDKIT=1 docker build \
  --target migrator \
  --build-arg VCS_REF="$TARGET_SHA" \
  -t "youyidian-3d-migrator:$TARGET_SHA" .
docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
  "youyidian-3d-migrator:$TARGET_SHA"
MIGRATOR_BAKED_SHA="$(docker run --rm --network none --read-only \
  --entrypoint cat "youyidian-3d-migrator:$TARGET_SHA" /app/DEPLOYMENT_COMMIT)"
test "$MIGRATOR_BAKED_SHA" = "$TARGET_SHA"

revision label 和 baked SHA 必须精确等于目标 SHA。构建机和生产机可能运行不同版本的 Docker,docker image inspect .Id 对带 provenance 的镜像可能分别显示 manifest-list digest 与 config digest,因此不能直接把构建机 .Id 当成跨主机身份凭证。下一步必须从 docker save 的可信归档中提取该镜像唯一的 config digest,再与生产装载后的 .Id 比较。

1.3 通过已固定主机指纹的 SSH 传输 migrator

不能经未加密 HTTP registry 或临时关闭 SSH 主机校验。下面使用构建机上已经由发布脚本预置并核验过的生产 known_hosts;实际用户名、主机名和文件路径按现场受控配置填写:

set -Eeuo pipefail
MIGRATOR_IMAGE="youyidian-3d-migrator:$TARGET_SHA"
MIGRATOR_ARCHIVE="$(mktemp /root/gongxiangp2-migrator-${TARGET_SHA}.XXXXXX.tar)"
trap 'rm -f -- "$MIGRATOR_ARCHIVE"' EXIT
docker save --output "$MIGRATOR_ARCHIVE" "$MIGRATOR_IMAGE"
MIGRATOR_ARCHIVE_SHA256="$(sha256sum "$MIGRATOR_ARCHIVE" | awk '{print $1}')"
test -n "$MIGRATOR_ARCHIVE_SHA256"
MIGRATOR_IMAGE_ID="$(tar -xOf "$MIGRATOR_ARCHIVE" manifest.json | node -e '
  const fs = require("node:fs")
  const image = process.argv[1]
  const entries = JSON.parse(fs.readFileSync(0, "utf8"))
  const matches = entries.filter((entry) => Array.isArray(entry.RepoTags) && entry.RepoTags.includes(image))
  if (matches.length !== 1 || typeof matches[0].Config !== "string") process.exit(2)
  const configMatch = /^blobs\/sha256\/([0-9a-f]{64})$/.exec(matches[0].Config)
  if (!configMatch) process.exit(3)
  process.stdout.write(`sha256:${configMatch[1]}`)
' "$MIGRATOR_IMAGE")"
test -n "$MIGRATOR_IMAGE_ID"

SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=yes \
  -o UserKnownHostsFile=/root/.ssh/gongxiangp2-production-known_hosts)
PRODUCTION_SSH=<受控用户@生产主机>
REMOTE_ARCHIVE="/root/gongxiangp2-migrator-${TARGET_SHA}.tar"

scp "${SSH_OPTS[@]}" -- "$MIGRATOR_ARCHIVE" "$PRODUCTION_SSH:$REMOTE_ARCHIVE"
ssh "${SSH_OPTS[@]}" "$PRODUCTION_SSH" bash -s -- \
  "$REMOTE_ARCHIVE" "$MIGRATOR_ARCHIVE_SHA256" "$MIGRATOR_IMAGE" \
  "$MIGRATOR_IMAGE_ID" "$TARGET_SHA" <<'PRODUCTION_VERIFY'
set -Eeuo pipefail
archive="$1"; expected_archive_sha="$2"; image="$3"; expected_image_id="$4"; expected_sha="$5"
trap 'rm -f -- "$archive"' EXIT
[ -f "$archive" ] && [ ! -L "$archive" ]
[ "$(stat -c '%a' "$archive")" = "600" ]
[ "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_archive_sha" ]
docker load --input "$archive" >/tmp/gongxiangp2-migrator-load.log
[ "$(docker image inspect --format '{{.Id}}' "$image")" = "$expected_image_id" ]
[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image")" = "$expected_sha" ]
[ "$(docker run --rm --network none --read-only --entrypoint cat "$image" /app/DEPLOYMENT_COMMIT)" = "$expected_sha" ]
PRODUCTION_VERIFY

scp 目标目录必须预先由 root 创建且不可由其他账号写入;传输完成后生产端会重新核对 archive SHA、migrator image ID、OCI revision 和 baked commit。任一不一致都在进入维护窗前停止。

2. 用工具生成可核验的备份凭证

禁止手写或补填 receipt。scripts/create-verified-production-backup.mjs 会在同一次受控运行中自动完成:

恢复使用原始 custom 归档分段执行 pg_restore。pre-data 完成后,先验证两个既有回收站函数的确定签名、语言、返回类型、不可变性及空配置;仅在隔离库给它们临时设置 pg_catalog, public 搜索路径,再完整恢复 data 和 post-data。恢复后 RESET,并核对函数定义与全部已捕获配置完全一致,才进行摘要验证和生成 receipt。此步骤解决空搜索路径下重建回收站 GIN 索引时的旧函数内部调用问题;不修改归档、生产函数或全局恢复搜索路径。

  1. 只读核对生产库与隔离库的配置身份和数据库实际身份;
  2. 在生产库读取迁移表、素材、用户、系统设置、产品包作业的有界摘要;
  3. 用 pg_dump --format=custom 写入本次唯一的 .partial 文件,刷新到磁盘后原子改名;
  4. 再读一次生产摘要。维护窗内前后摘要不同就停止,不生成 receipt;
  5. 在第二道明确授权下,只清空符合命名规则的隔离库 public schema,再用 pg_restore 恢复同一备份;
  6. 在 30 秒 statement timeout 和每表最多 32 条样本的边界内,对比关键表计数与样本摘要;
  7. 全部一致后,原子生成 receipt,并输出 receipt 自身 SHA-256。

2.1 准备真正隔离的恢复目标

隔离目标必须同时满足:

  • 数据库名匹配 gongxiangp2_restore_<唯一小写后缀>;
  • 数据库名与生产库不同;
  • 配置 host/port 与生产不同;
  • inet_server_addr() / inet_server_port() 返回的实际 PostgreSQL 服务器也与生产不同;
  • 只供本次恢复验证,账号允许在这个隔离库内重建 public schema,不拥有生产库写权限。

因此,同一台生产 PostgreSQL 上另建一个数据库也不合格。先进入维护窗口,停止会修改关键业务表的入口;工具的前后摘要只是近似验证,不能代替维护窗口。

2.2 准备两份 root-only 环境文件

实际密码、证书路径和连接值不写在命令、Git、聊天或运行日志里。由密码管理工具在生产机生成两份 0600 环境文件;下面只列变量名,不展示值:

  • 生产连接:PRODUCTION_PGHOST、PRODUCTION_PGPORT、PRODUCTION_PGDATABASE、PRODUCTION_PGUSER,以及 PRODUCTION_PGPASSWORD 或 PRODUCTION_PGPASSFILE;
  • 生产期望身份:EXPECTED_DATABASE_HOST、EXPECTED_DATABASE_PORT、EXPECTED_DATABASE_NAME、EXPECTED_DATABASE_USER、EXPECTED_DATABASE_SERVER_ADDRESS、EXPECTED_DATABASE_SERVER_PORT;
  • 隔离连接:ISOLATED_PGHOST、ISOLATED_PGPORT、ISOLATED_PGDATABASE、ISOLATED_PGUSER,以及 ISOLATED_PGPASSWORD 或 ISOLATED_PGPASSFILE;
  • 隔离期望身份:EXPECTED_ISOLATED_DATABASE_HOST、EXPECTED_ISOLATED_DATABASE_PORT、EXPECTED_ISOLATED_DATABASE_NAME、EXPECTED_ISOLATED_DATABASE_USER、EXPECTED_ISOLATED_DATABASE_SERVER_ADDRESS、EXPECTED_ISOLATED_DATABASE_SERVER_PORT。

若现场要求 TLS,可在各自前缀下增加 PGSSLMODE、PGSSLROOTCERT、PGSSLCERT、PGSSLKEY。PGPASSFILE 和证书若是宿主机路径,还要只读挂载到容器内,并在环境文件中填写容器内路径。

期望的 server address/port 必须来自当次现场只读查询。使用已安全注入的标准 PG* 环境变量执行,不在命令行传密码或连接 URL:

psql --no-psqlrc --no-align --tuples-only --set=ON_ERROR_STOP=1 <<'SQL'
SELECT current_database(), current_user,
       COALESCE(inet_server_addr()::text, ''),
       COALESCE(inet_server_port()::text, '');
SQL

2.3 先运行备份工具的只读审计

下面的网络必须能访问生产 PostgreSQL 和真正隔离的 PostgreSQL,但隔离账号不得拥有生产写权限:

set -Eeuo pipefail
TARGET_SHA=<40位目标SHA>
MIGRATOR_IMAGE="youyidian-3d-migrator:$TARGET_SHA"
PRODUCTION_BACKUP_ENV=/root/secure/gongxiangp2-production-backup.env
ISOLATED_RESTORE_ENV=/root/secure/gongxiangp2-isolated-restore.env

test "$(stat -c '%a' "$PRODUCTION_BACKUP_ENV")" = 600
test "$(stat -c '%a' "$ISOLATED_RESTORE_ENV")" = 600
docker run --rm \
  --network <可同时访问两套数据库的受控网络> \
  --env-file "$PRODUCTION_BACKUP_ENV" \
  --env-file "$ISOLATED_RESTORE_ENV" \
  -e EXPECTED_COMMIT_SHA="$TARGET_SHA" \
  --entrypoint node \
  "$MIGRATOR_IMAGE" \
  scripts/create-verified-production-backup.mjs --audit

审计模式只执行客户端版本探测和两套身份查询,不创建 dump、不清空隔离库、不恢复、不生成 receipt。输出只包含去密后的身份;任何字段不一致都先修正环境文件,不能绕过。

2.4 显式创建并验证备份

为本次发布创建一个全新的宿主机目录。工具会拒绝符号链接、已有同名文件和非目录;不会复用历史 receipt:

set -Eeuo pipefail
BACKUP_BATCH="$(date -u +%Y%m%dT%H%M%SZ)-${TARGET_SHA}"
HOST_BACKUP_DIR="/root/gongxiangp2-db-backup-receipt/$BACKUP_BATCH"
HOST_RESULT_DIR="/root/gongxiangp2-migration-audit/$BACKUP_BATCH"
HOST_RESULT_FILE="$HOST_RESULT_DIR/verified-backup-result.txt"
MIGRATOR_UID="$(docker run --rm --entrypoint id "$MIGRATOR_IMAGE" -u)"
install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" "$HOST_BACKUP_DIR"
install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" "$HOST_RESULT_DIR"
test ! -L "$HOST_BACKUP_DIR"
test -z "$(find "$HOST_BACKUP_DIR" -mindepth 1 -maxdepth 1 -print -quit)"
test ! -e "$HOST_RESULT_FILE"

docker run --rm \
  --network <可同时访问两套数据库的受控网络> \
  --env-file "$PRODUCTION_BACKUP_ENV" \
  --env-file "$ISOLATED_RESTORE_ENV" \
  -e EXPECTED_COMMIT_SHA="$TARGET_SHA" \
  -e BACKUP_OUTPUT_DIR=/backup \
  -e ALLOW_VERIFIED_BACKUP=CREATE_AND_RESTORE \
  -e ALLOW_ISOLATED_DATABASE_RESET=RESET_VERIFIED_ISOLATED_TARGET \
  -e CONFIRM_MAINTENANCE_WINDOW=NO_WRITES_DURING_SNAPSHOT \
  -v "$HOST_BACKUP_DIR:/backup" \
  --entrypoint node \
  "$MIGRATOR_IMAGE" \
  scripts/create-verified-production-backup.mjs --create-and-restore \
  | tee "$HOST_RESULT_FILE"

只有末尾出现 Production backup was created, restored to the isolated server and verified. 才算成功。保存工具输出的 artifactPath、artifactSize、artifactSha256、receiptPath、receiptSize、receiptSha256。receipt 自动绑定:

  • 精确 sourceCommit;
  • 生产 configured/server database fingerprint;
  • /backup/...dump 容器路径、字节数、SHA-256 和起止时间;
  • 隔离库 fingerprint、恢复时间和同一 artifact SHA-256;
  • 生产前后与恢复库的关键表计数、有限样本摘要及整体摘要 SHA-256。

pg_dump、pg_restore、摘要查询、身份核对或原子写入任一步失败,都不会产生 .receipt.json。可能已生成但未通过恢复验证的 .dump 只能作为失败现场证据,不能交给 migrator。运行 migrator 时把同一个 HOST_BACKUP_DIR 以 /backup:ro 挂载;工具会重新读取完整 artifact 和 receipt,核对类型、路径、字节数、SHA-256 与有效期。receipt 最多允许 BACKUP_MAX_AGE_MINUTES 指定的时长(范围 1–1440 分钟)。

在退出维护窗前,先从 helper 的最终 JSON 结果提取本批次精确路径和 SHA;不要按时间猜文件名,也不要把上级备份目录整体挂进 migrator。下面的 node 只读取结果和 receipt,不接触数据库:

readarray -t BACKUP_EVIDENCE < <(node --input-type=commonjs - "$HOST_RESULT_FILE" "$HOST_BACKUP_DIR" <<'NODE'
const fs = require('node:fs')
const path = require('node:path')

const [resultPath, hostBackupDir] = process.argv.slice(2)
const resultText = fs.readFileSync(resultPath, 'utf8')
const marker = '\nProduction backup was created, restored to the isolated server and verified.\n'
const markerIndex = resultText.lastIndexOf(marker)
if (markerIndex < 0) throw new Error('helper success marker is missing')
const resultJson = resultText.slice(0, markerIndex).trim()
if (!resultJson.startsWith('{') || !resultJson.endsWith('}')) throw new Error('helper final JSON is missing')
const result = JSON.parse(resultJson)

const entries = fs.readdirSync(hostBackupDir, { withFileTypes: true })
const receiptNames = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.receipt.json')).map((entry) => entry.name)
const dumpNames = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.dump')).map((entry) => entry.name)
if (receiptNames.length !== 1 || dumpNames.length !== 1 || entries.length !== 2) {
  throw new Error('batch directory must contain exactly one regular receipt and one regular dump')
}
for (const name of [receiptNames[0], dumpNames[0]]) {
  const candidate = path.join(hostBackupDir, name)
  const stat = fs.lstatSync(candidate)
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('backup evidence must be regular non-symlink files')
}
const receiptPath = path.join(hostBackupDir, receiptNames[0])
const dumpPath = path.join(hostBackupDir, dumpNames[0])
const receiptBytes = fs.readFileSync(receiptPath)
const receipt = JSON.parse(receiptBytes.toString('utf8'))
const crypto = require('node:crypto')
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
const receiptSha256 = sha256(receiptBytes)
const dumpStat = fs.lstatSync(dumpPath)
const dumpHash = crypto.createHash('sha256')
const dumpStream = fs.createReadStream(dumpPath)
dumpStream.on('data', (chunk) => dumpHash.update(chunk))
dumpStream.once('error', (error) => { throw error })
dumpStream.once('end', () => {
  const dumpSha256 = dumpHash.digest('hex')
  if (
    result.receiptPath !== `/backup/${receiptNames[0]}`
    || result.receiptSize !== receiptBytes.length
    || result.receiptSha256 !== receiptSha256
    || result.artifactPath !== `/backup/${dumpNames[0]}`
    || result.artifactSize !== dumpStat.size
    || result.artifactSha256 !== dumpSha256
    || receipt.backup.artifactPath !== result.artifactPath
    || receipt.backup.artifactSize !== result.artifactSize
    || receipt.backup.artifactSha256 !== result.artifactSha256
  ) throw new Error('helper output, receipt and artifact evidence do not match')
  process.stdout.write(`${receiptNames[0]}\n${receiptSha256}\n`)
})
NODE
)
test "${#BACKUP_EVIDENCE[@]}" -eq 2
BACKUP_RECEIPT_BASENAME="${BACKUP_EVIDENCE[0]}"
EXPECTED_BACKUP_RECEIPT_SHA256="${BACKUP_EVIDENCE[1]}"
test -n "$BACKUP_RECEIPT_BASENAME"
test -n "$EXPECTED_BACKUP_RECEIPT_SHA256"

当前状态(2026-08-12):代码和伪 PostgreSQL 测试已就绪,但尚未对真实生产库执行 pg_dump、隔离恢复或任何 migration。本文步骤不是已完成证明。

3. 先做只读审计

下例只展示参数结构;真实值从生产现场读取,不写进 Git:

PREFLIGHT_AUDIT_FILE="$HOST_RESULT_DIR/preflight-$(date -u +%Y%m%dT%H%M%SZ).json"
MIGRATION_AUDIT_FILE="$HOST_RESULT_DIR/applied-$(date -u +%Y%m%dT%H%M%SZ).json"
test ! -e "$PREFLIGHT_AUDIT_FILE"
test ! -e "$MIGRATION_AUDIT_FILE"
docker run --rm \
  --network youyidian-3d_app-network \
  --env-file /mnt/PoolOne/apps/youyidian-3d/.env \
  -e EXPECTED_COMMIT_SHA=<40位目标SHA> \
  -e MIGRATION_AUDIT_OUTPUT="/audit/$(basename "$PREFLIGHT_AUDIT_FILE")" \
  -v "$HOST_RESULT_DIR:/audit" \
  --entrypoint node \
  youyidian-3d-migrator:<40位目标SHA> \
  scripts/safe-production-migrate.mjs --audit
test -f "$PREFLIGHT_AUDIT_FILE" && test ! -L "$PREFLIGHT_AUDIT_FILE"
test "$(stat -c '%a' "$PREFLIGHT_AUDIT_FILE")" = "600"

审计只挂载前一步已按 migrator UID 创建的本批次 HOST_RESULT_DIR,不挂载其 root 所有的上级目录。审计输出不得包含数据库密码。确认目标库、账号、服务器地址和当前 migration 状态,若出现失败 migration 或本轮以外的未执行 migration,立即停止。

先确认 migrator 容器内的 node 用户 UID,并预创建只属于本次发布的审计目录;目录必须可写,已存在的审计文件不得复用:

MIGRATOR_UID="$(docker run --rm --entrypoint id youyidian-3d-migrator:<40位目标SHA> -u)"
install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" /root/gongxiangp2-migration-audit/<发布批次>

4. 带备份凭证执行

把备份 receipt 只读挂载到容器,显式提供生产身份:

docker run --rm \
  --network youyidian-3d_app-network \
  --env-file /mnt/PoolOne/apps/youyidian-3d/.env \
  -e ALLOW_PRODUCTION_MIGRATIONS=APPLY \
  -e EXPECTED_COMMIT_SHA=<40位目标SHA> \
  -e EXPECTED_DATABASE_HOST=<DATABASE_URL中的主机> \
  -e EXPECTED_DATABASE_PORT=<DATABASE_URL中的端口> \
  -e EXPECTED_DATABASE_NAME=<数据库名> \
  -e EXPECTED_DATABASE_USER=<数据库账号> \
  -e EXPECTED_DATABASE_SERVER_ADDRESS=<inet_server_addr结果> \
  -e EXPECTED_DATABASE_SERVER_PORT=<inet_server_port结果> \
  -e BACKUP_RECEIPT_PATH="/backup/$BACKUP_RECEIPT_BASENAME" \
  -e BACKUP_ARTIFACT_ROOT=/backup \
  -e BACKUP_MAX_AGE_MINUTES=240 \
  -e EXPECTED_BACKUP_RECEIPT_SHA256="$EXPECTED_BACKUP_RECEIPT_SHA256" \
  -e MIGRATION_AUDIT_OUTPUT="/audit/$(basename "$MIGRATION_AUDIT_FILE")" \
  -v "$HOST_BACKUP_DIR:/backup:ro" \
  -v "$HOST_RESULT_DIR:/audit" \
  "$MIGRATOR_IMAGE" --apply

工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 prisma migrate deploy,最后核对三十三条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、api_keys 三个可空加密列、api_key_reveal_audits 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、materials_uploadId_key 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 materials.task_detail_demand_type 无默认值、materials.task_detail_private / material_permission_pack_items.can_access_private 默认值为 false,并用稳定语义核对 materials_private_optimization_check,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。

apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:

test -f "$MIGRATION_AUDIT_FILE" && test ! -L "$MIGRATION_AUDIT_FILE"
chmod 0600 "$MIGRATION_AUDIT_FILE"
MIGRATION_AUDIT_SHA256="$(sha256sum "$MIGRATION_AUDIT_FILE" | awk '{print $1}')"
test -n "$MIGRATION_AUDIT_SHA256"

5. 放行条件

只有以下全部成立,才进入应用首次安全上线步骤:

  • applied.json 的 verified=true;
  • 三十三条 target migration 均 completed=true,source/database checksum 一致;
  • _prisma_migrations.finished_at 非空、rolled_back_at 为空;
  • 视频租约列/索引、任务详情作业租约列/索引、active-only uploadId 唯一索引、品牌默认值精确匹配;
  • 再次运行 --audit 仍通过;
  • 备份 receipt、迁移审计文件、目标 SHA 和 migrator image ID 被记录在当次发布记录中。

任意一项不成立:不部署应用、不手工修改 _prisma_migrations、不批量改业务状态;先保留现场并依据备份制定恢复方案。

6. 用同一 SHA 执行迁移批次的直接替换部署

数据库迁移与日常应用发布是两条独立流程。只有本节这种“刚完成迁移、要上线依赖该 schema 的代码”的批次,才向部署脚本传入 -RequireMigrationAudit 和审计凭证。日常代码、前端与配置发布不传这些参数,不会创建 dump、恢复隔离库或要求历史审计文件。

保持 bootstrap 维护入口和全局部署锁不变,使用第 1 步固定的 runner,并把第 4 步真实生成的审计文件与 SHA-256 显式交给迁移批次部署脚本:

.\scripts\git-incremental-deploy.ps1 `
  -Target production `
  -Commit '<与 runner/migrator 完全相同的 40 位 SHA>' `
  -MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-<UTC时间>.json' `
  -MigrationAuditSha256 '<该文件的 64 位小写 SHA-256>' `
  -RequireMigrationAudit `
  -KeepOldImages

脚本会在候选创建前核对审计文件的路径、普通文件属性、0600 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。对于显式带 -RequireMigrationAudit 的迁移批次,缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。