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 都必须有对应图片文件」的守卫,防止新增商品漏图。
This commit is contained in:
yml2213
2026-10-09 14:47:50 +08:00
parent f72eeb7648
commit 0b619b2915
42 changed files with 105 additions and 0 deletions
@@ -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}`
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB