API/src/service/wp.service.ts

779 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 | number>): 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<T>(site: any, resource: string, params: Record<string, any> = {}) {
const api = this.createApi(site, 'wc/v3');
return this.sdkGetPage<T>(api, resource, params);
}
/**
* 通过 SDK 获取单页数据,并返回数据与 totalPages
*/
private async sdkGetPage<T>(api: any, resource: string, params: Record<string, any> = {}) {
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<T>(api: WooCommerceRestApi, resource: string, params: Record<string, any> = {}, maxPages: number = 50): Promise<T[]> {
// 直接传入较大的per_page参数一次性获取所有数据
const { items } = await this.sdkGetPage<T>(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<T>(
endpoint: string,
site: any,
param: Record<string, any> = {}
): Promise<T> {
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<T>(
endpoint: string,
site: any,
page: number = 1,
perPage: number = 100
): Promise<T[]> {
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<any> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetPage<WpProduct>(api, 'products', { page, per_page: pageSize });
}
// 导出 WooCommerce 产品为特殊CSV平台特性
async exportProductsCsvSpecial(site: any, page: number = 1, pageSize: number = 100): Promise<string> {
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<any> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetPage<Variation>(api, `products/${productId}/variations`, { page, per_page: pageSize });
}
async getVariation(
site: any,
productId: number,
variationId: number
): Promise<Variation> {
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<Record<string, any>> {
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<string, any>;
}
async getOrders(site: any | number, page: number = 1, pageSize: number = 100): Promise<any> {
// 如果传入的是站点ID则获取站点配置
const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site;
const api = this.createApi(siteConfig, 'wc/v3');
return await this.sdkGetPage<Record<string, any>>(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<any> {
// 如果传入的是站点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<Record<string, any>>(api, 'subscriptions', { page, per_page: pageSize });
}
async getOrderRefund(
siteId: string,
orderId: string,
refundId: number
): Promise<Record<string, any>> {
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<string, any>;
}
async getOrderRefunds(
site: any | string,
orderId: number,
page: number = 1,
pageSize: number = 100
): Promise<any> {
// 如果传入的是站点ID则获取站点配置
const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site;
const api = this.createApi(siteConfig, 'wc/v3');
return await this.sdkGetPage<Record<string, any>>(api, `orders/${orderId}/refunds`, { page, per_page: pageSize });
}
async getOrderNote(
siteId: string,
orderId: number,
noteId: number
): Promise<Record<string, any>> {
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<string, any>;
}
async getOrderNotes(
site: any | string,
orderId: number,
page: number = 1,
pageSize: number = 100
): Promise<any> {
// 如果传入的是站点ID则获取站点配置
const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site;
const api = this.createApi(siteConfig, 'wc/v3');
return await this.sdkGetPage<Record<string, any>>(api, `orders/${orderId}/notes`, { page, per_page: pageSize });
}
/**
* 创建 WooCommerce 产品
* @param site 站点配置
* @param data 产品数据
*/
async createProduct(
site: any,
data: any
): Promise<any> {
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<any> {
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<boolean> {
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<boolean> {
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<boolean> {
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<UpdateVariationDTO>
): Promise<boolean> {
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<string, any>
): Promise<boolean> {
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<string, any>
) {
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<boolean> {
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/<order_id>/shipment-trackings/<tracking_id>
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<any> {
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<any[]> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<any>(api, 'products/categories');
}
/**
* 批量处理产品分类
* @param site 站点配置
* @param data { create?: [], update?: [], delete?: [] }
*/
async batchProcessCategories(
site: any,
data: { create?: any[]; update?: any[]; delete?: any[] }
): Promise<any> {
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<any[]> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<any>(api, 'products/tags');
}
/**
* 批量处理产品标签
* @param site 站点配置
* @param data { create?: [], update?: [], delete?: [] }
*/
async batchProcessTags(
site: any,
data: { create?: any[]; update?: any[]; delete?: any[] }
): Promise<any> {
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<any> {
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<any> {
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<any> {
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<any>(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 }[];
}
}