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:
Sisyphus 2026-06-25 10:01:45 +08:00
parent 672a4b41b2
commit 49f922444a
4 changed files with 65 additions and 102 deletions

View File

@ -373,27 +373,23 @@ 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) { const errMsg = typeof result.content?.[0]?.text === 'string' ? result.content[0].text : 'Tool 调用返回错误'
throw new Error(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]
if (item.type === 'text') { if (item.type === 'text') {
try { try {
return JSON.parse(item.text) as T return JSON.parse(item.text) as T
} catch { } catch {
return item.text as unknown as T return item.text as unknown as T
}
} }
} }
return result as T
} catch (e: any) {
this.logger.error(`解析 MCP tool 结果失败: ${e.message}`)
return null
} }
return result as T
} }
getClient(integrationId: string): Client | null { getClient(integrationId: string): Client | null {

View File

@ -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)

View File

@ -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 } })
} }
} }

View File

@ -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>