diff --git a/Dockerfile b/Dockerfile index 2635110..cf90517 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,13 +48,13 @@ CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"] # 构建阶段 FROM base AS builder ARG ALPINE_MIRROR -ARG VCS_REF # 安装构建依赖 RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache libc6-compat python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev ffmpeg openssl WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG VCS_REF # The source revision is immutable image metadata, not a runtime environment # assertion. Production builds must provide the exact 40-character Git SHA. @@ -146,9 +146,7 @@ RUN echo "清理 standalone 目录..." && \ # identity, exact source SHA and a hashed backup receipt before --apply works. FROM base AS migrator ARG ALPINE_MIRROR -ARG VCS_REF WORKDIR /app -LABEL org.opencontainers.image.revision=$VCS_REF ENV NODE_ENV=production RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache openssl postgresql-client && \ @@ -156,6 +154,8 @@ RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk command -v pg_dump >/dev/null && \ command -v pg_restore >/dev/null && \ command -v psql >/dev/null +ARG VCS_REF +LABEL org.opencontainers.image.revision=$VCS_REF COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/scripts/safe-production-migrate.mjs ./scripts/safe-production-migrate.mjs @@ -174,11 +174,8 @@ ENTRYPOINT ["node", "scripts/safe-production-migrate.mjs"] # 生产运行阶段 FROM base AS runner -ARG VCS_REF WORKDIR /app -LABEL org.opencontainers.image.revision=$VCS_REF - ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 @@ -189,6 +186,8 @@ RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk flock --exclusive --timeout 1 --conflict-exit-code 75 --no-fork \ /tmp/material-upload-flock-probe.lock sh -c 'true' && \ rm -f /tmp/material-upload-flock-probe.lock +ARG VCS_REF +LABEL org.opencontainers.image.revision=$VCS_REF RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs diff --git a/docs/回收站删除索引修复-2026-10-09.md b/docs/回收站删除索引修复-2026-10-09.md index 1934a83..b6c63ab 100644 --- a/docs/回收站删除索引修复-2026-10-09.md +++ b/docs/回收站删除索引修复-2026-10-09.md @@ -12,10 +12,13 @@ ## 验证 -- 21 个相关 Jest 测试,包括入队与空队列检查交错时不会丢失唤醒。 +- 23 个相关 Jest 测试,包括入队与空队列检查交错时不会丢失唤醒和进行中状态的 500ms 刷新。 - 14 个真实 PostgreSQL/文件系统/MinIO 集成验证组;包括幂等、事务回滚、租约接管、真实删除、共享保护、直接 SQL 更新、队列状态变化、特殊地址和失败续跑。 -- 独立数据库 400,000 条额外资产:引用检查 59ms,提交 24ms,4 个文件从提交到实际删除及审计完成 122ms;资产查询计划实际使用 `material_assets_purge_references_idx`。 -- 已启动 worker 收到提交后 60ms 完成,不等待 5 秒轮询。 +- 独立数据库 400,000 条额外资产:引用检查 67ms,提交 31ms,4 个文件从提交到实际删除及审计完成 141ms;资产查询计划实际使用 `material_assets_purge_references_idx`。 +- 已启动 worker 收到提交后 92ms 完成,不等待 5 秒轮询。 +- 白底派生地址中的原始查询、片段和点段与原生 URL 解析一致;真实 MinIO 集成验证不会漏掉共享文件。 - 类型检查、存储边界检查和 14 个安全迁移测试通过。迁移审计核对九个索引的表达式、状态谓词、有效性及三个函数的完整定义。 生产验收只读核对原先 18 个任务的 1,276 个文件和现存引用样本,不由自动化提交真实素材删除。隔离测试耗时不等于生产完整物理清理耗时;生产查询耗时和发布凭据另存于发布回执。 + +镜像构建将提交 SHA 参数放在固定系统依赖层之后,避免每次提交使编译器安装缓存失效;镜像标签、提交文件和发布时的精确 SHA 校验保持有效。 diff --git a/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql b/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql index 1d7cfdc..70dc67a 100644 --- a/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql +++ b/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql @@ -89,6 +89,11 @@ BEGIN IF tail[1] = 'preview' THEN tail := tail[2:array_length(tail, 1)]; END IF; IF cardinality(tail) > 0 THEN derived := 'white-background/' || segments[wb - 1] || '/' || segments[wb + 1] || '/' || array_to_string(tail, '/'); + IF derived ~ E'[\\t\\r\\n]' OR derived ~* '/(\.|%2e)' THEN + result := array_append(result, 'url:resolve'); + END IF; + -- 派生地址会经过 URL 解析;原始 ?/# 是查询/片段,编码形式仍是文件名。 + derived := regexp_replace(derived, '[?#].*$', ''); FOR round IN 0..6 LOOP result := array_append(result, 'derived:' || md5(derived)); decoded := purge_decode_reference(derived); diff --git a/scripts/test-material-purge-integration.ts b/scripts/test-material-purge-integration.ts index d8d1e11..9170b90 100644 --- a/scripts/test-material-purge-integration.ts +++ b/scripts/test-material-purge-integration.ts @@ -223,7 +223,7 @@ async function main() { await processNextMaterialPurge() await client.send(new HeadObjectCommand({ Bucket, Key: encodedKey })) const localWhiteBackground = `uploads/skus/${objectKeeper.id}/white-background/${run}/preview/shared-display.png` - await prisma.materialAsset.create({ data: { materialId: objectKeeper.id, assetType: 'test', fileUrl: localWhiteBackground } }) + const whiteAsset = await prisma.materialAsset.create({ data: { materialId: objectKeeper.id, assetType: 'test', fileUrl: localWhiteBackground } }) const { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPathFromAssetPath } = await import('../lib/public-display') const displaySource = buildPublicDisplayUrl(buildWhiteBackgroundPublicDisplayPathFromAssetPath(localWhiteBackground)!)! const { getTrustedS3MutationKey } = await import('../lib/s3') @@ -233,6 +233,18 @@ async function main() { await enqueueMaterialPurge([display.id], admin.id) await processNextMaterialPurge() await client.send(new HeadObjectCommand({ Bucket, Key: displayKey })) + const displayTarget = [{ ...identifyPurgeFile(displaySource)!, outcome: 'pending' as const }] + for (const fileUrl of [localWhiteBackground + '#preview', localWhiteBackground + '?cache=1', + localWhiteBackground.replace('/preview/shared-display', '/preview/./shared-display'), + localWhiteBackground.replace('/preview/shared-display', '/preview/sub/../shared-display'), + localWhiteBackground.replace('/preview/shared-display', '/preview/%2e/shared-display')]) { + await prisma.materialAsset.update({ where: { id: whiteAsset.id }, data: { fileUrl } }) + const full = await findPurgeFileReferences(prisma) + const next = await findPurgeFileReferences(prisma, displayTarget) + assert(full.has(purgeFileIdentity(displayTarget[0])), '白底派生引用确实存在') + assert(next.has(purgeFileIdentity(displayTarget[0])), `不得漏掉派生 URL 引用 ${fileUrl}`) + } + await prisma.materialAsset.update({ where: { id: whiteAsset.id }, data: { fileUrl: localWhiteBackground } }) const referenceTarget = [{ ...identifyPurgeFile(source(sharedKey))!, outcome: 'pending' as const }] for (const previewUrl of [ source(sharedKey).replace('shared', '%73hared'), source(sharedKey).replace('shared', '%2573hared'),