同步优化

This commit is contained in:
龟男日记\www 2026-02-18 03:17:19 +08:00
parent f5621a3aa1
commit 1f78d88d10
14 changed files with 1280 additions and 96 deletions

View File

@ -26,6 +26,7 @@ import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField' import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField'
import { SyncMedusaButton as SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08 } from '../../../components/sync/SyncMedusaButton' import { SyncMedusaButton as SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08 } from '../../../components/sync/SyncMedusaButton'
import { BatchSyncButton as BatchSyncButton_c62499057175f17acbe529b96de3aeb8 } from '../../../components/sync/BatchSyncButton'
import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler' import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler'
import { ForceSyncButton as ForceSyncButton_28396efe36d6238add95cf44109e281c } from '../../../components/sync/ForceSyncButton' import { ForceSyncButton as ForceSyncButton_28396efe36d6238add95cf44109e281c } from '../../../components/sync/ForceSyncButton'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
@ -63,6 +64,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426, "/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426,
"/components/sync/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08, "/components/sync/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08,
"/components/sync/BatchSyncButton#BatchSyncButton": BatchSyncButton_c62499057175f17acbe529b96de3aeb8,
"/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2, "/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2,
"/components/sync/ForceSyncButton#ForceSyncButton": ForceSyncButton_28396efe36d6238add95cf44109e281c, "/components/sync/ForceSyncButton#ForceSyncButton": ForceSyncButton_28396efe36d6238add95cf44109e281c,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,

View File

