fix: preserve shared derived file references in purge lookup

This commit is contained in:
WuYongJie 2026-10-09 16:11:27 +08:00
parent c27d920527
commit a1ffe3b106
4 changed files with 29 additions and 10 deletions

View File

@ -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

View File

@ -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 校验保持有效。

View File

@ -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);

View File

@ -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'),