/** * * https://developer.wordpress.org/rest-api/reference/media/ */ import { Inject, Provide } from '@midwayjs/core'; import axios, { AxiosRequestConfig } from 'axios'; import WooCommerceRestApi, { WooCommerceRestApiVersion } from '@woocommerce/woocommerce-rest-api'; import { Variation } from '../entity/variation.entity'; import { SiteService } from './site.service'; import { IPlatformService } from '../interface/platform.interface'; import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto'; import * as FormData from 'form-data'; import * as fs from 'fs'; import { WooProduct, WooVariation } from '../dto/woocommerce.dto'; const MAX_PAGE_SIZE = 100; @Provide() export class WPService implements IPlatformService { @Inject() private readonly siteService: SiteService; getCustomer(site: any, id: number): Promise { throw new Error('Method not implemented.'); } /** * 构建 URL,自动规范各段的斜杠,避免出现多 / 或少 / 导致请求失败 * 使用示例:this.buildURL(wpApiUrl, '/wp-json', 'wc/v3/products', productId) */ private buildURL(base: string, ...parts: Array): string { // 去掉 base 末尾多余斜杠,但不影响协议中的 // const baseSanitized = String(base).replace(/\/+$/g, ''); // 规范各段前后斜杠 const segments = parts .filter((p) => p !== undefined && p !== null) .map((p) => String(p)) .map((s) => s.replace(/^\/+|\/+$/g, '')) .filter(Boolean); const joined = [baseSanitized, ...segments].join('/'); // 折叠除协议外的多余斜杠,例如 https://example.com//a///b -> https://example.com/a/b return joined.replace(/([^:])\/{2,}/g, '$1/'); } /** * 创建 WooCommerce SDK 实例 * @param site 站点配置 * @param namespace API 命名空间,默认 wc/v3;订阅推荐 wcs/v1 */ private createApi(site: any, namespace: WooCommerceRestApiVersion = 'wc/v3') { return new WooCommerceRestApi({ url: site.apiUrl, consumerKey: site.consumerKey, consumerSecret: site.consumerSecret, version: namespace, }); } /** * 通用分页获取资源 */ public async fetchResourcePaged(site: any, resource: string, params: Record = {}, namespace: WooCommerceRestApiVersion = 'wc/v3') { const api = this.createApi(site, namespace); return this.sdkGetPage(api, resource, params); } /** * 通过 SDK 获取单页数据,并返回数据与 totalPages */ private async sdkGetPage(api: any, resource: string, params: Record = {}) { const page = params.page ?? 1; const per_page = params.per_page ?? params.page_size ?? 100; const res = await api.get(resource.replace(/^\/+/, ''), { ...params, page, per_page }); if (res?.headers?.['content-type']?.includes('text/html')) { throw new Error('接口返回了 text/html,可能为 WordPress 登录页或错误页,请检查站点配置或权限'); } const data = res.data as T[]; const totalPages = Number(res.headers?.['x-wp-totalpages'] ?? 1); const total = Number(res.headers?.['x-wp-total']?? 1) return { items: data, total, totalPages, page, per_page, page_size: per_page }; } /** * 通过 SDK 聚合分页数据,返回全部数据 * 使用并发方式获取所有分页数据,提高性能 * 默认按 date_created 倒序排列,确保获取最新的数据 */ async sdkGetAll(api: WooCommerceRestApi, resource: string, params: Record = {}, maxPages: number = MAX_PAGE_SIZE): Promise { return this.sdkGetAllConcurrent(api, resource, params, maxPages); } /** * 通过 SDK 聚合分页数据,使用并发方式获取所有分页数据 * 支持自定义并发数和最大页数限制 * 默认按 date_created 倒序排列,确保获取最新的数据 */ private async sdkGetAllConcurrent( api: WooCommerceRestApi, resource: string, params: Record = {}, maxPages: number = MAX_PAGE_SIZE, concurrencyLimit: number = 5 ): Promise { // 设置默认排序为 date_created 倒序,确保获取最新数据 const defaultParams = { order: 'desc', // 倒序,优先获取最新数据 per_page: MAX_PAGE_SIZE, ...params }; // 首先获取第一页数据,同时获取总页数信息 const firstPage = await this.sdkGetPage(api, resource, { ...defaultParams, page: 1 }); const { items: firstPageItems, totalPages } = firstPage; // 如果只有一页数据,直接返回 if (totalPages <= 1) { return firstPageItems; } // 限制最大页数,避免过多的并发请求 const actualMaxPages = Math.min(totalPages, maxPages); // 收集所有页面数据,从第二页开始 const allItems = [...firstPageItems]; let currentPage = 2; // 使用并发限制,避免一次性发起过多请求 while (currentPage <= actualMaxPages) { const batchPromises: Promise[] = []; const batchSize = Math.min(concurrencyLimit, actualMaxPages - currentPage + 1); // 创建当前批次的并发请求 for (let i = 0; i < batchSize; i++) { const page = currentPage + i; const pagePromise = this.sdkGetPage(api, resource, { ...defaultParams, page }) .then(pageResult => pageResult.items) .catch(error => { console.error(`获取第 ${page} 页数据失败:`, error); return []; // 如果某页获取失败,返回空数组,不影响整体结果 }); batchPromises.push(pagePromise); } // 等待当前批次完成 const batchResults = await Promise.all(batchPromises); // 合并当前批次的数据 for (const pageItems of batchResults) { allItems.push(...pageItems); } // 移动到下一批次 currentPage += batchSize; } return allItems; } /** * 获取 WordPress 数据 * @param wpApiUrl WordPress REST API 的基础地址 * @param endpoint API 端点路径(例如 wc/v3/products) * @param consumerKey WooCommerce 的消费者密钥 * @param consumerSecret WooCommerce 的消费者密钥 */ async fetchData( endpoint: string, site: any, param: Record = {} ): Promise { try { const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site; // 构建 URL,规避多/或少/问题 const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString( 'base64' ); const response = await axios.request({ url, headers: { Authorization: `Basic ${auth}`, }, method: 'GET', ...param, }); return response.data; } catch (error) { throw error; } } async fetchPagedData( endpoint: string, site: any, page: number = 1, perPage: number = 100 ): Promise { const allData: T[] = []; const { apiUrl, consumerKey, consumerSecret } = site; const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString( 'base64' ); console.log(`!!!wpApiUrl, consumerKey, consumerSecret, auth`,site.apiUrl, consumerKey, consumerSecret, auth) let hasMore = true; while (hasMore) { const config: AxiosRequestConfig = { method: 'GET', // 构建 URL,规避多/或少/问题 url: this.buildURL(apiUrl, '/wp-json', endpoint), headers: { Authorization: `Basic ${auth}`, }, params: { page, per_page: perPage, }, }; try { const response = await axios.request(config); // Append the current page data allData.push(...response.data); // Check for more pages const totalPages = parseInt( response.headers['x-wp-totalpages'] || '1', 10 ); hasMore = page < totalPages; page += 1; } catch (error) { throw error; } } return allData; } async getProducts(site: any, page: number = 1, pageSize: number = 100): Promise { const api = this.createApi(site, 'wc/v3'); return await this.sdkGetPage(api, 'products', { page, per_page: pageSize }); } async getProduct(site: any, id: number): Promise { const api = this.createApi(site, 'wc/v3'); const res = await api.get(`products/${id}`); return res.data; } // 导出 WooCommerce 产品为特殊CSV(平台特性) async exportProductsCsvSpecial(site: any, page: number = 1, pageSize: number = 100): Promise { const list = await this.getProducts(site, page, pageSize); const header = ['id','name','type','status','sku','regular_price','sale_price','stock_status','stock_quantity']; const rows = (list.items || []).map((p: any) => [p.id,p.name,p.type,p.status,p.sku,p.regular_price,p.sale_price,p.stock_status,p.stock_quantity]); const csv = [header.join(','), ...rows.map(r => r.map(v => String(v ?? '')).join(','))].join('\n'); return csv; } async getVariations(site: any, productId: number, page: number = 1, pageSize: number = 100): Promise { const api = this.createApi(site, 'wc/v3'); return await this.sdkGetPage(api, `products/${productId}/variations`, { page, per_page: pageSize }); } async getVariation( site: any, productId: number, variationId: number ): Promise { const api = this.createApi(site, 'wc/v3'); const res = await api.get(`products/${productId}/variations/${variationId}`); return res.data as Variation; } async getOrder( siteId: number, orderId: string ): Promise> { const site = await this.siteService.get(siteId); const api = this.createApi(site, 'wc/v3'); const res = await api.get(`orders/${orderId}`); return res.data as Record; } async getOrders(siteId: number,params: Record = {}): Promise[]> { const site = await this.siteService.get(siteId); const api = this.createApi(site, 'wc/v3'); return await this.sdkGetAll>(api, 'orders', params); } /** * 获取 WooCommerce Subscriptions * 优先尝试 wc/v1/subscriptions(Subscriptions 插件提供),失败时回退 wc/v3/subscriptions. */ async getSubscriptions(site: any | number, page: number = 1, pageSize: number = 100): Promise { // 如果传入的是站点ID,则获取站点配置 const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site; // 优先使用 Subscriptions 命名空间 wcs/v1,失败回退 wc/v3 const api = this.createApi(siteConfig, 'wc/v3'); return await this.sdkGetPage>(api, 'subscriptions', { page, per_page: pageSize }); } async getOrderRefund( siteId: string, orderId: string, refundId: number ): Promise> { const site = await this.siteService.get(siteId); const api = this.createApi(site, 'wc/v3'); const res = await api.get(`orders/${orderId}/refunds/${refundId}`); return res.data as Record; } async getOrderRefunds( site: any | string, orderId: number, page: number = 1, pageSize: number = 100 ): Promise { // 如果传入的是站点ID,则获取站点配置 const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site; const api = this.createApi(siteConfig, 'wc/v3'); return await this.sdkGetPage>(api, `orders/${orderId}/refunds`, { page, per_page: pageSize }); } async getOrderNote( siteId: string, orderId: number, noteId: number ): Promise> { const site = await this.siteService.get(siteId); const api = this.createApi(site, 'wc/v3'); const res = await api.get(`orders/${orderId}/notes/${noteId}`); return res.data as Record; } async getOrderNotes( site: any | string, orderId: number, page: number = 1, pageSize: number = 100 ): Promise { // 如果传入的是站点ID,则获取站点配置 const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site; const api = this.createApi(siteConfig, 'wc/v3'); return await this.sdkGetPage>(api, `orders/${orderId}/notes`, { page, per_page: pageSize }); } /** * 创建 WooCommerce 产品 * @param site 站点配置 * @param data 产品数据 */ async createProduct( site: any, data: any ): Promise { const api = this.createApi(site, 'wc/v3'); // 确保价格为字符串 if (data.regular_price !== undefined && data.regular_price !== null) { data.regular_price = String(data.regular_price); } if (data.sale_price !== undefined && data.sale_price !== null) { data.sale_price = String(data.sale_price); } // 处理标签字段,如果为字符串数组则转换为 WooCommerce 所需的对象数组 if (Array.isArray((data as any).tags)) { const tags = (data as any).tags; (data as any).tags = tags.map((name: any) => ({ name })); } try { const response = await api.post('products', data); return response.data; } catch (error) { console.error('创建产品失败:', error.response?.data || error.message); throw error; } } /** * 更新 WooCommerce 产品 * @param productId 产品 ID * @param data 更新的数据 */ async updateProduct( site: any, productId: string, data: WooProduct ): Promise { const { regular_price, sale_price, ...params } = data; const api = this.createApi(site, 'wc/v3'); const updateData: any = { ...params }; if (regular_price !== undefined && regular_price !== null) { updateData.regular_price = String(regular_price); } if (sale_price !== undefined && sale_price !== null) { updateData.sale_price = String(sale_price); } // 处理标签字段,如果为字符串数组则转换为 WooCommerce 所需的对象数组 if (Array.isArray(updateData.tags)) { updateData.tags = updateData.tags.map((name: any) => ({ name })); } try { const response = await api.put(`products/${productId}`, updateData); return response.data; } catch (error) { console.error('更新产品失败:', error.response?.data || error.message); throw new Error(`更新产品失败: ${error.response?.data?.message || error.message}`); } } /** * 更新 WooCommerce 产品 上下架状态 * @param productId 产品 ID * @param status 状态 * @param stockStatus 库存状态 */ async updateProductStatus( site: any, productId: string, status: string, stockStatus: string ): Promise { const api = this.createApi(site, 'wc/v3'); try { await api.put(`products/${productId}`, { status, manage_stock: false, // 为true的时候,用quantity控制库存,为false时,直接用stock_status控制 stock_status: stockStatus, }); return true; } catch (error) { console.error('更新产品上下架状态失败:', error.response?.data || error.message); throw new Error(`更新产品上下架状态失败: ${error.response?.data?.message || error.message}`); } } /** * 更新 WooCommerce 产品库存 * @param site 站点配置 * @param productId 产品 ID * @param quantity 库存数量 * @param stockStatus 库存状态 */ async updateProductStock( site: any, productId: string, quantity: number, stockStatus: string ): Promise { const api = this.createApi(site, 'wc/v3'); try { await api.put(`products/${productId}`, { manage_stock: true, stock_quantity: quantity, stock_status: stockStatus, }); return true; } catch (error) { const errorMessage = error.response?.data?.message || error.message || '更新产品库存失败'; throw new Error(`更新产品库存失败: ${errorMessage}`); } } /** * 更新 WooCommerce 产品变体库存 * @param site 站点配置 * @param productId 产品 ID * @param variationId 变体 ID * @param quantity 库存数量 * @param stockStatus 库存状态 */ async updateProductVariationStock( site: any, productId: string, variationId: string, quantity: number, stockStatus: string ): Promise { const api = this.createApi(site, 'wc/v3'); try { await api.put(`products/${productId}/variations/${variationId}`, { manage_stock: true, stock_quantity: quantity, stock_status: stockStatus, }); return true; } catch (error) { const errorMessage = error.response?.data?.message || error.message || '更新产品变体库存失败'; throw new Error(`更新产品变体库存失败: ${errorMessage}`); } } /** * 更新 WooCommerce 产品变体 * @param productId 产品 ID * @param variationId 变体 ID * @param data 更新的数据 */ async updateVariation( site: any, productId: string, variationId: string, data: Partial ): Promise { const { regular_price, sale_price, ...params } = data; const api = this.createApi(site, 'wc/v3'); const updateData: any = { ...params }; if (regular_price !== undefined && regular_price !== null) { updateData.regular_price = String(regular_price); } if (sale_price !== undefined && sale_price !== null) { updateData.sale_price = String(sale_price); } try { await api.put(`products/${productId}/variations/${variationId}`, updateData); return true; } catch (error) { console.error('更新产品变体失败:', error.response?.data || error.message); throw new Error(`更新产品变体失败: ${error.response?.data?.message || error.message}`); } } /** * 更新 Order */ async updateOrder( site: any, orderId: string, data: Record ): Promise { const api = this.createApi(site, 'wc/v3'); try { await api.put(`orders/${orderId}`, data); return true; } catch (error) { console.error('更新订单失败:', error.response?.data || error.message); throw new Error(`更新订单失败: ${error.response?.data?.message || error.message}`); } } async createShipment( site: any, orderId: string, data: Record ) { const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site; const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString( 'base64' ); const config: AxiosRequestConfig = { method: 'POST', // 构建 URL,规避多/或少/问题 url: this.buildURL( apiUrl, '/wp-json', 'wc-ast/v3/orders', orderId, 'shipment-trackings' ), headers: { Authorization: `Basic ${auth}`, }, data, }; return await axios.request(config); } async deleteShipment( site: any, orderId: string, trackingId: string, ): Promise { const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site; const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString( 'base64' ); console.log('del', orderId, trackingId); // 删除接口: DELETE /wp-json/wc-shipment-tracking/v3/orders//shipment-trackings/ const config: AxiosRequestConfig = { method: 'DELETE', // 构建 URL,规避多/或少/问题 url: this.buildURL( apiUrl, '/wp-json', 'wc-ast/v3/orders', orderId, 'shipment-trackings', trackingId ), headers: { Authorization: `Basic ${auth}`, }, }; try { await axios.request(config); return true; } catch (error) { const errorMessage = error.response?.data?.message || error.message || '删除物流信息失败'; throw new Error(`删除物流信息失败: ${errorMessage}`); } } /** * 批量处理产品 (Create, Update, Delete) * @param site 站点配置 * @param data 批量操作数据 { create?: [], update?: [], delete?: [] } */ async batchProcessProducts( site: any, data: BatchOperationDTO ): Promise { const api = this.createApi(site, 'wc/v3'); try { const response = await api.post('products/batch', data); const result = response.data; // 转换 WooCommerce 批量操作结果为统一格式 const errors: Array<{identifier: string, error: string}> = []; // WooCommerce 返回格式: { create: [...], update: [...], delete: [...] } // 错误信息可能在每个项目的 error 字段中 const checkForErrors = (items: any[]) => { items.forEach(item => { if (item.error) { errors.push({ identifier: String(item.id || item.sku || 'unknown'), error: typeof item.error === 'string' ? item.error : JSON.stringify(item.error) }); } }); }; // 检查每个操作类型的结果中的错误 if (result.create) checkForErrors(result.create); if (result.update) checkForErrors(result.update); if (result.delete) checkForErrors(result.delete); return { total: (data.create?.length || 0) + (data.update?.length || 0) + (data.delete?.length || 0), processed: (result.create?.length || 0) + (result.update?.length || 0) + (result.delete?.length || 0), created: result.create?.length || 0, updated: result.update?.length || 0, deleted: result.delete?.length || 0, errors: errors }; } catch (error) { console.error('批量处理产品失败:', error.response?.data || error.message); throw error; } } /** * 获取 api 客户端 * @param site 站点配置信息 * @returns api 客户端 */ getApiClient(site: any): any { return this.createApi(site); } /** * 获取所有产品分类 * @param site 站点配置 */ async getCategories(site: any): Promise { const api = this.createApi(site, 'wc/v3'); return await this.sdkGetAll(api, 'products/categories'); } /** * 批量处理产品分类 * @param site 站点配置 * @param data { create?: [], update?: [], delete?: [] } */ async batchProcessCategories( site: any, data: { create?: any[]; update?: any[]; delete?: any[] } ): Promise { const api = this.createApi(site, 'wc/v3'); try { const response = await api.post('products/categories/batch', data); return response.data; } catch (error) { console.error('批量处理产品分类失败:', error.response?.data || error.message); throw error; } } /** * 获取所有产品标签 * @param site 站点配置 */ async getTags(site: any): Promise { const api = this.createApi(site, 'wc/v3'); return await this.sdkGetAll(api, 'products/tags'); } /** * 批量处理产品标签 * @param site 站点配置 * @param data { create?: [], update?: [], delete?: [] } */ async batchProcessTags( site: any, data: { create?: any[]; update?: any[]; delete?: any[] } ): Promise { const api = this.createApi(site, 'wc/v3'); try { const response = await api.post('products/tags/batch', data); return response.data; } catch (error) { console.error('批量处理产品标签失败:', error.response?.data || error.message); throw error; } } async createReview(site: any, data: any): Promise { const api = this.createApi(site, 'wc/v3'); try { const response = await api.post('products/reviews', data); return response.data; } catch (error) { console.error('创建评论失败:', error.response?.data || error.message); throw error; } } async getReviews(site: any, page: number = 1, pageSize: number = 100): Promise { const api = this.createApi(site, 'wc/v3'); return await this.sdkGetPage(api, 'products/reviews', { page, per_page: pageSize }); } async updateReview(site: any, reviewId: number, data: any): Promise { const api = this.createApi(site, 'wc/v3'); try { const response = await api.put(`products/reviews/${reviewId}`, data); return response.data; } catch (error) { console.error('更新评论失败:', error.response?.data || error.message); throw error; } } async deleteReview(site: any, reviewId: number): Promise { const api = this.createApi(site, 'wc/v3'); try { await api.delete(`products/reviews/${reviewId}`, { force: true }); return true; } catch (error) { const errorMessage = error.response?.data?.message || error.message || '删除评论失败'; throw new Error(`删除评论失败: ${errorMessage}`); } } /** * 获取WooCommerce webhook列表 * @param site 站点配置 * @param params 查询参数 * @returns 分页webhook列表 */ async getWebhooks(site: any, params: any): Promise { // WooCommerce API: GET /webhooks const api = this.createApi(site, 'wc/v3'); return this.sdkGetPage(api, 'webhooks', params); } /** * 获取单个WooCommerce webhook * @param site 站点配置 * @param webhookId webhook ID * @returns webhook详情 */ async getWebhook(site: any, webhookId: string | number): Promise { // WooCommerce API: GET /webhooks/{id} const api = this.createApi(site, 'wc/v3'); const res = await api.get(`webhooks/${webhookId}`); return res.data; } /** * 创建WooCommerce webhook * @param site 站点配置 * @param data webhook数据 * @returns 创建结果 */ async createWebhook(site: any, data: any): Promise { // WooCommerce API: POST /webhooks const api = this.createApi(site, 'wc/v3'); const res = await api.post('webhooks', data); return res.data; } /** * 更新WooCommerce webhook * @param site 站点配置 * @param webhookId webhook ID * @param data 更新数据 * @returns 更新结果 */ async updateWebhook(site: any, webhookId: string | number, data: any): Promise { // WooCommerce API: PUT /webhooks/{id} const api = this.createApi(site, 'wc/v3'); const res = await api.put(`webhooks/${webhookId}`, data); return res.data; } /** * 删除WooCommerce webhook * @param site 站点配置 * @param webhookId webhook ID * @returns 删除结果 */ async deleteWebhook(site: any, webhookId: string | number): Promise { // WooCommerce API: DELETE /webhooks/{id} const api = this.createApi(site, 'wc/v3'); try { await api.delete(`webhooks/${webhookId}`, { force: true }); return true; } catch (error) { const errorMessage = error.response?.data?.message || error.message || '删除webhook失败'; throw new Error(`删除webhook失败: ${errorMessage}`); } } /** * 获取 WordPress 媒体库数据 * @param siteId 站点 ID * @param page 页码 * @param perPage 每页数量 */ async getMedia(siteId: number, page: number = 1, perPage: number = 20): Promise<{ items: any[], total: number, totalPages: number }> { const site = await this.siteService.get(siteId, true); if (!site) { throw new Error('站点不存在'); } const endpoint = 'wp/v2/media'; const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; // 构建 URL,规避多/或少/问题 const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); const response = await axios.get(url, { headers: { Authorization: `Basic ${auth}` }, params: { page, per_page: perPage } }); const total = Number(response.headers['x-wp-total'] || 0); const totalPages = Number(response.headers['x-wp-totalpages'] || 0); return { items: response.data, total, totalPages }; } public async fetchMediaPaged(site: any, params: Record = {}) { const page = Number(params.page ?? 1); const per_page = Number( params.per_page ?? 20); const where = params.where && typeof params.where === 'object' ? params.where : {}; let orderby: string | undefined = params.orderby; let order: 'asc' | 'desc' | undefined = params.orderDir as any; if (!orderby && params.order && typeof params.order === 'object') { const entries = Object.entries(params.order as Record); if (entries.length > 0) { const [field, dir] = entries[0]; orderby = field; order = String(dir).toLowerCase() === 'desc' ? 'desc' : 'asc'; } } const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; const endpoint = 'wp/v2/media'; const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); const response = await axios.get(url, { headers: { Authorization: `Basic ${auth}` }, params: { ...where, ...(params.search ? { search: params.search } : {}), ...(orderby ? { orderby } : {}), ...(order ? { order } : {}), page, per_page } }); const total = Number(response.headers['x-wp-total'] || 0); const totalPages = Number(response.headers['x-wp-totalpages'] || 0); return { items: response.data, total, totalPages, page, per_page, page_size: per_page }; } /** * 上传媒体文件 * @param siteId 站点 ID * @param file 文件对象 */ async createMedia(siteId: number, file: any): Promise { const site = await this.siteService.get(siteId, true); if (!site) { throw new Error('站点不存在'); } const endpoint = 'wp/v2/media'; const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); const formData = new FormData(); // 假设 file 是 MidwayJS 的 file 对象 // MidwayJS 上传文件通常在 tmp 目录,需要读取流 formData.append('file', fs.createReadStream(file.data), { filename: file.filename, contentType: file.mimeType, }); // Axios headers for multipart const headers = { Authorization: `Basic ${auth}`, 'Content-Disposition': `attachment; filename=${file.filename}`, ...formData.getHeaders(), }; try { const response = await axios.post(url, formData, { headers, // [关键修复] 允许 axios 发送大的请求体, Infinity 表示不限制大小 // 默认情况下, axios 对请求体大小有限制, 上传大文件时会中断 maxBodyLength: Infinity, maxContentLength: Infinity, // [建议] 为上传设置一个更长的超时时间 (例如 60 秒) // 避免因网络波动或大文件上传时间长导致请求被客户端中断 timeout: 60000, }); return response.data; } catch (error) { console.error('上传媒体文件失败:', error.response?.data || error.message); // 增加对 EPIPE 错误的特定提示 if (error.code === 'EPIPE') { console.error( '检测到 EPIPE 错误, 这通常意味着服务器端提前关闭了连接.' ); console.error( '请检查服务器配置, 例如 Nginx 的 client_max_body_size 和 PHP 的 post_max_size / upload_max_filesize.' ); } throw error; } } /** * 更新媒体信息 * @param siteId 站点 ID * @param mediaId 媒体 ID * @param data 更新数据 (title, caption, description, alt_text) */ async updateMedia(siteId: number, mediaId: number, data: any): Promise { const site = await this.siteService.get(siteId, true); if (!site) { throw new Error('站点不存在'); } const endpoint = `wp/v2/media/${mediaId}`; const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); const response = await axios.post(url, data, { headers: { Authorization: `Basic ${auth}` }, }); return response.data; } async getMediaDetail(siteId: number, mediaId: number): Promise { // 函数说明 获取单个媒体的详细信息用于后续下载与转换 const site = await this.siteService.get(siteId, true); if (!site) { throw new Error('站点不存在'); } const endpoint = `wp/v2/media/${mediaId}`; const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); const response = await axios.get(url, { headers: { Authorization: `Basic ${auth}` } }); return response.data; } async uploadMediaBufferWebp(siteId: number, buffer: Buffer, baseFilename: string): Promise { // 函数说明 以 Buffer 上传 webp 文件到 WordPress 媒体库 const site = await this.siteService.get(siteId, true); if (!site) { throw new Error('站点不存在'); } const endpoint = 'wp/v2/media'; const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); const form = new (FormData as any)(); const filename = `${baseFilename}.webp`; // 条件判断 如果文件名为空则使用默认名称 form.append('file', buffer, { filename, contentType: 'image/webp' }); const headers = { Authorization: `Basic ${auth}`, 'Content-Disposition': `attachment; filename=${filename}`, ...(form as any).getHeaders(), }; const response = await axios.post(url, form, { headers }); return response.data; } async convertMediaToWebp(siteId: number, mediaIds: Array): Promise<{ converted: any[]; failed: Array<{ id: number | string; error: string }> }> { // 函数说明 批量将媒体转换为 webp 并上传为新媒体 const converted: any[] = []; const failed: Array<{ id: number | string; error: string }> = []; const sharp = require('sharp'); for (const id of mediaIds) { try { // 获取媒体详情用于下载源文件 const detail = await this.getMediaDetail(siteId, Number(id)); const srcUrl = detail?.source_url; if (!srcUrl) { throw new Error('source_url 不存在'); } // 下载源文件为 Buffer const resp = await axios.get(srcUrl, { responseType: 'arraybuffer', timeout: 30000, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Node.js Axios)', } }); const inputBuffer = Buffer.from(resp.data); // 条件判断 如果下载的 Buffer 为空则抛出错误 if (!inputBuffer || inputBuffer.length === 0) { throw new Error('下载源文件失败'); } // 使用 sharp 转换为 webp 格式 const webpBuffer: Buffer = await sharp(inputBuffer).webp({ quality: 85 }).toBuffer(); // 条件判断 如果转换结果为空则抛出错误 if (!webpBuffer || webpBuffer.length === 0) { throw new Error('转换为 webp 失败'); } // 生成基础文件名 使用原始文件名或媒体ID const baseName = String(detail?.slug || detail?.title?.rendered || detail?.title || id).replace(/\s+/g, '-'); const uploaded = await this.uploadMediaBufferWebp(siteId, webpBuffer, baseName); converted.push(uploaded); } catch (e) { failed.push({ id, error: (e as any)?.message || '未知错误' }); } } return { converted, failed }; } /** * 删除媒体文件 * @param siteId 站点 ID * @param mediaId 媒体 ID * @param force 是否强制删除(绕过回收站) */ async deleteMedia(siteId: number, mediaId: number, force: boolean = true): Promise { const site = await this.siteService.get(siteId, true); if (!site) { throw new Error('站点不存在'); } const endpoint = `wp/v2/media/${mediaId}`; const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; const url = this.buildURL(apiUrl, '/wp-json', endpoint); const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); const response = await axios.delete(url, { headers: { Authorization: `Basic ${auth}` }, params: { force }, }); return response.data; } async getCustomers(siteId: number, page: number = 1, perPage: number = 20): Promise { const site = await this.siteService.get(siteId); if (!site) { throw new Error(`Site ${siteId} not found`); } const api = this.createApi(site, 'wc/v3'); return await this.sdkGetPage(api, 'customers', { page, per_page: perPage }); } async ensureTags(site: any, tagNames: string[]): Promise<{ id: number; name: string }[]> { if (!tagNames || tagNames.length === 0) return []; const allTags = await this.getTags(site); const existingTagMap = new Map(allTags.map((t) => [t.name, t.id])); const missingTags = tagNames.filter((name) => !existingTagMap.has(name)); if (missingTags.length > 0) { const createPayload = missingTags.map((name) => ({ name })); const createdTagsResult = await this.batchProcessTags(site, { create: createPayload }); if (createdTagsResult && createdTagsResult.create) { createdTagsResult.create.forEach((t) => { if (t.id && t.name) existingTagMap.set(t.name, t.id); }); } } return tagNames .map((name) => { const id = existingTagMap.get(name); return id ? { id, name } : null; }) .filter((t) => t !== null) as { id: number; name: string }[]; } async ensureCategories(site: any, categoryNames: string[]): Promise<{ id: number; name: string }[]> { if (!categoryNames || categoryNames.length === 0) return []; const allCategories = await this.getCategories(site); const existingCatMap = new Map(allCategories.map((c) => [c.name, c.id])); const missingCategories = categoryNames.filter((name) => !existingCatMap.has(name)); if (missingCategories.length > 0) { const createPayload = missingCategories.map((name) => ({ name })); const createdCatsResult = await this.batchProcessCategories(site, { create: createPayload }); if (createdCatsResult && createdCatsResult.create) { createdCatsResult.create.forEach((c) => { if (c.id && c.name) existingCatMap.set(c.name, c.id); }); } } return categoryNames .map((name) => { const id = existingCatMap.get(name); return id ? { id, name } : null; }) .filter((c) => c !== null) as { id: number; name: string }[]; } }