@ -0,0 +1,119 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextResponse } from 'next/server'
/**
* Medusa
* POST /api/batch-sync-medusa
* Body: { ids: string[], collection: 'products' | 'preorder-products', forceUpdate: boolean }
*/
export async function POST(request: Request) {
try {
const body = await request.json()
const { ids, collection, forceUpdate = false } = body
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return NextResponse.json(
{
success: false,
error: 'No product IDs provided',
},
{ status: 400 },
)
}
if (!collection || !['products', 'preorder-products'].includes(collection)) {
return NextResponse.json(
{
success: false,
error: 'Invalid collection',
},
{ status: 400 },
)
}
const payload = await getPayload({ config })
const results = {
total: ids.length,
success: 0,
failed: 0,
details: [] as any[],
}
// 逐个同步选中的产品
for (const id of ids) {
try {
// 获取产品信息
const product = await payload.findByID({
collection: collection as 'products' | 'preorder-products',
id,
})
if (!product || !product.medusaId) {
results.failed++
results.details.push({
id,
title: product?.title || 'Unknown',
status: 'failed',
error: 'No Medusa ID',
})
continue
}
// 调用单个产品同步 API
const syncResponse = await fetch(
`${request.url.replace('/api/batch-sync-medusa', '/api/sync-medusa')}?medusaId=${product.medusaId}&forceUpdate=${forceUpdate}`,
{
method: 'GET',
headers: request.headers,
},
)
const syncResult = await syncResponse.json()
if (syncResult.success) {
results.success++
results.details.push({
id,
medusaId: product.medusaId,
title: product.title,
status: 'success',
action: syncResult.action,
})
} else {
results.failed++
results.details.push({
id,
medusaId: product.medusaId,
title: product.title,
status: 'failed',
error: syncResult.error || syncResult.message,
})
}
} catch (error) {
results.failed++
results.details.push({
id,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return NextResponse.json({
success: true,
message: `批量同步完成: ${results.success} 成功, ${results.failed} 失败`,
results,
})
} catch (error) {
console.error('Batch sync error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
)
}
}

View File

@ -0,0 +1,42 @@
import { getAllMedusaProducts } from '@/lib/medusa'
import { NextResponse } from 'next/server'
/**
* API - Medusa metadata
* GET /api/diagnose-products
*/
export async function GET() {
try {
const products = await getAllMedusaProducts()
const diagnosis = products.map((product) => ({
id: product.id,
title: product.title,
handle: product.handle,
metadata: product.metadata,
is_preorder_value: product.metadata?.is_preorder,
is_preorder_type: typeof product.metadata?.is_preorder,
should_be_in_collection:
product.metadata?.is_preorder === true || product.metadata?.is_preorder === 'true'
? 'preorder-products'
: 'products',
}))
return NextResponse.json({
success: true,
total: products.length,
preorder_count: diagnosis.filter((d) => d.should_be_in_collection === 'preorder-products')
.length,
regular_count: diagnosis.filter((d) => d.should_be_in_collection === 'products').length,
products: diagnosis,
})
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
)
}
}

View File

@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* Medusa
* GET /api/preorders/:id/orders
*/
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const payload = await getPayload({ config })
const { id } = params
// 获取预购产品
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 2,
})
} catch (err) {
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
product = result.docs[0]
}
}
if (!product) {
return NextResponse.json(
{ error: 'Preorder product not found' },
{ status: 404 }
)
}
if (!product.medusaId) {
return NextResponse.json(
{ error: 'Product has no Medusa ID, cannot fetch orders' },
{ status: 400 }
)
}
// 从 Medusa 获取订单数据
const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const medusaResponse = await fetch(`${medusaUrl}/admin/orders`, {
headers: {
'Content-Type': 'application/json',
},
})
if (!medusaResponse.ok) {
throw new Error('Failed to fetch orders from Medusa')
}
const { orders } = await medusaResponse.json()
// 筛选包含此产品的订单
const productOrders = (orders || [])
.filter((order: any) => {
return order?.items?.some((item: any) => item.product_id === product.medusaId)
})
.map((order: any) => {
// 提取此产品的订单项
const items = order.items.filter((item: any) => item.product_id === product.medusaId)
return {
id: order.id,
display_id: order.display_id,
status: order.status,
payment_status: order.payment_status,
fulfillment_status: order.fulfillment_status,
customer_id: order.customer_id,
email: order.email,
total: order.total,
currency_code: order.currency_code,
created_at: order.created_at,
updated_at: order.updated_at,
items: items.map((item: any) => ({
id: item.id,
variant_id: item.variant_id,
title: item.title,
quantity: item.quantity,
unit_price: item.unit_price,
total: item.total,
})),
}
})
// 按创建时间倒序排序
productOrders.sort((a: any, b: any) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
return NextResponse.json({
orders: productOrders,
count: productOrders.length,
product: {
id: product.id,
title: product.title,
medusa_id: product.medusaId,
},
})
} catch (error: any) {
console.error('[Payload Preorder Orders API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to fetch preorder orders', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* Medusa
* POST /api/preorders/:id/recalculate
*/
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const payload = await getPayload({ config })
const { id } = params
// 获取预购产品
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 2,
})
} catch (err) {
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
product = result.docs[0]
}
}
if (!product) {
return NextResponse.json(
{ error: 'Preorder product not found' },
{ status: 404 }
)
}
if (!product.medusaId) {
return NextResponse.json(
{ error: 'Product has no Medusa ID, cannot recalculate' },
{ status: 400 }
)
}
// 从 Medusa 获取订单数据
const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const medusaResponse = await fetch(`${medusaUrl}/admin/orders`, {
headers: {
'Content-Type': 'application/json',
},
})
if (!medusaResponse.ok) {
throw new Error('Failed to fetch orders from Medusa')
}
const { orders } = await medusaResponse.json()
// 统计每个变体的订单数量
const variantCounts: Record<string, number> = {}
let totalCount = 0
for (const order of orders || []) {
if (!order?.items) continue
for (const item of order.items) {
if (!item || item.product_id !== product.medusaId) continue
const variantId = item.variant_id
if (variantId) {
variantCounts[variantId] = (variantCounts[variantId] || 0) + (item.quantity || 0)
totalCount += item.quantity || 0
}
}
}
// 从 Medusa 获取产品变体列表
const productResponse = await fetch(`${medusaUrl}/admin/products/${product.medusaId}`, {
headers: {
'Content-Type': 'application/json',
},
})
if (!productResponse.ok) {
throw new Error('Failed to fetch product from Medusa')
}
const { product: medusaProduct } = await productResponse.json()
const variants = medusaProduct.variants || []
// 更新每个变体的 metadata在 Medusa 中)
const updatePromises = variants.map(async (variant: any) => {
const count = variantCounts[variant.id] || 0
await fetch(`${medusaUrl}/admin/product-variants/${variant.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
metadata: {
...(variant.metadata || {}),
current_orders: String(count),
},
}),
})
return {
variant_id: variant.id,
count,
}
})
const updatedVariants = await Promise.all(updatePromises)
return NextResponse.json({
success: true,
product_id: product.id,
total_orders: totalCount,
variants: updatedVariants,
message: `Recalculated orders for ${variants.length} variant(s)`,
})
} catch (error: any) {
console.error('[Payload Preorder Recalculate API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to recalculate orders', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,307 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
*
* GET /api/preorders/:id
*/
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const payload = await getPayload({ config })
const { id } = params
// 尝试通过 Payload ID 查找
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 2,
})
} catch (err) {
// 如果不是 Payload ID尝试通过 medusaId 或 seedId 查找
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
product = result.docs[0]
}
}
if (!product) {
return NextResponse.json(
{ error: 'Preorder product not found' },
{ status: 404 }
)
}
// 格式化变体数据
const variants = (product.variants || []).map((variant: any) => {
const currentOrders = parseInt(variant.currentOrders || '0', 10) || 0
const maxOrders = parseInt(variant.maxOrders || '0', 10) || 0
const availableSlots = maxOrders > 0 ? maxOrders - currentOrders : 0
const soldOut = maxOrders > 0 && currentOrders >= maxOrders
const utilization = maxOrders > 0 ? Math.round((currentOrders / maxOrders) * 100) : 0
return {
id: variant.id,
title: variant.title,
sku: variant.sku,
current_orders: currentOrders,
max_orders: maxOrders,
available_slots: availableSlots,
sold_out: soldOut,
utilization_percentage: utilization,
prices: variant.prices || [],
options: variant.options || {},
metadata: variant.metadata || {},
}
})
// 计算统计数据
const totalOrders = variants.reduce((sum: number, v: any) => sum + v.current_orders, 0)
const totalMaxOrders = variants.reduce((sum: number, v: any) => sum + v.max_orders, 0)
const totalAvailable = variants.reduce((sum: number, v: any) => sum + v.available_slots, 0)
const fundingGoal = parseInt(product.fundingGoal || '0', 10) || totalMaxOrders
const completionPercentage = fundingGoal > 0
? Math.round((totalOrders / fundingGoal) * 100)
: 0
const allSoldOut = variants.every((v: any) => v.sold_out)
const someSoldOut = variants.some((v: any) => v.sold_out)
return NextResponse.json({
preorder: {
id: product.id,
title: product.title,
handle: product.handle,
description: product.description,
status: product._status,
thumbnail: product.thumbnail,
images: product.images || [],
// IDs
seed_id: product.seedId || product.medusaId,
medusa_id: product.medusaId,
// 预购元数据
is_preorder: true,
preorder_type: product.preorderType || 'standard',
estimated_ship_date: product.estimatedShipDate || null,
funding_goal: fundingGoal,
// 统计数据
current_orders: totalOrders,
total_max_orders: totalMaxOrders,
total_available_slots: totalAvailable,
completion_percentage: completionPercentage,
// 可用性状态
all_variants_sold_out: allSoldOut,
some_variants_sold_out: someSoldOut,
is_available: !allSoldOut && totalAvailable > 0,
// 详细信息
variants,
variants_count: variants.length,
categories: product.categories || [],
collection: product.collection || null,
metadata: product.metadata || {},
// 时间戳
created_at: product.createdAt,
updated_at: product.updatedAt,
},
})
} catch (error: any) {
console.error('[Payload Preorder Detail API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to fetch preorder product', message: error?.message },
{ status: 500 }
)
}
}
/**
*
* PATCH /api/preorders/:id
*
* Body:
* - variant_id?: string -
* - current_orders?: number -
* - max_orders?: number -
* - increment?: number -
* - decrement?: number -
*
* - estimated_ship_date?: string -
* - funding_goal?: number -
* - preorder_type?: string -
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const payload = await getPayload({ config })
const { id } = params
const body = await req.json()
const {
variant_id,
current_orders,
max_orders,
increment,
decrement,
estimated_ship_date,
funding_goal,
preorder_type,
} = body
// 获取产品
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 2,
})
} catch (err) {
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
product = result.docs[0]
}
}
if (!product) {
return NextResponse.json(
{ error: 'Preorder product not found' },
{ status: 404 }
)
}
// 模式1: 更新变体预购计数
if (variant_id) {
// 预购变体数据存储在 Medusa 中,直接更新 Medusa
const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
// 获取当前变体数据
const variantResponse = await fetch(`${medusaUrl}/admin/product-variants/${variant_id}`, {
headers: {
'Content-Type': 'application/json',
},
})
if (!variantResponse.ok) {
return NextResponse.json(
{ error: 'Variant not found in Medusa' },
{ status: 404 }
)
}
const { variant } = await variantResponse.json()
const currentMeta = variant.metadata || {}
let newCurrentOrders = parseInt(currentMeta.current_orders || '0', 10) || 0
let newMaxOrders = parseInt(currentMeta.max_orders || '0', 10) || 0
// 处理更新逻辑
if (typeof current_orders === 'number') {
newCurrentOrders = Math.max(0, current_orders)
} else if (typeof increment === 'number') {
newCurrentOrders = Math.max(0, newCurrentOrders + increment)
} else if (typeof decrement === 'number') {
newCurrentOrders = Math.max(0, newCurrentOrders - decrement)
}
if (typeof max_orders === 'number') {
newMaxOrders = Math.max(0, max_orders)
}
// 更新 Medusa 变体 metadata
const updateResponse = await fetch(`${medusaUrl}/admin/product-variants/${variant_id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
metadata: {
...currentMeta,
current_orders: String(newCurrentOrders),
max_orders: String(newMaxOrders),
},
}),
})
if (!updateResponse.ok) {
throw new Error('Failed to update variant in Medusa')
}
return NextResponse.json({
success: true,
variant_id,
current_orders: newCurrentOrders,
max_orders: newMaxOrders,
available_slots: newMaxOrders - newCurrentOrders,
sold_out: newMaxOrders > 0 && newCurrentOrders >= newMaxOrders,
})
}
// 模式2: 更新产品级别元数据
const updateData: any = {}
if (estimated_ship_date !== undefined) {
updateData.estimatedShipDate = estimated_ship_date
}
if (funding_goal !== undefined) {
updateData.fundingGoal = String(funding_goal)
}
if (preorder_type !== undefined) {
updateData.preorderType = preorder_type
}
if (Object.keys(updateData).length > 0) {
await payload.update({
collection: 'preorder-products',
id: product.id,
data: updateData,
})
}
return NextResponse.json({
success: true,
message: 'Preorder product updated successfully',
})
} catch (error: any) {
console.error('[Payload Preorder Update API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to update preorder product', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
*
* GET /api/preorders
*
* Query params:
* - seed_id: seed_id
* - status: 按状态筛选 (draft|published)
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
const { searchParams } = new URL(req.url)
const seed_id = searchParams.get('seed_id')
const status = searchParams.get('status')
// 构建查询条件
const where: any = {}
if (seed_id) {
where.seedId = { equals: seed_id }
}
if (status) {
where._status = { equals: status }
}
// 查询预购产品集合
const result = await payload.find({
collection: 'preorder-products',
where,
depth: 2,
limit: 100,
sort: '-createdAt',
})
// 格式化数据 - 以预购为主,展示完整变体信息
const formattedProducts = result.docs.map((product: any) => {
// 计算变体预购统计
const variants = (product.variants || []).map((variant: any) => {
const currentOrders = parseInt(variant.currentOrders || '0', 10) || 0
const maxOrders = parseInt(variant.maxOrders || '0', 10) || 0
const availableSlots = maxOrders > 0 ? maxOrders - currentOrders : 0
const soldOut = maxOrders > 0 && currentOrders >= maxOrders
const utilization = maxOrders > 0 ? Math.round((currentOrders / maxOrders) * 100) : 0
return {
id: variant.id,
title: variant.title,
sku: variant.sku,
current_orders: currentOrders,
max_orders: maxOrders,
available_slots: availableSlots,
sold_out: soldOut,
utilization_percentage: utilization,
prices: variant.prices || [],
}
})
// 产品级别统计
const totalOrders = variants.reduce((sum: number, v: any) => sum + v.current_orders, 0)
const totalMaxOrders = variants.reduce((sum: number, v: any) => sum + v.max_orders, 0)
const totalAvailable = variants.reduce((sum: number, v: any) => sum + v.available_slots, 0)
const fundingGoal = parseInt(product.fundingGoal || '0', 10) || totalMaxOrders
const completionPercentage = fundingGoal > 0
? Math.round((totalOrders / fundingGoal) * 100)
: 0
const allSoldOut = variants.every((v: any) => v.sold_out)
const someSoldOut = variants.some((v: any) => v.sold_out)
return {
id: product.id,
title: product.title,
handle: product.handle,
status: product._status,
thumbnail: product.thumbnail,
description: product.description,
// Seed ID
seed_id: product.seedId || product.medusaId,
medusa_id: product.medusaId,
// 预购元数据
preorder_type: product.preorderType || 'standard',
estimated_ship_date: product.estimatedShipDate || null,
funding_goal: fundingGoal,
// 统计数据
current_orders: totalOrders,
total_max_orders: totalMaxOrders,
total_available_slots: totalAvailable,
completion_percentage: completionPercentage,
// 可用性状态
all_variants_sold_out: allSoldOut,
some_variants_sold_out: someSoldOut,
is_available: !allSoldOut && totalAvailable > 0,
// 变体详情
variants,
variants_count: variants.length,
// 时间戳
created_at: product.createdAt,
updated_at: product.updatedAt,
}
})
// 排序:有库存优先 → 完成度高优先
formattedProducts.sort((a: any, b: any) => {
if (a.is_available !== b.is_available) {
return a.is_available ? -1 : 1
}
if (a.completion_percentage !== b.completion_percentage) {
return b.completion_percentage - a.completion_percentage
}
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
return NextResponse.json({
preorders: formattedProducts,
count: formattedProducts.length,
summary: {
total: formattedProducts.length,
available: formattedProducts.filter((p: any) => p.is_available).length,
sold_out: formattedProducts.filter((p: any) => p.all_variants_sold_out).length,
total_orders: formattedProducts.reduce((sum: number, p: any) => sum + p.current_orders, 0),
total_slots: formattedProducts.reduce((sum: number, p: any) => sum + p.total_max_orders, 0),
},
})
} catch (error: any) {
console.error('[Payload Preorders API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to fetch preorder products', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -8,6 +8,95 @@ import {
getProductCollection, getProductCollection,
} from '@/lib/medusa' } from '@/lib/medusa'
/**
* seedId medusaId 使 seedId
* collection
* @returns { product, collection } null
*/
async function findProductBySeedIdOrMedusaId(
payload: any,
seedId: string | null,
medusaId: string,
): Promise<{ product: any; collection: 'products' | 'preorder-products' } | null> {
const collections: Array<'products' | 'preorder-products'> = ['products', 'preorder-products']
// 优先通过 seedId 查找
if (seedId) {
for (const collection of collections) {
const result = await payload.find({
collection,
where: {
seedId: { equals: seedId },
},
limit: 1,
})
if (result.docs[0]) {
return { product: result.docs[0], collection }
}
}
}
// 如果通过 seedId 没找到,使用 medusaId 查找
for (const collection of collections) {
const result = await payload.find({
collection,
where: {
medusaId: { equals: medusaId },
},
limit: 1,
})
if (result.docs[0]) {
return { product: result.docs[0], collection }
}
}
return null
}
/**
* - Payload
* @param existingProduct Payload
* @param newData Medusa
* @param forceUpdate
* @returns
*/
function mergeProductData(existingProduct: any, newData: any, forceUpdate: boolean): any {
if (forceUpdate) {
// 强制更新模式:使用所有新数据
return { ...newData }
}
// 只填充空值模式:只更新空字段
const mergedData: any = {}
// 总是更新这些字段
mergedData.lastSyncedAt = newData.lastSyncedAt
mergedData.medusaId = newData.medusaId
// 如果 seedId 为空,更新它
if (!existingProduct.seedId && newData.seedId) {
mergedData.seedId = newData.seedId
}
// 只在字段为空时更新
if (!existingProduct.title) {
mergedData.title = newData.title
}
if (!existingProduct.handle) {
mergedData.handle = newData.handle
}
if (!existingProduct.thumbnail) {
mergedData.thumbnail = newData.thumbnail
}
if (!existingProduct.status) {
mergedData.status = newData.status
}
return mergedData
}
/** /**
* Medusa Payload CMS * Medusa Payload CMS
* GET /api/sync-medusa * GET /api/sync-medusa
@ -16,6 +105,24 @@ import {
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
// 可选的 API Key 验证
const authHeader = request.headers.get('authorization')
const storeApiKey = process.env.STORE_API_KEY
// 如果配置了 STORE_API_KEY则验证请求
if (storeApiKey && authHeader) {
const token = authHeader.replace('Bearer ', '')
if (token !== storeApiKey) {
return NextResponse.json(
{
success: false,
error: 'Invalid API key',
},
{ status: 401 },
)
}
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const medusaId = searchParams.get('medusaId') const medusaId = searchParams.get('medusaId')
const payloadId = searchParams.get('payloadId') const payloadId = searchParams.get('payloadId')
@ -78,31 +185,18 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force
const otherCollection = const otherCollection =
targetCollection === 'preorder-products' ? 'products' : 'preorder-products' targetCollection === 'preorder-products' ? 'products' : 'preorder-products'
// 在目标 collection 中检查是否已存在 // 转换数据
const existingInTarget = await payload.find({
collection: targetCollection,
where: {
medusaId: { equals: medusaId },
},
limit: 1,
})
// 在另一个 collection 中检查是否存在(产品类型可能改变)
const existingInOther = await payload.find({
collection: otherCollection,
where: {
medusaId: { equals: medusaId },
},
limit: 1,
})
const productData = transformMedusaProductToPayload(medusaProduct) const productData = transformMedusaProductToPayload(medusaProduct)
const seedId = productData.seedId
// 如果在另一个 collection 中存在,需要删除并在正确的 collection 中创建 // 使用新的查找函数(优先 seedId
if (existingInOther.docs[0]) { const found = await findProductBySeedIdOrMedusaId(payload, seedId, medusaId)
// 如果在另一个 collection 中找到,需要移动
if (found && found.collection !== targetCollection) {
await payload.delete({ await payload.delete({
collection: otherCollection, collection: found.collection,
id: existingInOther.docs[0].id, id: found.product.id,
}) })
const created = await payload.create({ const created = await payload.create({
@ -113,27 +207,50 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force
return { return {
success: true, success: true,
action: 'moved', action: 'moved',
message: `商品 ${medusaId} 已从 ${otherCollection} 移动到 ${targetCollection}`, message: `商品 ${medusaId} 已从 ${found.collection} 移动到 ${targetCollection}`,
productId: created.id, productId: created.id,
collection: targetCollection, collection: targetCollection,
} }
} }
const existingProduct = existingInTarget.docs[0] // 如果在目标 collection 中找到
if (found) {
const existingProduct = found.product
// 如果存在且不强制更新,跳过 // 如果存在且不强制更新,只更新空字段
if (existingProduct && !forceUpdate) { if (!forceUpdate) {
// 合并数据(只更新空字段)
const mergedData = mergeProductData(existingProduct, productData, false)
// 如果没有需要更新的字段,跳过
if (Object.keys(mergedData).length <= 2) {
// 只有 lastSyncedAt 和 medusaId
return { return {
success: true, success: true,
action: 'skipped', action: 'skipped',
message: `商品 ${medusaId} 已存在于 ${targetCollection}`, message: `商品 ${medusaId} 已存在于 ${targetCollection},且所有字段都有值`,
productId: existingProduct.id, productId: existingProduct.id,
collection: targetCollection, collection: targetCollection,
} }
} }
if (existingProduct) { // 更新(只更新空字段)
// 更新现有商品 const updated = await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: mergedData,
})
return {
success: true,
action: 'updated_partial',
message: `商品 ${medusaId} 已部分更新(仅空字段)于 ${targetCollection}`,
productId: updated.id,
collection: targetCollection,
}
}
// 强制更新所有字段
const updated = await payload.update({ const updated = await payload.update({
collection: targetCollection, collection: targetCollection,
id: existingProduct.id, id: existingProduct.id,
@ -147,8 +264,9 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force
productId: updated.id, productId: updated.id,
collection: targetCollection, collection: targetCollection,
} }
} else { }
// 创建新商品
// 不存在,创建新商品
const created = await payload.create({ const created = await payload.create({
collection: targetCollection, collection: targetCollection,
data: productData, data: productData,
@ -161,7 +279,6 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force
productId: created.id, productId: created.id,
collection: targetCollection, collection: targetCollection,
} }
}
} catch (error) { } catch (error) {
console.error(`Error syncing product ${medusaId}:`, error) console.error(`Error syncing product ${medusaId}:`, error)
return { return {
@ -252,6 +369,8 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) {
total: 0, total: 0,
created: 0, created: 0,
updated: 0, updated: 0,
updated_partial: 0,
moved: 0,
skipped: 0, skipped: 0,
errors: 0, errors: 0,
details: [] as any[], details: [] as any[],
@ -273,34 +392,23 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) {
try { try {
// 确定应该同步到哪个 collection // 确定应该同步到哪个 collection
const targetCollection = getProductCollection(medusaProduct) const targetCollection = getProductCollection(medusaProduct)
const otherCollection =
targetCollection === 'preorder-products' ? 'products' : 'preorder-products'
// 在目标 collection 中检查是否已存在
const existingInTarget = await payload.find({
collection: targetCollection,
where: {
medusaId: { equals: medusaProduct.id },
},
limit: 1,
})
// 在另一个 collection 中检查是否存在(产品类型可能改变)
const existingInOther = await payload.find({
collection: otherCollection,
where: {
medusaId: { equals: medusaProduct.id },
},
limit: 1,
})
// 转换数据
const productData = transformMedusaProductToPayload(medusaProduct) const productData = transformMedusaProductToPayload(medusaProduct)
const seedId = productData.seedId
// 使用新的查找函数(优先 seedId
const found = await findProductBySeedIdOrMedusaId(
payload,
seedId,
medusaProduct.id,
)
// 如果在错误的 collection 中,移动它 // 如果在错误的 collection 中,移动它
if (existingInOther.docs[0]) { if (found && found.collection !== targetCollection) {
await payload.delete({ await payload.delete({
collection: otherCollection, collection: found.collection,
id: existingInOther.docs[0].id, id: found.product.id,
}) })
await payload.create({ await payload.create({
@ -308,24 +416,33 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) {
data: productData, data: productData,
}) })
results.updated++ results.moved++
results.details.push({ results.details.push({
medusaId: medusaProduct.id, medusaId: medusaProduct.id,
seedId: seedId,
title: medusaProduct.title, title: medusaProduct.title,
action: 'moved', action: 'moved',
from: otherCollection, from: found.collection,
to: targetCollection, to: targetCollection,
}) })
continue continue
} }
const existingProduct = existingInTarget.docs[0] // 如果在目标 collection 中找到
if (found) {
const existingProduct = found.product
// 如果存在且不强制更新,跳过 // 如果不强制更新,只更新空字段
if (existingProduct && !forceUpdate) { if (!forceUpdate) {
const mergedData = mergeProductData(existingProduct, productData, false)
// 如果没有需要更新的字段,跳过
if (Object.keys(mergedData).length <= 2) {
// 只有 lastSyncedAt 和 medusaId
results.skipped++ results.skipped++
results.details.push({ results.details.push({
medusaId: medusaProduct.id, medusaId: medusaProduct.id,
seedId: seedId,
title: medusaProduct.title, title: medusaProduct.title,
action: 'skipped', action: 'skipped',
collection: targetCollection, collection: targetCollection,
@ -333,8 +450,25 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) {
continue continue
} }
if (existingProduct) { // 更新(只更新空字段)
// 更新 await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: mergedData,
})
results.updated_partial++
results.details.push({
medusaId: medusaProduct.id,
seedId: seedId,
title: medusaProduct.title,
action: 'updated_partial',
collection: targetCollection,
})
continue
}
// 强制更新
await payload.update({ await payload.update({
collection: targetCollection, collection: targetCollection,
id: existingProduct.id, id: existingProduct.id,
@ -343,12 +477,13 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) {
results.updated++ results.updated++
results.details.push({ results.details.push({
medusaId: medusaProduct.id, medusaId: medusaProduct.id,
seedId: seedId,
title: medusaProduct.title, title: medusaProduct.title,
action: 'updated', action: 'updated',
collection: targetCollection, collection: targetCollection,
}) })
} else { } else {
// 创建 // 创建新商品
await payload.create({ await payload.create({
collection: targetCollection, collection: targetCollection,
data: productData, data: productData,
@ -356,6 +491,7 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) {
results.created++ results.created++
results.details.push({ results.details.push({
medusaId: medusaProduct.id, medusaId: medusaProduct.id,
seedId: seedId,
title: medusaProduct.title, title: medusaProduct.title,
action: 'created', action: 'created',
collection: targetCollection, collection: targetCollection,
@ -382,7 +518,7 @@ async function syncAllProducts(payload: any, forceUpdate: boolean) {
return { return {
success: true, success: true,
message: `同步完成: ${results.created} 个创建, ${results.updated} 个更新, ${results.skipped} 个跳过, ${results.errors} 个错误`, message: `同步完成: ${results.created} 个创建, ${results.updated} 个更新, ${results.updated_partial} 个部分更新, ${results.moved} 个移动, ${results.skipped} 个跳过, ${results.errors} 个错误`,
results, results,
} }
} catch (error) { } catch (error) {

View File

@ -39,6 +39,7 @@ export const PreorderProducts: CollectionConfig = {
}, },
beforeListTable: [ beforeListTable: [
'/components/sync/SyncMedusaButton#SyncMedusaButton', '/components/sync/SyncMedusaButton#SyncMedusaButton',
'/components/sync/BatchSyncButton#BatchSyncButton',
'/components/list/ProductGridStyler', '/components/list/ProductGridStyler',
], ],
}, },
@ -84,6 +85,16 @@ export const PreorderProducts: CollectionConfig = {
}, },
], ],
}, },
{
name: 'seedId',
type: 'text',
unique: true,
index: true,
admin: {
description: 'Seed ID (从 Medusa 同步,用于数据绑定)',
readOnly: true,
},
},
{ {
name: 'title', name: 'title',
type: 'text', type: 'text',

View File

@ -39,6 +39,7 @@ export const Products: CollectionConfig = {
}, },
beforeListTable: [ beforeListTable: [
'/components/sync/SyncMedusaButton#SyncMedusaButton', '/components/sync/SyncMedusaButton#SyncMedusaButton',
'/components/sync/BatchSyncButton#BatchSyncButton',
'/components/list/ProductGridStyler', '/components/list/ProductGridStyler',
], ],
}, },
@ -90,6 +91,16 @@ export const Products: CollectionConfig = {
}, },
], ],
}, },
{
name: 'seedId',
type: 'text',
unique: true,
index: true,
admin: {
description: 'Seed ID (恥从 Medusa 同步,用于数据绑定)',
readOnly: true,
},
},
{ {
name: 'title', name: 'title',
type: 'text', type: 'text',

View File

@ -0,0 +1,128 @@
'use client'
import { useState } from 'react'
import { Button, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
/**
*
* Medusa
*/
export function BatchSyncButton() {
const { getQueryParams, selectAll, toggleAll } = useSelection()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const router = useRouter()
// 获取当前页面的 collection slug
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
const collectionSlug = pathname.includes('preorder-products')
? 'preorder-products'
: 'products'
const handleBatchSync = async (forceUpdate: boolean = false) => {
try {
const queryParams = getQueryParams()
// 尝试从不同的位置获取选中的 IDs
let selectedIds: string[] = []
if (queryParams && typeof queryParams === 'object') {
// 尝试从 where 条件中获取
const whereCondition = (queryParams as any).where
if (whereCondition?.id?.in) {
selectedIds = whereCondition.id.in
}
}
if (!selectedIds || selectedIds.length === 0) {
setMessage('请先勾选要同步的商品(使用列表左侧的复选框)')
return
}
if (
forceUpdate &&
!confirm(`确定要强制更新选中的 ${selectedIds.length} 个商品吗?这将覆盖本地修改。`)
) {
return
}
setLoading(true)
setMessage('')
const response = await fetch('/api/batch-sync-medusa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: selectedIds,
collection: collectionSlug,
forceUpdate,
}),
})
const data = await response.json()
if (data.success) {
setMessage(data.message || '批量同步成功!')
// 清除选择
if (toggleAll) {
toggleAll()
}
// 刷新页面
setTimeout(() => {
router.refresh()
}, 1500)
} else {
setMessage(`批量同步失败: ${data.error || '未知错误'}`)
}
} catch (error) {
setMessage(`批量同步出错: ${error instanceof Error ? error.message : '未知错误'}`)
} finally {
setLoading(false)
}
}
return (
<div style={{ padding: '1rem', borderTop: '1px solid var(--theme-elevation-100)' }}>
<h3 style={{ marginBottom: '1rem' }}></h3>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
<Button onClick={() => handleBatchSync(false)} disabled={loading} buttonStyle="secondary">
{loading ? '同步中...' : '同步选中商品'}
</Button>
<Button onClick={() => handleBatchSync(true)} disabled={loading} buttonStyle="secondary">
{loading ? '强制更新中...' : '强制更新选中商品'}
</Button>
</div>
{message && (
<div
style={{
padding: '0.75rem',
backgroundColor: message.includes('失败') || message.includes('出错')
? 'var(--theme-error-50)'
: message.includes('请先选择')
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
borderRadius: '4px',
fontSize: '0.875rem',
}}
>
{message}
</div>
)}
<div
style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--theme-elevation-400)' }}
>
<p style={{ marginBottom: '0.5rem' }}>
<strong></strong>:
</p>
<p style={{ margin: 0 }}>
<strong></strong>:
</p>
</div>
</div>
)
}

