优化推送订单功能
This commit is contained in:
328
app/process/AngpowImportJobs.php
Normal file
328
app/process/AngpowImportJobs.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\model\MallItem;
|
||||
use app\common\model\MallOrder;
|
||||
use app\common\model\MallUserAsset;
|
||||
use GuzzleHttp\Client;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* Angpow 导入推送任务
|
||||
* - 数据源:mall_order(type=BONUS)
|
||||
* - 推送频率:每 30 秒
|
||||
* - 批量:每次最多 100 条(对方文档限制)
|
||||
* - 幂等:merchant_code + report_date 级别签名;每条订单通过 external_transaction_id 在本地控制只推送一次
|
||||
*/
|
||||
class AngpowImportJobs
|
||||
{
|
||||
private const TIMER_SECONDS = 30;
|
||||
private const BATCH_LIMIT = 100;
|
||||
private const MAX_RETRY = 3;
|
||||
|
||||
protected Client $http;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 确保定时任务只在一个 worker 上运行
|
||||
if (!Worker::getAllWorkers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->http = new Client($this->buildGuzzleOptions());
|
||||
|
||||
Timer::add(self::TIMER_SECONDS, [$this, 'pushPendingOrders']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guzzle 默认校验 HTTPS;Windows 未配置 CA 时会出现 cURL error 60。
|
||||
* 优先使用 PLAYX_ANGPOW_IMPORT_CACERT 指向 cacert.pem;否则可按环境关闭校验(仅开发)。
|
||||
*/
|
||||
private function buildGuzzleOptions(): array
|
||||
{
|
||||
$options = [
|
||||
'timeout' => 20,
|
||||
'http_errors' => false,
|
||||
];
|
||||
|
||||
$conf = config('playx.angpow_import');
|
||||
if (!is_array($conf)) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
$caFile = $conf['ca_file'] ?? '';
|
||||
if (is_string($caFile) && $caFile !== '' && is_file($caFile)) {
|
||||
$options['verify'] = $caFile;
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
$verifySsl = $conf['verify_ssl'] ?? true;
|
||||
if ($verifySsl === false) {
|
||||
$options['verify'] = false;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function pushPendingOrders(): void
|
||||
{
|
||||
$conf = config('playx.angpow_import');
|
||||
if (!is_array($conf)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$baseUrl = $conf['base_url'] ?? '';
|
||||
$path = $conf['path'] ?? '';
|
||||
$merchantCode = $conf['merchant_code'] ?? '';
|
||||
$authKey = $conf['auth_key'] ?? '';
|
||||
|
||||
if (!is_string($baseUrl) || $baseUrl === '' || !is_string($path) || $path === '' || !is_string($merchantCode) || $merchantCode === '') {
|
||||
return;
|
||||
}
|
||||
if (!is_string($authKey) || $authKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = rtrim($baseUrl, '/') . $path;
|
||||
|
||||
$orders = MallOrder::where('type', MallOrder::TYPE_BONUS)
|
||||
->whereIn('grant_status', [MallOrder::GRANT_NOT_SENT, MallOrder::GRANT_FAILED_RETRYABLE])
|
||||
->where('status', MallOrder::STATUS_PENDING)
|
||||
->where('retry_count', '<', self::MAX_RETRY)
|
||||
->order('id', 'asc')
|
||||
->limit(self::BATCH_LIMIT)
|
||||
->select();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reportDate = strval(time());
|
||||
$signatureInput = 'merchant_code=' . $merchantCode . '&report_date=' . $reportDate;
|
||||
$signature = $this->buildSignature($signatureInput, $authKey);
|
||||
if ($signature === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'merchant_code' => $merchantCode,
|
||||
'report_date' => $reportDate,
|
||||
'angpow' => [],
|
||||
'currency_visual' => [
|
||||
[
|
||||
'currency' => strval($conf['currency'] ?? 'MYR'),
|
||||
'visual_name' => strval($conf['visual_name'] ?? 'Angpow'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$orderIds = [];
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$row = $this->buildAngpowRow($order);
|
||||
if ($row === null) {
|
||||
// 构造失败:直接标为可重试失败
|
||||
$this->markFailedAttempt($order, 'Build payload failed');
|
||||
continue;
|
||||
}
|
||||
$payload['angpow'][] = $row;
|
||||
$orderIds[] = $order->id;
|
||||
}
|
||||
|
||||
if (empty($payload['angpow'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先标记“已尝试发送”,避免并发重复推送;同时只在这里累加 retry_count(一次推送=一次尝试)
|
||||
$now = time();
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($order->id, $orderIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$retry = $order->retry_count ?? 0;
|
||||
if (!is_int($retry)) {
|
||||
$retry = is_numeric($retry) ? intval($retry) : 0;
|
||||
}
|
||||
$order->retry_count = $retry + 1;
|
||||
$order->grant_status = MallOrder::GRANT_SENT_PENDING;
|
||||
$order->update_time = $now;
|
||||
$order->save();
|
||||
}
|
||||
|
||||
$res = null;
|
||||
$body = '';
|
||||
try {
|
||||
$res = $this->http->post($url, [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Request-Signature' => $signature,
|
||||
],
|
||||
'json' => $payload,
|
||||
]);
|
||||
$body = strval($res->getBody());
|
||||
} catch (\Throwable $e) {
|
||||
// 网络/异常:对这一批订单记一次失败尝试
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$this->markFailedAttempt($order, $e->getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
if (!is_array($data)) {
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$this->markFailedAttempt($order, 'Invalid response');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$code = $data['code'] ?? null;
|
||||
$message = $data['message'] ?? '';
|
||||
$msg = is_string($message) ? $message : 'Request failed';
|
||||
|
||||
// 成功:code=0
|
||||
if ($code === '0' || $code === 0) {
|
||||
MallOrder::whereIn('id', $orderIds)->update([
|
||||
'grant_status' => MallOrder::GRANT_ACCEPTED,
|
||||
'fail_reason' => null,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 失败:整批视为失败(对方未提供逐条返回)
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$this->markFailedAttempt($order, $msg);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildAngpowRow(MallOrder $order): ?array
|
||||
{
|
||||
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
|
||||
if (!$asset) {
|
||||
if (is_string($order->user_id) && ctype_digit($order->user_id)) {
|
||||
$byId = MallUserAsset::where('id', $order->user_id)->find();
|
||||
if ($byId) {
|
||||
$asset = $byId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$asset || !is_string($asset->playx_user_id ?? null) || strval($asset->playx_user_id) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = null;
|
||||
if ($order->mallItem) {
|
||||
$item = $order->mallItem;
|
||||
} else {
|
||||
$item = MallItem::where('id', $order->mall_item_id)->find();
|
||||
}
|
||||
if (!$item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$createTime = $order->create_time ?? null;
|
||||
if (!is_int($createTime)) {
|
||||
if (is_numeric($createTime)) {
|
||||
$createTime = intval($createTime);
|
||||
} else {
|
||||
$createTime = time();
|
||||
}
|
||||
}
|
||||
|
||||
$start = gmdate('Y-m-d\TH:i:s\Z', $createTime);
|
||||
$end = gmdate('Y-m-d\TH:i:s\Z', $createTime + 86400);
|
||||
|
||||
return [
|
||||
'member_login' => strval($asset->playx_user_id),
|
||||
'start_time' => $start,
|
||||
'end_time' => $end,
|
||||
'amount' => $order->amount,
|
||||
'reward_name' => strval($item->title ?? ''),
|
||||
'description' => strval($item->description ?? ''),
|
||||
'member_inbox_message' => 'Congratulations! You received an angpow.',
|
||||
'category' => strval($item->category ?? ''),
|
||||
'category_title' => strval($item->category_title ?? ''),
|
||||
'one_time_turnover' => 'yes',
|
||||
'multiplier' => $order->multiplier,
|
||||
];
|
||||
}
|
||||
|
||||
private function markFailedAttempt(MallOrder $order, string $reason): void
|
||||
{
|
||||
// retry_count 在“准备发送”阶段已 +1;此处用当前 retry_count 作为 attempt 编号
|
||||
$retryCount = $order->retry_count ?? 0;
|
||||
$attempt = is_int($retryCount) ? $retryCount : (is_numeric($retryCount) ? intval($retryCount) : 0);
|
||||
if ($attempt <= 0) {
|
||||
$attempt = 1;
|
||||
$order->retry_count = 1;
|
||||
}
|
||||
|
||||
$prev = $order->fail_reason;
|
||||
$prefix = 'attempt ' . $attempt . ': ';
|
||||
$line = $prefix . $reason;
|
||||
$newReason = $line;
|
||||
if (is_string($prev) && $prev !== '') {
|
||||
$newReason = $prev . "\n" . $line;
|
||||
}
|
||||
|
||||
$final = $attempt >= self::MAX_RETRY;
|
||||
$order->grant_status = $final ? MallOrder::GRANT_FAILED_FINAL : MallOrder::GRANT_FAILED_RETRYABLE;
|
||||
$order->fail_reason = $newReason;
|
||||
$order->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成对方要求的 Base64(HMAC-SHA1)
|
||||
* - 文档中示例 python 会 base64_decode(key) 后参与 hmac
|
||||
* - 生产 key 由 BA 提供,可能是 base64 或 hex;这里做兼容处理
|
||||
*/
|
||||
private function buildSignature(string $input, string $authKey): ?string
|
||||
{
|
||||
$keyBytes = null;
|
||||
|
||||
$maybeBase64 = base64_decode($authKey, true);
|
||||
if ($maybeBase64 !== false && $maybeBase64 !== '') {
|
||||
$keyBytes = $maybeBase64;
|
||||
}
|
||||
|
||||
if ($keyBytes === null) {
|
||||
$isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
|
||||
if ($isHex) {
|
||||
$hex = hex2bin($authKey);
|
||||
if ($hex !== false && $hex !== '') {
|
||||
$keyBytes = $hex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($keyBytes === null) {
|
||||
$keyBytes = $authKey;
|
||||
}
|
||||
|
||||
$raw = hash_hmac('sha1', $input, $keyBytes, true);
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base64_encode($raw);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user