316 lines
8.8 KiB
TypeScript
316 lines
8.8 KiB
TypeScript
/**
|
||
* Medusa API 客户端
|
||
*/
|
||
|
||
const MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
|
||
const MEDUSA_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || ''
|
||
// Payload API key - 用于同步产品数据(包括 metadata)
|
||
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY || ''
|
||
|
||
interface MedusaProduct {
|
||
id: string
|
||
title: string
|
||
handle: string
|
||
description: string
|
||
thumbnail: string
|
||
status: string
|
||
created_at: string
|
||
updated_at: string
|
||
metadata?: Record<string, any> & {
|
||
is_preorder?: boolean | string
|
||
seed_id?: string
|
||
}
|
||
images?: Array<{
|
||
id: string
|
||
url: string
|
||
}>
|
||
variants?: Array<{
|
||
id: string
|
||
title: string
|
||
prices: Array<{
|
||
amount: number
|
||
currency_code: string
|
||
}>
|
||
}>
|
||
tags?: Array<{
|
||
id: string
|
||
value: string
|
||
}>
|
||
// 完整的关联对象
|
||
collection?: {
|
||
id: string
|
||
title: string
|
||
handle: string
|
||
}
|
||
type?: {
|
||
id: string
|
||
value: string
|
||
}
|
||
categories?: Array<{
|
||
id: string
|
||
name: string
|
||
handle: string
|
||
}>
|
||
// 物理属性
|
||
height?: number
|
||
width?: number
|
||
length?: number
|
||
weight?: number
|
||
// 海关与物流
|
||
mid_code?: string
|
||
hs_code?: string
|
||
origin_country?: string
|
||
// 兼容旧字段
|
||
collection_id?: string
|
||
type_id?: string
|
||
}
|
||
|
||
interface MedusaResponse<T> {
|
||
products?: T[]
|
||
product?: T
|
||
count?: number
|
||
offset?: number
|
||
limit?: number
|
||
}
|
||
|
||
/**
|
||
* 获取所有 Medusa 商品(使用 admin API 以获取 metadata)
|
||
*/
|
||
export async function getAllMedusaProducts(): Promise<MedusaProduct[]> {
|
||
try {
|
||
const response = await fetch(`${MEDUSA_BACKEND_URL}/store/products-sync`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
|
||
'x-payload-api-key': PAYLOAD_API_KEY,
|
||
},
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
console.error(`[getAllMedusaProducts] Error response:`, errorText)
|
||
throw new Error(`Failed to fetch products: ${response.status} ${response.statusText} - ${errorText}`)
|
||
}
|
||
|
||
const data: MedusaResponse<MedusaProduct> = await response.json()
|
||
return data.products || []
|
||
} catch (error) {
|
||
console.error('[getAllMedusaProducts] Error fetching Medusa products:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取单个 Medusa 商品
|
||
*/
|
||
export async function getMedusaProduct(productId: string): Promise<MedusaProduct | null> {
|
||
try {
|
||
const response = await fetch(`${MEDUSA_BACKEND_URL}/store/products/${productId}`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
|
||
},
|
||
})
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 404) {
|
||
return null
|
||
}
|
||
throw new Error(`Failed to fetch product: ${response.statusText}`)
|
||
}
|
||
|
||
const data: MedusaResponse<MedusaProduct> = await response.json()
|
||
return data.product || null
|
||
} catch (error) {
|
||
console.error(`Error fetching Medusa product ${productId}:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 分页获取 Medusa 商品
|
||
* 注意:由于使用自定义同步端点,实际会获取所有产品然后在内存中分页
|
||
*/
|
||
export async function getMedusaProductsPaginated(
|
||
offset: number = 0,
|
||
limit: number = 100,
|
||
): Promise<{ products: MedusaProduct[]; count: number }> {
|
||
try {
|
||
// 获取所有产品
|
||
const allProducts = await getAllMedusaProducts()
|
||
|
||
// 在内存中分页
|
||
const products = allProducts.slice(offset, offset + limit)
|
||
|
||
return {
|
||
products,
|
||
count: allProducts.length,
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching Medusa products (paginated):', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 转换 Medusa 商品为 Payload 格式
|
||
*/
|
||
/**
|
||
* 从 URL 下载并上传图片到 Payload Media 集合
|
||
*/
|
||
export async function uploadImageFromUrl(imageUrl: string, payload: any): Promise<string | null> {
|
||
if (!imageUrl) return null
|
||
|
||
try {
|
||
// 下载图片
|
||
const response = await fetch(imageUrl)
|
||
if (!response.ok) {
|
||
console.error(`Failed to download image: ${imageUrl}`)
|
||
return null
|
||
}
|
||
|
||
const blob = await response.blob()
|
||
const arrayBuffer = await blob.arrayBuffer()
|
||
const buffer = Buffer.from(arrayBuffer)
|
||
|
||
// 从 URL 提取文件名
|
||
const urlParts = imageUrl.split('/')
|
||
const filename = urlParts[urlParts.length - 1] || 'product-image.jpg'
|
||
|
||
// 上传到 Media 集合
|
||
const media = await payload.create({
|
||
collection: 'media',
|
||
data: {
|
||
alt: filename,
|
||
},
|
||
file: {
|
||
data: buffer,
|
||
mimetype: blob.type || 'image/jpeg',
|
||
name: filename,
|
||
size: buffer.length,
|
||
},
|
||
})
|
||
|
||
return media.id
|
||
} catch (error) {
|
||
console.error(`Error uploading image from URL ${imageUrl}:`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判断产品是否为预售商品
|
||
* 检查 metadata.is_preorder 和 metadata.preorder (支持布尔值和字符串)
|
||
*/
|
||
export function isPreorderProduct(product: MedusaProduct): boolean {
|
||
const isPreorderValue = product.metadata?.is_preorder
|
||
const preorderValue = product.metadata?.preorder
|
||
|
||
// 尝试两个字段,支持多种类型
|
||
const value = isPreorderValue !== undefined ? isPreorderValue : preorderValue
|
||
|
||
// 支持布尔值 true、字符串 "true"、数字 1、字符串 "1"
|
||
if (value === true || value === 'true' || value === '1' || value === 1) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 获取产品应该同步到的 collection
|
||
*/
|
||
export function getProductCollection(product: MedusaProduct): 'preorder-products' | 'products' {
|
||
return isPreorderProduct(product) ? 'preorder-products' : 'products'
|
||
}
|
||
|
||
/**
|
||
* 转换 Medusa 商品数据到 Payload 格式
|
||
* 直接保存 Medusa 的图片 URL,不上传到 Media 集合
|
||
*/
|
||
export function transformMedusaProductToPayload(product: MedusaProduct) {
|
||
// 优先使用 thumbnail,如果没有则使用第一张图片的 URL
|
||
let thumbnailUrl = product.thumbnail
|
||
|
||
if (!thumbnailUrl && product.images && product.images.length > 0) {
|
||
thumbnailUrl = product.images[0].url
|
||
}
|
||
|
||
// 提取 tags(逗号分隔)
|
||
const tags = product.tags?.map(tag => tag.value).join(', ') || null
|
||
|
||
// 提取 type(优先使用 type.value,否则使用 type_id 或 metadata)
|
||
const type = product.type?.value || product.type_id || product.metadata?.type || null
|
||
|
||
// 提取 collection(优先使用 collection.title,否则使用 collection_id 或 metadata)
|
||
const collection = product.collection?.title || product.collection_id || product.metadata?.collection || null
|
||
|
||
// 提取 category(从 categories 数组或 metadata)
|
||
const category = product.categories?.[0]?.name || product.metadata?.category || null
|
||
|
||
return {
|
||
medusaId: product.id,
|
||
seedId: product.metadata?.seed_id || null,
|
||
title: product.title,
|
||
handle: product.handle,
|
||
thumbnail: thumbnailUrl || null,
|
||
status: 'draft',
|
||
lastSyncedAt: new Date().toISOString(),
|
||
|
||
// Medusa 默认属性
|
||
tags: tags,
|
||
type: type,
|
||
collection: collection,
|
||
category: category,
|
||
|
||
// 物理属性(优先使用直接字段,否则从 metadata 中提取)
|
||
height: product.height || (product.metadata?.height ? Number(product.metadata.height) : null),
|
||
width: product.width || (product.metadata?.width ? Number(product.metadata.width) : null),
|
||
length: product.length || (product.metadata?.length ? Number(product.metadata.length) : null),
|
||
weight: product.weight || (product.metadata?.weight ? Number(product.metadata.weight) : null),
|
||
|
||
// 海关与物流(优先使用直接字段,否则从 metadata 中提取)
|
||
midCode: product.mid_code || product.metadata?.mid_code || product.metadata?.midCode || null,
|
||
hsCode: product.hs_code || product.metadata?.hs_code || product.metadata?.hsCode || null,
|
||
countryOfOrigin: product.origin_country || product.metadata?.country_of_origin || product.metadata?.countryOfOrigin || null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 转换 Medusa 商品数据到 Payload 格式(异步版本)
|
||
* 将图片上传到 Media 集合并返回 Media ID
|
||
*/
|
||
export async function transformMedusaProductToPayloadAsync(
|
||
product: MedusaProduct,
|
||
payload: any,
|
||
): Promise<{
|
||
medusaId: string
|
||
title: string
|
||
handle: string
|
||
thumbnail: string | null
|
||
status: string
|
||
lastSyncedAt: string
|
||
}> {
|
||
// 优先使用 thumbnail,如果没有则使用第一张图片的 URL
|
||
let thumbnailUrl = product.thumbnail
|
||
|
||
if (!thumbnailUrl && product.images && product.images.length > 0) {
|
||
thumbnailUrl = product.images[0].url
|
||
}
|
||
|
||
// 上传图片到 Media 集合
|
||
let thumbnailId: string | null = null
|
||
if (thumbnailUrl) {
|
||
thumbnailId = await uploadImageFromUrl(thumbnailUrl, payload)
|
||
}
|
||
|
||
return {
|
||
medusaId: product.id,
|
||
title: product.title,
|
||
handle: product.handle,
|
||
thumbnail: thumbnailId,
|
||
status: 'draft',
|
||
lastSyncedAt: new Date().toISOString(),
|
||
}
|
||
}
|