首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >WordPress 通过 REST API 接 ERP/CRM:鉴权、字段映射与失败重试怎么设计

WordPress 通过 REST API 接 ERP/CRM:鉴权、字段映射与失败重试怎么设计

原创
作者头像
用户10345225
发布于 2026-10-24 14:00:57
发布于 2026-10-24 14:00:57
290
举报

把 WordPress 接到 ERP 或 CRM,难点不在「发一个 HTTP 请求」,而在三个地方:谁能调、字段怎么对、失败之后怎么办。这篇按这三块给一套可以直接落地的做法。

先定两个边界,不定清楚后面全是返工:

  1. 方向:是 WordPress 主动推给 ERP(订单、表单、会员),还是 ERP 主动拉/推给 WordPress(库存、价格、发货状态)。两者鉴权和重试策略不一样。
  2. 语义:是「同步」还是「事件通知」。同步要求当场返回结果,事件通知允许失败后重试。绝大多数对接应该按后者设计。

一、鉴权:机器对机器,别用用户密码

三种可选方案,按场景选:

方案

适用

注意

Application Passwords(HTTP Basic over HTTPS)

内部工具、临时脚本、人工调试

必须全程 HTTPS;权限跟对应用户走,别用管理员账号

HMAC 签名(共享密钥 + 时间戳)

服务端到服务端,高频调用

密钥不落数据库,放 wp-config.php 或环境变量

OAuth 2 / 平台自带的令牌机制

对接的是第三方 SaaS,对方要求

令牌刷新与过期处理要单独写

服务端到服务端推荐 HMAC:它不依赖 WordPress 用户体系,也不用担心密码轮换。下面是接收端(WordPress)的完整实现。

代码语言:php
复制
// wp-config.php 里定义,不要写进数据库
define( 'AUMERP_SHARED_SECRET', getenv( 'AUMERP_SHARED_SECRET' ) );
代码语言:php
复制
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 验证签名是否对得上:

代码语言:bash
复制
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 存储

类型/单位

转换规则

外部单号

so_no

_erp_order_no (post meta)

string

唯一键,原样存

金额

total

_order_total_cents

int,分

不转浮点,全程整数分

下单时间

created_at

_erp_created_at

string

统一 UTC,RFC3339

状态

status

_erp_status

enum

走状态映射表

收货地址

嵌套对象

拍平为多个 meta

—

统一键名,不用数组

四条硬规则:

  1. 金额一律用整数最小单位(分)。浮点数在比较和累加时会出现 12900 != 12900.0000001 这种问题。
  2. 时间统一存 UTC,格式统一 RFC3339,展示时再按站点时区转。别在两套系统之间传本地时间字符串。
  3. 空值区分 null 和空字符串。null 表示「没这个值」,'' 表示「值就是空的」。在映射层显式决定,不要靠 empty() 兜底。
  4. 外部主键单独存一份。用外部 ID 反查时,meta_query 在数据量大时很慢,建一张映射表或者把外部 ID 放进一张带唯一索引的表里。

状态映射单独做一张表,不要用 switch 散落在各处:

代码语言:php
复制
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,接收方用唯一索引抢占。

代码语言:sql
复制
CREATE TABLE wp_aumerp_seen (
  idem_key   VARCHAR(191) NOT NULL,
  created_at DATETIME     NOT NULL,
  PRIMARY KEY (idem_key)
) DEFAULT CHARSET=utf8mb4;
代码语言:php
复制
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,以及幂等冲突(重复请求)

标记完成

代码语言:php
复制
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,进程一挂就全丢。落一张队列表:

代码语言:sql
复制
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 逻辑:

代码语言:php
复制
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:

代码语言:bash
复制
* * * * * /usr/local/bin/wp --path=/var/www/html eval 'aumerp_run_queue_batch(50);' --quiet

五、可观测性:出问题时你要能回答三个问题

第一条失败的任务是什么、为什么失败、重试了几次。这要求每次投递都落一条结构化日志:请求 ID、幂等键、HTTP 状态码、耗时、响应体前 500 字。用 error_log 打 JSON 就够了,别把整个响应体写进去。

再加一层对账:每天拉一次 ERP 的变更列表,和 WordPress 侧比对数量与关键字段,差异写进报表。重试机制解决的是「最终送达」,对账解决的是「有没有悄悄丢」——这两件事不能互相替代。

六、上线前清单

  • 明确方向与语义:推送还是拉取,同步还是事件
  • 密钥放环境变量,不进数据库、不进代码仓库
  • 签名覆盖「时间戳 + 原始请求体」,含时间窗防重放
  • 字段映射表三方签字,金额用整数分、时间用 UTC RFC3339
  • 状态映射表显式定义,未知状态报错而非落默认值
  • 每条请求带稳定幂等键,接收方用唯一索引去重
  • 错误已分类,4xx 进死信、5xx 与网络错误退避重试
  • 队列有上限、有死信、有卡死回收
  • 调度用系统 cron,不依赖 WP-Cron
  • 有结构化日志,有每日对账

如果这套对接要交给外部做,我们的定制开发走「客户填需求表 → 报价 → 付 50% 开工 → 验收付尾款」的流程,范围与流程见 aumcreate.com/services。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、鉴权:机器对机器,别用用户密码
  • 二、字段映射:先写表,再写代码
  • 三、幂等:重试的前提
  • 四、失败重试:先分类,再退避
  • 五、可观测性:出问题时你要能回答三个问题
  • 六、上线前清单
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档