把 WordPress 接到 ERP 或 CRM,难点不在「发一个 HTTP 请求」,而在三个地方:谁能调、字段怎么对、失败之后怎么办。这篇按这三块给一套可以直接落地的做法。
先定两个边界,不定清楚后面全是返工:
三种可选方案,按场景选:
方案 | 适用 | 注意 |
|---|---|---|
Application Passwords(HTTP Basic over HTTPS) | 内部工具、临时脚本、人工调试 | 必须全程 HTTPS;权限跟对应用户走,别用管理员账号 |
HMAC 签名(共享密钥 + 时间戳) | 服务端到服务端,高频调用 | 密钥不落数据库,放 |
OAuth 2 / 平台自带的令牌机制 | 对接的是第三方 SaaS,对方要求 | 令牌刷新与过期处理要单独写 |
服务端到服务端推荐 HMAC:它不依赖 WordPress 用户体系,也不用担心密码轮换。下面是接收端(WordPress)的完整实现。
// wp-config.php 里定义,不要写进数据库
define( 'AUMERP_SHARED_SECRET', getenv( 'AUMERP_SHARED_SECRET' ) );add_action( 'rest_api_init', function () {
register_rest_route( 'aumerp/v1', '/orders', [
'methods' => 'POST',
'callback' => 'aumerp_handle_order_push',
'permission_callback' => 'aumerp_verify_signature',
] );
} );
/**
* 校验签名:签名内容 = timestamp + "\n" + 原始请求体
*/
function aumerp_verify_signature( WP_REST_Request $request ) {
if ( ! defined( 'AUMERP_SHARED_SECRET' ) || ! AUMERP_SHARED_SECRET ) {
return new WP_Error( 'aumerp_not_configured', 'shared secret missing', [ 'status' => 500 ] );
}
$ts = (int) $request->get_header( 'x-aum-timestamp' );
$sig = (string) $request->get_header( 'x-aum-signature' );
if ( ! $ts || '' === $sig ) {
return new WP_Error( 'aumerp_bad_auth', 'missing signature headers', [ 'status' => 401 ] );
}
// 5 分钟时间窗,用来挡重放
if ( abs( time() - $ts ) > 300 ) {
return new WP_Error( 'aumerp_expired', 'timestamp out of window', [ 'status' => 401 ] );
}
$expected = hash_hmac( 'sha256', $ts . "\n" . $request->get_body(), AUMERP_SHARED_SECRET );
if ( ! hash_equals( $expected, $sig ) ) {
return new WP_Error( 'aumerp_bad_sig', 'signature mismatch', [ 'status' => 401 ] );
}
return true;
}hash_equals 不能换成 ==,它做的是定长比较,避免比较耗时泄露信息。
调用端(ERP/CRM 那一侧)用 shell 验证签名是否对得上:
export AUMERP_SHARED_SECRET='your-secret'
TS=$(date +%s)
BODY='{"external_id":"SO-1001","status":"paid","total_cents":12900}'
SIG=$(printf '%s\n%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$AUMERP_SHARED_SECRET" -hex | awk '{print $2}')
curl -sS -X POST "https://example.com/wp-json/aumerp/v1/orders" \
-H 'Content-Type: application/json' \
-H "X-Aum-Timestamp: $TS" \
-H "X-Aum-Signature: $SIG" \
-H 'X-Idempotency-Key: SO-1001-paid-v1' \
--data-raw "$BODY"注意一个坑:WordPress 收到的 get_body() 是原始字符串,任何中间层(代理、WAF、日志插件)改写请求体都会让签名对不上。排查签名失败时,先确认请求体到达 PHP 时和发出时逐字节一致。
不要一边写代码一边想字段。先出一张映射表,三方(业务、WordPress 侧、ERP 侧)确认后再动手。
业务含义 | ERP 字段 | WordPress 存储 | 类型/单位 | 转换规则 |
|---|---|---|---|---|
外部单号 |
|
| string | 唯一键,原样存 |
金额 |
|
| int,分 | 不转浮点,全程整数分 |
下单时间 |
|
| string | 统一 UTC,RFC3339 |
状态 |
|
| enum | 走状态映射表 |
收货地址 | 嵌套对象 | 拍平为多个 meta | — | 统一键名,不用数组 |
四条硬规则:
12900 != 12900.0000001 这种问题。null 和空字符串。null 表示「没这个值」,'' 表示「值就是空的」。在映射层显式决定,不要靠 empty() 兜底。meta_query 在数据量大时很慢,建一张映射表或者把外部 ID 放进一张带唯一索引的表里。状态映射单独做一张表,不要用 switch 散落在各处:
function aumerp_map_status( string $erp_status ): ?string {
static $map = [
'NEW' => 'pending',
'PAID' => 'processing',
'SHIPPED' => 'shipped',
'CANCELLED' => 'cancelled',
'REFUNDED' => 'refunded',
];
// 不认识的状态必须报错,不能默默落成默认值
return $map[ $erp_status ] ?? null;
}「不认识的状态返回 null 并记录告警」比「默认落到 pending」安全得多——后者会把已取消的订单复活。
没有幂等,重试就是制造重复数据。做法很简单:调用方带一个稳定的 X-Idempotency-Key,接收方用唯一索引抢占。
CREATE TABLE wp_aumerp_seen (
idem_key VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (idem_key)
) DEFAULT CHARSET=utf8mb4;function aumerp_claim_idempotency( string $key ): bool {
global $wpdb;
$table = $wpdb->prefix . 'aumerp_seen';
$affected = $wpdb->query(
$wpdb->prepare(
"INSERT IGNORE INTO {$table} (idem_key, created_at) VALUES (%s, %s)",
$key,
gmdate( 'Y-m-d H:i:s' )
)
);
return 1 === (int) $affected; // false 表示这条已经处理过
}处理逻辑里先抢占,抢不到就直接返回 200 和一个 duplicate: true,让调用方停止重试。别返回 4xx,否则调用方会认为失败继续重发。
错误分三类,处理方式完全不同:
类型 | 例子 | 处理 |
|---|---|---|
不可重试 | 400 参数错误、401 签名错、404 路径错、422 校验失败 | 直接进死信,告警,人工修 |
可重试 | 连接超时、TLS 失败、408、429、5xx | 指数退避后重试 |
已成功 | 2xx,以及幂等冲突(重复请求) | 标记完成 |
function aumerp_should_retry( int $code, bool $is_wp_error ): bool {
if ( $is_wp_error ) {
return true; // 连接失败、超时、DNS,先当作可重试
}
if ( 408 === $code || 429 === $code ) {
return true;
}
return $code >= 500;
}重试不能只靠内存里 sleep,进程一挂就全丢。落一张队列表:
CREATE TABLE wp_aumerp_queue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
endpoint VARCHAR(191) NOT NULL,
payload LONGTEXT NOT NULL,
idempotency_key VARCHAR(191) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
next_run_at DATETIME NOT NULL,
last_error TEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_idem (idempotency_key),
KEY idx_due (status, next_run_at)
) DEFAULT CHARSET=utf8mb4;worker 逻辑:
function aumerp_run_queue_batch( int $limit = 20 ): void {
global $wpdb;
$table = $wpdb->prefix . 'aumerp_queue';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE status = 'pending' AND next_run_at <= %s ORDER BY next_run_at ASC LIMIT %d",
gmdate( 'Y-m-d H:i:s' ),
$limit
)
);
foreach ( $rows as $row ) {
aumerp_deliver( $row );
}
}
function aumerp_deliver( object $row ): void {
global $wpdb;
$table = $wpdb->prefix . 'aumerp_queue';
// 抢占,避免多个 worker 同时投递同一条
$claimed = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'sending' WHERE id = %d AND status = 'pending'",
$row->id
)
);
if ( 1 !== (int) $claimed ) {
return;
}
$response = wp_remote_post( $row->endpoint, [
'timeout' => 15,
'headers' => aumerp_signed_headers( $row->payload, $row->idempotency_key ),
'body' => $row->payload,
] );
$is_wp_error = is_wp_error( $response );
$code = $is_wp_error ? 0 : (int) wp_remote_retrieve_response_code( $response );
$detail = $is_wp_error ? $response->get_error_message() : wp_remote_retrieve_body( $response );
if ( ! $is_wp_error && $code >= 200 && $code < 300 ) {
$wpdb->update( $table, [ 'status' => 'done', 'last_error' => null ], [ 'id' => $row->id ] );
return;
}
$attempts = (int) $row->attempts + 1;
if ( ! aumerp_should_retry( $code, $is_wp_error ) || $attempts >= 8 ) {
$wpdb->update( $table, [
'status' => 'dead',
'attempts' => $attempts,
'last_error' => mb_substr( (string) $detail, 0, 1000 ),
], [ 'id' => $row->id ] );
return;
}
// 指数退避 + 抖动,上限 1 小时;抖动避免所有任务同一秒涌出
$delay = min( 3600, 30 * ( 2 ** min( $attempts, 7 ) ) ) + random_int( 0, 30 );
$wpdb->update( $table, [
'status' => 'pending',
'attempts' => $attempts,
'next_run_at' => gmdate( 'Y-m-d H:i:s', time() + $delay ),
'last_error' => mb_substr( (string) $detail, 0, 1000 ),
], [ 'id' => $row->id ] );
}
function aumerp_signed_headers( string $body, string $idempotency_key ): array {
$ts = (string) time();
return [
'Content-Type' => 'application/json',
'X-Aum-Timestamp' => $ts,
'X-Aum-Signature' => hash_hmac( 'sha256', $ts . "\n" . $body, AUMERP_SHARED_SECRET ),
'X-Idempotency-Key' => $idempotency_key,
];
}两个细节别省:429 要读 Retry-After 响应头,对方明确说了等多久就按它来;worker 要能回收卡在 sending 状态的行,加一条「超过 10 分钟仍是 sending 的重置为 pending」,否则进程被杀一次就永久卡住。
调度别用 WP-Cron,它是「有人访问站点才触发」的,低频站点上会拖到不可用。用系统 cron:
* * * * * /usr/local/bin/wp --path=/var/www/html eval 'aumerp_run_queue_batch(50);' --quiet第一条失败的任务是什么、为什么失败、重试了几次。这要求每次投递都落一条结构化日志:请求 ID、幂等键、HTTP 状态码、耗时、响应体前 500 字。用 error_log 打 JSON 就够了,别把整个响应体写进去。
再加一层对账:每天拉一次 ERP 的变更列表,和 WordPress 侧比对数量与关键字段,差异写进报表。重试机制解决的是「最终送达」,对账解决的是「有没有悄悄丢」——这两件事不能互相替代。
如果这套对接要交给外部做,我们的定制开发走「客户填需求表 → 报价 → 付 50% 开工 → 验收付尾款」的流程,范围与流程见 aumcreate.com/services。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。