feat(F002-P1): 调试期累积修复
- 商品上架 MCP: 简化 batchPublish, price=0 默认 0.01, sku 用 spec/name - 集成 service: 完善 import_products 调用链 - 前端 ProductPage: 调整按钮与列表 - 文档: 本地开发工作流 + devbox F002 升级需求 - 脚本: rotate-mcp-key.sh (一键密钥轮换)
This commit is contained in:
parent
672a4b41b2
commit
49f922444a
@ -373,11 +373,11 @@ export class IntegrationService implements OnModuleDestroy {
|
|||||||
* 解析 MCP tool 返回的 content 字段
|
* 解析 MCP tool 返回的 content 字段
|
||||||
* MCP 协议格式: { content: [{ type: 'text', text: '...' }], isError?: boolean }
|
* MCP 协议格式: { content: [{ type: 'text', text: '...' }], isError?: boolean }
|
||||||
*/
|
*/
|
||||||
private parseToolResult<T>(result: any): T | null {
|
private parseToolResult<T>(result: any): T {
|
||||||
try {
|
if (!result) throw new Error('MCP 工具返回结果为空')
|
||||||
if (!result) return null
|
|
||||||
if (result.isError) {
|
if (result.isError) {
|
||||||
throw new Error(typeof result.content?.[0]?.text === 'string' ? result.content[0].text : 'Tool 调用返回错误')
|
const errMsg = typeof result.content?.[0]?.text === 'string' ? result.content[0].text : 'Tool 调用返回错误'
|
||||||
|
throw new Error(errMsg)
|
||||||
}
|
}
|
||||||
if (result.content && Array.isArray(result.content) && result.content.length > 0) {
|
if (result.content && Array.isArray(result.content) && result.content.length > 0) {
|
||||||
const item = result.content[0]
|
const item = result.content[0]
|
||||||
@ -390,10 +390,6 @@ export class IntegrationService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result as T
|
return result as T
|
||||||
} catch (e: any) {
|
|
||||||
this.logger.error(`解析 MCP tool 结果失败: ${e.message}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getClient(integrationId: string): Client | null {
|
getClient(integrationId: string): Client | null {
|
||||||
|
|||||||
@ -40,14 +40,6 @@ export class ProductController {
|
|||||||
return this.productService.batchPublish(body.ids)
|
return this.productService.batchPublish(body.ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* F002 P0-A: 批量上架商品到 B2B 平台 (MCP import_products)
|
|
||||||
*/
|
|
||||||
@Post('publish-to-direv')
|
|
||||||
publishToDirev(@Body() body: { ids: string[] }) {
|
|
||||||
return this.productService.publishToDirev(body.ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.productService.remove(id)
|
return this.productService.remove(id)
|
||||||
|
|||||||
@ -42,90 +42,83 @@ export class ProductService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async batchPublish(ids: string[]) {
|
async batchPublish(ids: string[]) {
|
||||||
return this.prisma.product.updateMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
data: { status: 'published' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string) {
|
|
||||||
return this.prisma.product.delete({ where: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F002 P0-A: 批量上架商品到 B2B 平台 (MCP import_products)
|
|
||||||
* 字段映射: Product → import_products schema
|
|
||||||
*/
|
|
||||||
async publishToDirev(ids: string[]) {
|
|
||||||
const b2bIntegration = await this.integrationService.findActiveMCP()
|
|
||||||
if (!b2bIntegration) {
|
|
||||||
throw new ServiceUnavailableException('未配置 B2B 集成')
|
|
||||||
}
|
|
||||||
|
|
||||||
const products = await this.prisma.product.findMany({
|
const products = await this.prisma.product.findMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids } },
|
||||||
})
|
})
|
||||||
|
if (products.length === 0) return { imported: 0, errors: [], ids: [] }
|
||||||
|
|
||||||
if (products.length === 0) {
|
const b2bIntegration = await this.integrationService.findActiveMCP()
|
||||||
throw new ServiceUnavailableException('未找到指定商品')
|
if (!b2bIntegration) {
|
||||||
|
throw new ServiceUnavailableException('未配置 B2B 平台 MCP 集成,请在系统配置中添加')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category string → numeric ID mapping (B2B tb_good first_category_id)
|
|
||||||
const CATEGORY_MAP: Record<string, number> = {
|
const CATEGORY_MAP: Record<string, number> = {
|
||||||
'电缆': 28,
|
'电缆': 28, '电力电缆': 28, '配电箱': 29, '开关柜': 29,
|
||||||
'电力电缆': 28,
|
'变压器': 27, '电线': 28, '母线槽': 30, '桥架': 31,
|
||||||
'配电箱': 29,
|
|
||||||
'开关柜': 29,
|
|
||||||
'变压器': 27,
|
|
||||||
'电线': 28,
|
|
||||||
'母线槽': 30,
|
|
||||||
'桥架': 31,
|
|
||||||
}
|
}
|
||||||
const fallbackCategoryId = 1
|
const fallbackCategoryId = 1
|
||||||
|
|
||||||
const items = products.map(p => {
|
const mappedItems = products.map(p => {
|
||||||
const catId = CATEGORY_MAP[p.category] || fallbackCategoryId
|
const catId = CATEGORY_MAP[p.category] || fallbackCategoryId
|
||||||
const item: any = {
|
const item: any = {
|
||||||
name: p.name,
|
name: p.name, category_id: catId,
|
||||||
category_id: catId,
|
price: p.price > 0 ? p.price : 0.01,
|
||||||
price: p.price,
|
brandName: p.brand || '', stock: p.quantity,
|
||||||
brandName: p.brand || '',
|
sku: p.spec || p.name || '',
|
||||||
stock: p.quantity,
|
|
||||||
}
|
}
|
||||||
if (p.spec) item.sku = p.spec
|
|
||||||
if (p.imageUrl) item.images = [p.imageUrl]
|
if (p.imageUrl) item.images = [p.imageUrl]
|
||||||
if (p.supplier) item.remark = `供应商:${p.supplier}`
|
if (p.supplier) item.remark = `供应商:${p.supplier}`
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.logger.log(`batchPublish: 发送 ${mappedItems.length} 个商品到 MCP import_products`)
|
||||||
const result = await this.integrationService.executeTool<any>(
|
const result = await this.integrationService.executeTool<any>(
|
||||||
b2bIntegration.id,
|
b2bIntegration.id, 'import_products',
|
||||||
'import_products',
|
{ products: mappedItems },
|
||||||
{ products: items },
|
|
||||||
{ retries: 2, retryDelayMs: 1000 },
|
{ retries: 2, retryDelayMs: 1000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this.logger.log(`batchPublish: MCP 返回 success=${result.success} data=${result.data ? JSON.stringify(result.data).slice(0, 300) : '(empty)'}`)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errorMsg = result.error || '未知错误'
|
throw new ServiceUnavailableException(`B2B 同步失败: ${result.error || '未知错误'}`)
|
||||||
this.logger.error(`商品上架失败 (ids=${ids.join(',')}): ${errorMsg}`)
|
|
||||||
throw new ServiceUnavailableException(`上架失败: ${errorMsg}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = result.data as any
|
// MCP import_products 返回格式: { code:0, msg:"成功", data: [{ status:"success"|"failed", data:{id:"xxx"}, sku, name, error? }] }
|
||||||
const payload = data.data || data
|
const items: Array<{ status: string; data?: any; sku?: string; name?: string; error?: string }> =
|
||||||
const imported = payload.imported || 0
|
(result.data as any)?.data || []
|
||||||
const errors = payload.errors || []
|
const imported = items.filter(i => i.status === 'success').length
|
||||||
this.logger.log(`商品上架成功: 导入 ${imported} 条, 失败 ${errors.length} 条`)
|
const b2bIds: string[] = items
|
||||||
|
.filter(i => i.status === 'success' && i.data?.id)
|
||||||
|
.map(i => String(i.data.id))
|
||||||
|
const errors: Array<{ sku: string; name: string; error: string }> = items
|
||||||
|
.filter(i => i.status === 'failed')
|
||||||
|
.map(i => ({ sku: i.sku || '', name: i.name || '', error: i.error || '未知错误' }))
|
||||||
|
this.logger.log(`B2B import_products 完成: 导入 ${imported} 条, 失败 ${errors.length} 条, B2B IDs: ${b2bIds.join(',')}`)
|
||||||
|
|
||||||
|
if (b2bIds.length > 0) {
|
||||||
|
this.logger.log(`batchPublish: 上架 ${b2bIds.length} 个商品到 B2B publish_products`)
|
||||||
|
const publishResult = await this.integrationService.executeTool<any>(
|
||||||
|
b2bIntegration.id, 'publish_products',
|
||||||
|
{ product_ids: b2bIds },
|
||||||
|
{ retries: 1, retryDelayMs: 1000 },
|
||||||
|
)
|
||||||
|
this.logger.log(`batchPublish: publish_products 返回 success=${publishResult.success}`)
|
||||||
|
if (!publishResult.success) {
|
||||||
|
throw new ServiceUnavailableException(`B2B 上架失败: ${publishResult.error || 'publish_products 返回失败'}`)
|
||||||
|
}
|
||||||
|
this.logger.log(`B2B 上架成功: ${b2bIds.length} 个商品`)
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.product.updateMany({
|
await this.prisma.product.updateMany({
|
||||||
where: { id: { in: products.map(p => p.id) } },
|
where: { id: { in: ids } },
|
||||||
data: { status: 'published' },
|
data: { status: 'published' },
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return { imported, errors, ids: b2bIds }
|
||||||
imported,
|
|
||||||
errors,
|
|
||||||
ids: payload.ids || [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
return this.prisma.product.delete({ where: { id } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -172,21 +172,8 @@ export default function ProductPage() {
|
|||||||
const batchSync = async () => {
|
const batchSync = async () => {
|
||||||
if (selectedIds.size === 0) return
|
if (selectedIds.size === 0) return
|
||||||
showToast(`正在同步 ${selectedIds.size} 个商品...`)
|
showToast(`正在同步 ${selectedIds.size} 个商品...`)
|
||||||
await fetch(`${API}/batch-publish`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
|
||||||
})
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
await loadProducts()
|
|
||||||
showToast('批量同步完成')
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchPublishToDirev = async () => {
|
|
||||||
if (selectedIds.size === 0) return
|
|
||||||
showToast(`正在上架 ${selectedIds.size} 个商品到 B2B 平台...`)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/publish-to-direv`, {
|
const res = await fetch(`${API}/batch-publish`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||||
@ -195,9 +182,9 @@ export default function ProductPage() {
|
|||||||
if (!res.ok) throw new Error(data.message || data.error || '请求失败')
|
if (!res.ok) throw new Error(data.message || data.error || '请求失败')
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
await loadProducts()
|
await loadProducts()
|
||||||
showToast(`上架成功: ${data.imported || 0} 条${data.errors?.length ? `, 失败 ${data.errors.length} 条` : ''}`)
|
showToast(`同步成功: ${data.imported || 0} 条导入 B2B${data.errors?.length ? `, 失败 ${data.errors.length} 条` : ''}`)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showToast(`上架失败: ${e.message}`)
|
showToast(`同步失败: ${e.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -472,11 +459,6 @@ export default function ProductPage() {
|
|||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||||
批量同步
|
批量同步
|
||||||
</button>
|
</button>
|
||||||
<button disabled={selectedIds.size === 0} onClick={batchPublishToDirev}
|
|
||||||
className="px-3 py-1.5 text-[10px] font-medium bg-emerald-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:opacity-90">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
|
||||||
上架 B2B
|
|
||||||
</button>
|
|
||||||
<button disabled={selectedIds.size === 0} onClick={batchGenerate}
|
<button disabled={selectedIds.size === 0} onClick={batchGenerate}
|
||||||
className="px-3 py-1.5 text-[10px] font-medium bg-white border border-purple-200 text-purple-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:bg-purple-50 transition-colors">
|
className="px-3 py-1.5 text-[10px] font-medium bg-white border border-purple-200 text-purple-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:bg-purple-50 transition-colors">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user