import { Inject, Provide } from '@midwayjs/core'; import axios, { AxiosRequestConfig } from 'axios'; import WooCommerceRestApi, { WooCommerceRestApiVersion } from '@woocommerce/woocommerce-rest-api'; import { WpProduct } from '../entity/wp_product.entity'; import { Variation } from '../entity/variation.entity'; import { UpdateVariationDTO, UpdateWpProductDTO } from '../dto/wp_product.dto'; import { SiteService } from './site.service'; import { IPlatformService } from '../interface/platform.interface'; import * as FormData from 'form-data'; import * as fs from 'fs'; @Provide() export class WPService implements IPlatformService { @Inject() private readonly siteService: SiteService; /** * 构建 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 = {}) { const api = this.createApi(site, 'wc/v3'); 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 ?? 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 }; } /** * 通过 SDK 聚合分页数据,返回全部数据 */ private async sdkGetAll(api: WooCommerceRestApi, resource: string, params: Record = {}, maxPages: number = 50): Promise { // 直接传入较大的per_page参数,一次性获取所有数据 const { items } = await this.sdkGetPage(api, resource, { ...params, per_page: 100 }); return items; } /** * 获取 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 }); } // 导出 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: string, 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(site: any | number, page: number = 1, pageSize: number = 100): Promise { // 如果传入的是站点ID,则获取站点配置 const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site; const api = this.createApi(siteConfig, 'wc/v3'); return await this.sdkGetPage>(api, 'orders', { page, per_page: pageSize }); } /** * 获取 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); } 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: UpdateWpProductDTO ): 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 { 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) { console.error('更新产品库存失败:', error.response?.data || error.message); // throw new Error(`更新产品库存失败: ${error.response?.data?.message || error.message}`); // 为了不打断批量同步,这里记录错误但不抛出 return false; } } /** * 更新 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) { console.error('更新产品变体库存失败:', error.response?.data || error.message); return false; } } /** * 更新 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) { console.error('删除物流信息失败:', error.response?.data || error.message); return false; } } /** * 批量处理产品 (Create, Update, Delete) * @param site 站点配置 * @param data 批量操作数据 { create?: [], update?: [], delete?: [] } */ async batchProcessProducts( site: any, data: { create?: any[]; update?: any[]; delete?: any[] } ): Promise { const api = this.createApi(site, 'wc/v3'); try { const response = await api.post('products/batch', data); return response.data; } catch (error) { console.error('批量处理产品失败:', error.response?.data || error.message); throw error; } } /** * 获取所有产品分类 * @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; } } /** * 获取 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 }; } /** * 上传媒体文件 * @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(), }; const response = await axios.post(url, formData, { headers }); return response.data; } /** * 更新媒体信息 * @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; } /** * 删除媒体文件 * @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<{ items: any[], total: number, totalPages: number }> { const site = await this.siteService.get(siteId); if (!site) { throw new Error(`Site ${siteId} not found`); } if (site.type === 'shopyy') { return { items: [], total: 0, totalPages: 0 }; } 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 }[]; } }