View File

@ -8,7 +8,7 @@ import { useDocumentInfo } from '@payloadcms/ui'
* *
*/ */
export function ForceSyncButton() { export function ForceSyncButton() {
const { id } = useDocumentInfo() const { id, collectionSlug } = useDocumentInfo()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@ -26,9 +26,12 @@ export function ForceSyncButton() {
setMessage('') setMessage('')
try { try {
const response = await fetch(`/api/sync-medusa?payloadId=${id}&forceUpdate=true`, { const response = await fetch(
`/api/sync-medusa?payloadId=${id}&collection=${collectionSlug}&forceUpdate=true`,
{
method: 'GET', method: 'GET',
}) },
)
const data = await response.json() const data = await response.json()

View File

@ -15,7 +15,8 @@ interface MedusaProduct {
created_at: string created_at: string
updated_at: string updated_at: string
metadata?: Record<string, any> & { metadata?: Record<string, any> & {
is_preorder?: boolean is_preorder?: boolean | string
seed_id?: string
} }
images?: Array<{ images?: Array<{
id: string id: string
@ -177,10 +178,12 @@ export async function uploadImageFromUrl(imageUrl: string, payload: any): Promis
/** /**
* *
* metadata.is_preorder * metadata.is_preorder ()
*/ */
export function isPreorderProduct(product: MedusaProduct): boolean { export function isPreorderProduct(product: MedusaProduct): boolean {
return product.metadata?.is_preorder === true const isPreorder = product.metadata?.is_preorder
// 支持布尔值 true 或字符串 "true"
return isPreorder === true || isPreorder === 'true'
} }
/** /**
@ -204,6 +207,7 @@ export function transformMedusaProductToPayload(product: MedusaProduct) {
return { return {
medusaId: product.id, medusaId: product.id,
seedId: product.metadata?.seed_id || null,
title: product.title, title: product.title,
handle: product.handle, handle: product.handle,
thumbnail: thumbnailUrl || null, thumbnail: thumbnailUrl || null,

View File

@ -205,6 +205,10 @@ export interface Product {
* *
*/ */
status: 'draft' | 'published'; status: 'draft' | 'published';
/**
* Seed ID Medusa
*/
seedId?: string | null;
/** /**
* Medusa * Medusa
*/ */
@ -270,6 +274,10 @@ export interface PreorderProduct {
* *
*/ */
status: 'draft' | 'published'; status: 'draft' | 'published';
/**
* Seed ID Medusa
*/
seedId?: string | null;
/** /**
* Medusa * Medusa
*/ */
@ -762,6 +770,7 @@ export interface MediaSelect<T extends boolean = true> {
export interface ProductsSelect<T extends boolean = true> { export interface ProductsSelect<T extends boolean = true> {
medusaId?: T; medusaId?: T;
status?: T; status?: T;
seedId?: T;
title?: T; title?: T;
handle?: T; handle?: T;
thumbnail?: T; thumbnail?: T;
@ -778,6 +787,7 @@ export interface ProductsSelect<T extends boolean = true> {
export interface PreorderProductsSelect<T extends boolean = true> { export interface PreorderProductsSelect<T extends boolean = true> {
medusaId?: T; medusaId?: T;
status?: T; status?: T;
seedId?: T;
title?: T; title?: T;
handle?: T; handle?: T;
thumbnail?: T; thumbnail?: T;