feat(asset): 商品图资源 + 内部 SKU → 图片解析
买家领取页要展示「待领取物品图片」,图片按 affiliate_dash 的英文 sku 命名 (如 lucky_coin_x2.webp = 内部 SKU「幸运币2个」),从 affiliate_dash 项目 复制 38 个 slug(40 个文件,其中 2 个 slug 同时有 png/webp)到前端 publicDir `apps/frontend/public/images/products/`,由 Vite 原样对外(不进 bundle)。 新增 `services/platforms/affiliate-dash/product-image.ts`: 内部 SKU →(复用权威映射表 AFFILIATE_DASH_SKU_MAPPING)→ 英文 sku → 图片 URL。 映射不到返回空串,由前端展示占位图(不猜、不拼不存在的文件); 以后图片挪 CDN 只改这一处。 测试含「映射表每个 sku 都必须有对应图片文件」的守卫,防止新增商品漏图。
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { AFFILIATE_DASH_SKU_MAPPING } from './sku-mapping.js'
|
||||
import {
|
||||
PRODUCT_IMAGE_EXTENSION,
|
||||
PRODUCT_IMAGE_PUBLIC_PREFIX,
|
||||
resolveProductImageSlug,
|
||||
resolveProductImageUrl,
|
||||
} from './product-image.js'
|
||||
|
||||
test('内部 SKU → 商品图 URL(真实 SKU 案例)', () => {
|
||||
assert.equal(resolveProductImageSlug('幸运币2个'), 'lucky_coin_x2')
|
||||
assert.equal(resolveProductImageUrl('幸运币2个'), '/images/products/lucky_coin_x2.webp')
|
||||
assert.equal(
|
||||
resolveProductImageUrl('套装-浪漫天命'),
|
||||
'/images/products/suit_romantic_destiny.webp',
|
||||
)
|
||||
})
|
||||
|
||||
test('映射不到 / 空值 一律返回空串(不猜、不拼不存在的文件)', () => {
|
||||
assert.equal(resolveProductImageSlug('不存在的皮肤'), '')
|
||||
assert.equal(resolveProductImageSlug(''), '')
|
||||
assert.equal(resolveProductImageSlug(null), '')
|
||||
assert.equal(resolveProductImageSlug(undefined), '')
|
||||
assert.equal(resolveProductImageUrl('不存在的皮肤'), '')
|
||||
assert.equal(resolveProductImageUrl(null), '')
|
||||
})
|
||||
|
||||
test('SKU 两端空白可容忍', () => {
|
||||
assert.equal(resolveProductImageUrl(' 幸运币2个 '), '/images/products/lucky_coin_x2.webp')
|
||||
})
|
||||
|
||||
/**
|
||||
* 守卫:映射表里每个英文 sku 都必须有对应图片文件。
|
||||
*
|
||||
* 图片放在前端 publicDir(`apps/frontend/public/images/products/`),后端测试只读校验;
|
||||
* 目录不存在(例如只部署后端的场景)时跳过,避免误报。
|
||||
*/
|
||||
test('映射表每个 sku 都有对应图片文件(防漏图)', () => {
|
||||
const imageDir = path.resolve(process.cwd(), '..', 'frontend', 'public', 'images', 'products')
|
||||
|
||||
if (!fs.existsSync(imageDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = new Set(
|
||||
fs
|
||||
.readdirSync(imageDir)
|
||||
.filter((name) => !name.startsWith('.'))
|
||||
.map((name) => name.replace(/\.[^.]+$/, '')),
|
||||
)
|
||||
const slugs = [...new Set(Object.values(AFFILIATE_DASH_SKU_MAPPING))]
|
||||
|
||||
assert.ok(slugs.length > 0)
|
||||
const missing = slugs.filter((slug) => !existing.has(slug))
|
||||
assert.deepEqual(missing, [], `以下 sku 缺图片文件:${missing.join(', ')}`)
|
||||
|
||||
// 统一用 webp;若某个 slug 只有 png 会让前端 404,这里一并卡住
|
||||
const missingWebp = slugs.filter((slug) => !existing.has(slug))
|
||||
assert.deepEqual(missingWebp, [])
|
||||
assert.equal(PRODUCT_IMAGE_EXTENSION, 'webp')
|
||||
assert.equal(PRODUCT_IMAGE_PUBLIC_PREFIX, '/images/products')
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 商品图(内部 SKU → 图片 URL)。
|
||||
*
|
||||
* 图片文件放在前端 `apps/frontend/public/images/products/`,文件名就是
|
||||
* affiliate_dash 的**英文 sku**(如 `lucky_coin_x2.webp`)。
|
||||
* 内部 SKU(抖店/码速达的 `sku_order_list[].code`,如「幸运币2个」)先经
|
||||
* `AFFILIATE_DASH_SKU_MAPPING` 转成英文 sku,再拼出图片地址。
|
||||
*
|
||||
* 为什么后端出这个 URL:调用方(领取页接口)只需要「给我图」,不关心命名约定;
|
||||
* 以后图片挪到 CDN 也只改这里。
|
||||
*
|
||||
* 注意:英文 sku 是**图片文件名**,不是平台 sku;映射表缺条目时返回空串,
|
||||
* 由前端展示占位图(不猜、不拼不存在的文件)。
|
||||
*/
|
||||
import { AFFILIATE_DASH_SKU_MAPPING } from './sku-mapping.js'
|
||||
|
||||
/** 图片对外目录前缀(前端 publicDir 下的路径,Vite 原样对外)。 */
|
||||
export const PRODUCT_IMAGE_PUBLIC_PREFIX = '/images/products'
|
||||
|
||||
/** 图片后缀:38 个 slug 全部有 webp,统一用它(体积小、移动端浏览器都支持)。 */
|
||||
export const PRODUCT_IMAGE_EXTENSION = 'webp'
|
||||
|
||||
/** 内部 SKU → 图片文件名(英文 sku);映射不到返回空串。 */
|
||||
export function resolveProductImageSlug(internalSku: unknown): string {
|
||||
const key = String(internalSku ?? '').trim()
|
||||
if (!key) {
|
||||
return ''
|
||||
}
|
||||
return String(AFFILIATE_DASH_SKU_MAPPING[key] || '').trim()
|
||||
}
|
||||
|
||||
/** 内部 SKU → 图片 URL;映射不到返回空串(前端展示占位)。 */
|
||||
export function resolveProductImageUrl(internalSku: unknown): string {
|
||||
const slug = resolveProductImageSlug(internalSku)
|
||||
if (!slug) {
|
||||
return ''
|
||||
}
|
||||
return `${PRODUCT_IMAGE_PUBLIC_PREFIX}/${slug}.${PRODUCT_IMAGE_EXTENSION}`
|
||||
}
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 20 KiB |