# 生产数据库安全增量迁移 SOP(2026-08-12) ## 结论 当前应用发布工具验证十九份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。 目标 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` 它们新增可空租约字段/索引、修改未来 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`,因此不会破坏构建机已有工作区: ```bash 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`;实际用户名、主机名和文件路径按现场受控配置填写: ```bash 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` 会在同一次受控运行中自动完成: 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: ```bash 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,但隔离账号不得拥有生产写权限: ```bash 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: ```bash 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,不接触数据库: ```bash 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: ```bash 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,并预创建只属于本次发布的审计目录;目录必须可写,已存在的审计文件不得复用: ```bash 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 只读挂载到容器,显式提供生产身份: ```bash 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= \ -e EXPECTED_DATABASE_PORT= \ -e EXPECTED_DATABASE_NAME=<数据库名> \ -e EXPECTED_DATABASE_USER=<数据库账号> \ -e EXPECTED_DATABASE_SERVER_ADDRESS= \ -e EXPECTED_DATABASE_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 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写: ```bash 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 显式交给迁移批次部署脚本: ```powershell .\scripts\git-incremental-deploy.ps1 ` -Target production ` -Commit '<与 runner/migrator 完全相同的 40 位 SHA>' ` -MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-.json' ` -MigrationAuditSha256 '<该文件的 64 位小写 SHA-256>' ` -RequireMigrationAudit ` -KeepOldImages ``` 脚本会在候选创建前核对审计文件的路径、普通文件属性、`0600` 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。对于显式带 `-RequireMigrationAudit` 的迁移批次,缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。