fix(files): 去掉文件链接的签名与有效期,修客服端凭证裂图
问题:寄卖凭证的图片地址是带签名的临时链接(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 位随机段,实际不可枚举)。 如需收紧可给该路由加后台登录校验。
This commit is contained in:
@@ -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<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
: 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 || '',
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
/** 冷却截止时间,前端拿它显示倒计时 */
|
||||
|
||||
Reference in New Issue
Block a user