From 0fd09976a5bdeef074a1b6f38b8a061b24ddba1a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 17 Sep 2026 11:52:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(files):=20=E5=8E=BB=E6=8E=89=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=93=BE=E6=8E=A5=E7=9A=84=E7=AD=BE=E5=90=8D=E4=B8=8E?= =?UTF-8?q?=E6=9C=89=E6=95=88=E6=9C=9F=EF=BC=8C=E4=BF=AE=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E7=AB=AF=E5=87=AD=E8=AF=81=E8=A3=82=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:寄卖凭证的图片地址是带签名的临时链接(15 分钟有效),而入库时会把当时的链接 原样存进 screenshots_json;工单验收那条线出参时会用 refreshStoredFileUrl() 重签, 寄卖这条线(提报凭证/赠送凭证/卖家列表)漏了这一步,于是入库 15 分钟后链接必然失效, 客服看到裂图。图片本身没丢,是取的时候没重签。 业务定调:签名这套意义不大,直接去掉。 - buildFileUrl() 只产出不带签名、不带有效期的普通链接(对象 key 带随机段,不可枚举), 删除签名生成/校验与 15 分钟 TTL 常量,文件读取路由不再校验签名 - refreshStoredFileUrl() 语义改为"归一化":把历史存的签名链接洗成普通链接,老数据不用改也能看 - 寄卖出参统一归一化:提报凭证、赠送凭证(imageUrls + files[])、库存截图、卖家列表截图 真实库验证:库里早在 2026-09-15 就过期的老链接现在返回 HTTP 200(image/webp,52632 字节), 改造前是 403;客服接口下发地址已无签名;不存在的 key 仍 404。 取舍:文件路由原本靠签名把关,去掉后知道链接即可看图(key 含 24 位随机段,实际不可枚举)。 如需收紧可给该路由加后台登录校验。 --- apps/backend/src/routes/files.ts | 5 +- .../file-storage/file-storage-service.test.ts | 46 ++++-------- .../file-storage/file-storage-service.ts | 71 ++++--------------- .../consignment-admin-service.ts | 46 ++++++++---- .../consignment-evidence.test.ts | 38 ++++++++++ .../consignment-worker-service.ts | 6 +- docs/寄卖系统优化方案.md | 34 +++++++++ 7 files changed, 138 insertions(+), 108 deletions(-) diff --git a/apps/backend/src/routes/files.ts b/apps/backend/src/routes/files.ts index 5fa7f89e..80dbbcab 100644 --- a/apps/backend/src/routes/files.ts +++ b/apps/backend/src/routes/files.ts @@ -12,10 +12,7 @@ router.get('/object', async (req, res) => { try { const rawKey = String(req.query.key || '').trim() const isPublic = isPublicObjectKey(rawKey) - const object = await getStoredFileObject(req.query.key, req.query.variant, { - expires: req.query.expires, - signature: req.query.signature, - }) + const object = await getStoredFileObject(req.query.key, req.query.variant) if (object.etag) { res.setHeader('ETag', object.etag) diff --git a/apps/backend/src/services/file-storage/file-storage-service.test.ts b/apps/backend/src/services/file-storage/file-storage-service.test.ts index f01fd8b2..8360b058 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.test.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.test.ts @@ -5,46 +5,24 @@ import { buildFileUrl, normalizeStoredFileUrl, refreshStoredFileUrl, - verifyFileAccessSignature, } from './file-storage-service.js' -const NOW_SECONDS = 1_800_000_000 const OBJECT_KEY = 'worker-acceptance/2026/07/26/example.jpg' -test('file access URL carries a valid short-lived signature', () => { - const url = buildFileUrl(OBJECT_KEY, NOW_SECONDS) - const parsed = new URL(url, 'http://localhost') - - assert.equal(parsed.searchParams.get('key'), OBJECT_KEY) - assert.doesNotThrow(() => - verifyFileAccessSignature( - OBJECT_KEY, - parsed.searchParams.get('expires'), - parsed.searchParams.get('signature'), - NOW_SECONDS + 899, - ), - ) +test('文件链接就是普通链接:不带签名、不带有效期(存多久都还能看)', () => { + const url = buildFileUrl(OBJECT_KEY) + assert.equal(url, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`) + assert.equal(url.includes('expires='), false) + assert.equal(url.includes('signature='), false) }) -test('file access URL rejects expired and tampered signatures', () => { - const parsed = new URL(buildFileUrl(OBJECT_KEY, NOW_SECONDS), 'http://localhost') - const expires = parsed.searchParams.get('expires') - const signature = parsed.searchParams.get('signature') +test('历史存下来的签名链接会被归一化成普通链接(老数据不用改也能看)', () => { + const legacySignedUrl = `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}&expires=1789463063&signature=OLD_SIGNATURE` + const normalized = refreshStoredFileUrl(legacySignedUrl) - assert.throws(() => verifyFileAccessSignature(OBJECT_KEY, expires, signature, NOW_SECONDS + 901)) - assert.throws(() => - verifyFileAccessSignature(`${OBJECT_KEY}.changed`, expires, signature, NOW_SECONDS), - ) -}) - -test('stored file URL drops signatures and refreshes them for responses', () => { - const signedUrl = buildFileUrl(OBJECT_KEY, NOW_SECONDS) - const storedUrl = normalizeStoredFileUrl(signedUrl) - const refreshedUrl = refreshStoredFileUrl(storedUrl) - - assert.equal(storedUrl, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`) - assert.match(refreshedUrl, /[?&]expires=\d+/) - assert.match(refreshedUrl, /[?&]signature=/) + assert.equal(normalized, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`) + // 归一化不重签、不丢 key,所以早就过期的老链接修完之后照样能打开 + assert.equal(normalizeStoredFileUrl(legacySignedUrl), normalized) assert.equal( refreshStoredFileUrl('https://example.com/image.jpg'), 'https://example.com/image.jpg', @@ -53,7 +31,7 @@ test('stored file URL drops signatures and refreshes them for responses', () => test('public file scenes return stable unsigned URLs for browser caching', () => { const publicObjectKey = 'category_icon/2026/09/13/game-icon.png' - const url = buildFileUrl(publicObjectKey, NOW_SECONDS) + const url = buildFileUrl(publicObjectKey) assert.equal(url, `/api/v1/files/object?key=${encodeURIComponent(publicObjectKey)}`) assert.equal(url.includes('expires='), false) assert.equal(url.includes('signature='), false) diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index fe58606d..d1f972f5 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -14,7 +14,6 @@ const IMAGE_VARIANT_THUMB = 'thumb' const IMAGE_VARIANT_MEDIUM = 'medium' const THUMB_MAX_SIDE = 480 const MEDIUM_MAX_SIDE = 1280 -const FILE_URL_TTL_SECONDS = 15 * 60 const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']) @@ -160,13 +159,10 @@ export async function uploadFileAsset(input: UploadFileAssetInput) { } } -export async function getStoredFileObject( - key: unknown, - variant: unknown = '', - access: { expires?: unknown; signature?: unknown } = {}, -) { +export async function getStoredFileObject(key: unknown, variant: unknown = '') { + // 不再校验签名:文件链接就是普通链接(见 buildFileUrl 的说明), + // 对象 key 里带随机段,本身就是不可枚举的。 const objectKey = normalizeObjectKey(key) - verifyFileAccessSignature(objectKey, access.expires, access.signature) const variantName = normalizeVariant(variant) const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey] let lastError: unknown = null @@ -190,26 +186,27 @@ export function getMaxUploadSizeBytes() { return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024 } -export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) { - const normalizedKey = normalizeObjectKey(objectKey) - if (isPublicObjectKey(normalizedKey)) { - return buildUnsignedFileUrl(normalizedKey) - } - const expires = nowSeconds + FILE_URL_TTL_SECONDS - const signature = createFileAccessSignature(normalizedKey, expires) - return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}` +/** + * 文件链接:不再带签名与有效期。 + * + * 以前这里是"15 分钟有效的签名链接",结果是**入库时把当时的链接原样存下来**, + * 之后任何不重签的地方过一会儿必然打不开(客服看到裂图),要每一处读取都记得重签才不会出错。 + * 现在链接就是稳定的普通链接(对象 key 带随机段,不可枚举),存下来过多久都能看。 + */ +export function buildFileUrl(objectKey: string) { + return buildUnsignedFileUrl(normalizeObjectKey(objectKey)) } +/** 把库里历史存的(带签名/带有效期的)链接归一化成普通链接;非文件链接原样返回。 */ export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') { const rawUrl = String(value || '').trim() const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim() return objectKey ? buildFileUrl(objectKey) : rawUrl } +/** @deprecated 用 refreshStoredFileUrl;两者现在完全等价(都只做归一化,不再重签)。 */ export function normalizeStoredFileUrl(value: unknown, fallbackObjectKey = '') { - const rawUrl = String(value || '').trim() - const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim() - return objectKey ? buildUnsignedFileUrl(objectKey) : rawUrl + return refreshStoredFileUrl(value, fallbackObjectKey) } export function refreshUploadedFileUrls(value: unknown) { @@ -236,37 +233,6 @@ export function isPublicObjectKey(objectKey: string) { return PUBLIC_SCENES.has(firstSegment) } -export function verifyFileAccessSignature( - objectKey: string, - expiresValue: unknown, - signatureValue: unknown, - nowSeconds = Math.floor(Date.now() / 1000), -) { - if (isPublicObjectKey(objectKey)) { - return - } - const expires = Number(expiresValue || 0) - const signature = String(signatureValue || '').trim() - if (!Number.isSafeInteger(expires) || expires < nowSeconds || !signature) { - throw createHttpError('文件访问链接无效或已过期', { - statusCode: 403, - errorCode: 'file_access_expired', - }) - } - const expected = createFileAccessSignature(normalizeObjectKey(objectKey), expires) - const expectedBuffer = Buffer.from(expected) - const actualBuffer = Buffer.from(signature) - if ( - expectedBuffer.length !== actualBuffer.length || - !crypto.timingSafeEqual(expectedBuffer, actualBuffer) - ) { - throw createHttpError('文件访问签名不正确', { - statusCode: 403, - errorCode: 'file_access_signature_invalid', - }) - } -} - export function imageVariantKey(objectKey: string, variant: string) { const ext = path.extname(objectKey) const base = ext ? objectKey.slice(0, -ext.length) : objectKey @@ -308,13 +274,6 @@ function buildUnsignedFileUrl(objectKey: string) { return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}` } -function createFileAccessSignature(objectKey: string, expires: number) { - const secret = - String(runtimeConfig.admin.sessionSecret || '').trim() || - String(runtimeConfig.storage.secretAccessKey || '').trim() - return crypto.createHmac('sha256', secret).update(`${objectKey}\n${expires}`).digest('base64url') -} - function extractObjectKeyFromFileUrl(value: string) { if (!value) return '' try { diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 7146962a..07861185 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -21,6 +21,7 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { generateConsignmentInventoryNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' +import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { ensureConsignmentPublishDepositFrozenBySubmissionWithClient, @@ -84,6 +85,16 @@ function toScreenshotList(value: unknown): string[] { return list.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) } +/** + * 上传返回的是**带 15 分钟有效期的签名链接**,而入库时会把当时的链接原样存下来, + * 所以任何"读的时候不重签"的地方过一会儿就必然打不开(客服只会看到一个裂图)。 + * 这里统一在出参处重签:从链接里取回 objectKey,重新生成一个有效期内的签名链接。 + * 工单验收那条线早就是这么做的(mapAcceptanceForResponse),寄卖这条线以前漏了。 + */ +function refreshScreenshotList(value: unknown): string[] { + return toScreenshotList(value).map((url) => refreshStoredFileUrl(url)) +} + /** * 一件寄卖的**凭证链**:提报凭证(卖家提报时上传的物品/主页截图)+ 赠送凭证 * (履约方冷却结束后上传的赠送完成截图,落在绑定工单的验收资料里,客服凭它验收)。 @@ -102,10 +113,15 @@ function mapConsignmentEvidence(source: { const files = Array.isArray(acceptance?.files) ? (acceptance.files as unknown[]) : [] const fileUrls = files.map((file) => { const record = toRecord(file) - return String(record?.url || record?.mediumUrl || record?.thumbnailUrl || '').trim() + return refreshStoredFileUrl( + String(record?.url || record?.mediumUrl || record?.thumbnailUrl || '').trim(), + ) }) const giftImages = [ - ...new Set([...toScreenshotList(acceptance?.imageUrls), ...fileUrls.filter(Boolean)]), + ...new Set([ + ...toScreenshotList(acceptance?.imageUrls).map((url) => refreshStoredFileUrl(url)), + ...fileUrls.filter(Boolean), + ]), ] const workOrderStatus = String(source.gift_work_order_status || '') const reviewStatus = @@ -119,7 +135,7 @@ function mapConsignmentEvidence(source: { return { /** 提报凭证(物品/主页截图) */ - submissionScreenshots: toScreenshotList( + submissionScreenshots: refreshScreenshotList( source.submission_screenshots_json ?? source.screenshots_json, ), /** 赠送凭证(赠送完成截图) */ @@ -158,11 +174,13 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { row.credentials_json && typeof row.credentials_json === 'object' ? (row.credentials_json as Record) : safeParseJson(row.credentials_json), - screenshotsJson: Array.isArray(row.screenshots_json) - ? row.screenshots_json - : Array.isArray(safeParseJson(row.screenshots_json)) - ? (safeParseJson(row.screenshots_json) as unknown[]) - : [], + screenshotsJson: refreshScreenshotList( + Array.isArray(row.screenshots_json) + ? row.screenshots_json + : Array.isArray(safeParseJson(row.screenshots_json)) + ? (safeParseJson(row.screenshots_json) as unknown[]) + : [], + ), remark: row.remark || '', allowMatching: Boolean(row.allow_matching), status: row.status, @@ -207,11 +225,13 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { row.content_json && typeof row.content_json === 'object' ? (row.content_json as Record) : safeParseJson(row.content_json), - screenshotsJson: Array.isArray(row.screenshots_json) - ? row.screenshots_json - : Array.isArray(safeParseJson(row.screenshots_json)) - ? (safeParseJson(row.screenshots_json) as unknown[]) - : [], + screenshotsJson: refreshScreenshotList( + Array.isArray(row.screenshots_json) + ? row.screenshots_json + : Array.isArray(safeParseJson(row.screenshots_json)) + ? (safeParseJson(row.screenshots_json) as unknown[]) + : [], + ), askingPriceFen: Number(row.asking_price_fen || 0), workerId: row.worker_id ? Number(row.worker_id) : null, workerName: row.worker_name || '', diff --git a/apps/backend/src/services/worker-platform/consignment-evidence.test.ts b/apps/backend/src/services/worker-platform/consignment-evidence.test.ts index b12c17d7..b04f47b6 100644 --- a/apps/backend/src/services/worker-platform/consignment-evidence.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-evidence.test.ts @@ -158,3 +158,41 @@ test('客服手动入库已停用:接口必须明确拒绝,不能再造出 }, ) }) + +/** 库里存的典型形态:入库当时生成的签名链接(15 分钟有效),早就过期了 */ +const EXPIRED_URL = + '/api/v1/files/object?key=consignment_screenshot%2F2026%2F09%2F15%2Fabc.webp&expires=1789463063&signature=OLD' + +test('下发时归一化文件链接:不再带签名/有效期,客服任何时候打开都能看', () => { + const mapped = mapConsignmentInventory( + inventoryRow({ + submission_screenshots_json: [EXPIRED_URL], + screenshots_json: [EXPIRED_URL], + gift_evidence_json: { imageUrls: [EXPIRED_URL], files: [{ url: EXPIRED_URL }] }, + gift_work_order_no: 'WO1', + gift_work_order_status: 'pending_acceptance', + }) as never, + ) + + for (const url of [ + ...mapped.submissionScreenshots, + ...mapped.giftEvidenceImages, + ...(mapped.screenshotsJson as string[]), + ]) { + assert.ok(url.includes('consignment_screenshot'), `objectKey 必须保留:${url}`) + assert.equal(url.includes('expires='), false, `不应再带有效期:${url}`) + assert.equal(url.includes('signature='), false, `不应再带签名:${url}`) + } +}) + +test('归一化不会误伤普通链接与空值', () => { + const mapped = mapConsignmentInventory( + inventoryRow({ + submission_screenshots_json: ['https://cdn.example.com/normal.png', '', ' '], + screenshots_json: [], + gift_evidence_json: null, + }) as never, + ) + assert.deepEqual(mapped.submissionScreenshots, ['https://cdn.example.com/normal.png']) + assert.deepEqual(mapped.giftEvidenceImages, []) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index e28fee50..acd28b23 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -15,6 +15,7 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { generateConsignmentSubmissionNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' +import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js' import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js' import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js' import { confirmAdminWorkOrderFriendAdded } from './admin-work-order-gift-service.js' @@ -267,7 +268,10 @@ export async function listWorkerConsignments(workerId: number) { askingPriceFen: sub.asking_price_fen, depositAmountFen: sub.deposit_amount_fen, reviewNote: sub.review_note, - screenshotsJson: sub.screenshots_json, + // 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图 + screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : []) + .filter((url): url is string => typeof url === 'string' && url.trim().length > 0) + .map((url) => refreshStoredFileUrl(url)), createdAt: sub.created_at, ...stage, /** 冷却截止时间,前端拿它显示倒计时 */ diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index 65c0ca7d..29dcb209 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -21,6 +21,7 @@ | `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 | | `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` | | `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` | +| 本批提交(批次 O,同一提交内含本文档,故不写自身 hash) | 批次 O:文件链接去掉签名与有效期(修客服端凭证裂图),寄卖出参统一归一化 | `file-storage-service.ts`、`routes/files.ts`、`consignment-admin-service.ts` | | 本批提交(批次 N,同一提交内含本文档,故不写自身 hash) | 批次 N:删掉按库存号的卖家入口,卖家动作只按寄卖单号定位 | `consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx` | | 本批提交(批次 M,同一提交内含本文档,故不写自身 hash) | 批次 M:停用客服手动入库(前端入口删除 + 后端 409 守卫),寄卖货源只走平台提报 | `consignment-admin-service.ts`、`consignment-repo.ts`、`ConsignmentInventoryPanel.tsx` | | 本批提交(批次 L,同一提交内含本文档,故不写自身 hash) | 批次 L:匹配弹窗两个动作一律二次确认 + 按钮分区/图标/后果说明,防点错 | `WorkOrdersPanel.tsx` | @@ -284,6 +285,39 @@ 前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。 3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。 +### 批次 O · 去掉文件链接的签名/有效期(本轮) + +**问题(用户截图:客服看到的凭证是裂图)**:寄卖凭证的图片地址是**带签名的临时链接**: + +``` +/api/v1/files/object?key=consignment_screenshot/2026/09/15/xxx.webp&expires=1789463063&signature=... +``` + +签名有效期只有 **15 分钟**,而**入库时会把当时的链接原样存进 `screenshots_json`**。 +工单验收那条线在出参时用 `refreshStoredFileUrl()` 重签,所以工单图片正常; +寄卖这条线(提报凭证 / 赠送凭证 / 卖家列表)漏了这一步 → 存下来的链接早过期 → 客服看到裂图。 +图片本身没丢,是"取的时候没重签"。 + +**业务定调**(用户):签名这套意义不大,直接去掉,用普通链接。 + +**改法**: + +1. ✅ `buildFileUrl()` 现在只产出**不带签名、不带有效期**的普通链接 + (对象 key 带随机段,本身不可枚举),删掉签名生成/校验与 15 分钟 TTL 常量, + 文件读取路由不再校验签名。 +2. ✅ `refreshStoredFileUrl()` 保留但语义变成"**归一化**":把历史存的签名链接洗成普通链接, + 老数据不用改也能看(`normalizeStoredFileUrl` 保留为同义别名)。 +3. ✅ 寄卖侧出参统一走归一化:提报凭证、赠送凭证(`imageUrls` + `files[]`)、库存截图、卖家列表截图; + 这样新老数据都稳定可看,不再依赖"每一处读取都记得重签"。 + +**真实库验证**:库里那条**早在 2026-09-15 就过期**的老链接,现在真实请求返回 +`HTTP 200 / image/webp / 52632 字节`(改造前是 403);客服接口下发的地址已是不含签名的普通链接; +不存在的 key 仍然 404。 + +**取舍说明**:文件路由本身没有登录校验,原来唯一把关的就是签名。去掉签名后, +**知道链接的人就能看图**(key 里有 24 位随机段,实际不可枚举,等价于一条长口令)。 +寄卖凭证可能包含游戏账号/主页信息,如需收紧,可以后续给该路由加"仅登录后台可读"的校验。 + ### 批次 N · 删掉"按库存号操作"的卖家入口(本轮) 批次 I 为了兜住"客服手工入库的现货没有提报单"这种情况,给卖家动作额外开了两个按库存号的入口