回调优化

This commit is contained in:
liu
2026-09-04 23:30:13 +08:00
parent 968054b9ff
commit 17c1281073
7 changed files with 119 additions and 43 deletions
File diff suppressed because one or more lines are too long
@@ -105,20 +105,20 @@ class OnlinePaymentController extends BaseMiniController
}
/**
* 旺铺支付结果后台通知(公开路由,解密信封后幂等结账)
* 旺铺支付结果后台通知(公开路由,MD5 验签后幂等结账)
*
* 通知报文与网关应答同为加密信封(data + signature,商户私钥可解)
* 解密成功即视为合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
* 通知报文为明文表单(不涉及加解密),sign 之外非空数据元 ASCII 升序拼接 + 专用加签Key 做 MD5
* 验签通过即视为核心平台合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
*/
#[PostRoute('/payment/notify', authorize: false)]
public function notify(Request $request): JsonResponse
{
try {
$params = $this->wangpu->decryptNotify($request->all());
$params = $this->wangpu->verifyNotify($request->all());
} catch (Throwable $e) {
Log::warning('旺铺支付通知解密失败', ['error' => $e->getMessage()]);
return $this->notifyAck('01', '报文解密失败');
Log::warning('旺铺支付通知验签失败', ['error' => $e->getMessage()]);
return $this->notifyAck('01', '报文验签失败');
}
try {
+4 -2
View File
@@ -160,8 +160,10 @@ class OnlinePaymentService
$payment->pay_params = $gatewayData;
$payment->save();
$wxjsapistr = $gatewayData['wxjsapistr'] ?? '{}';
$wxjsapiData = json_decode($wxjsapistr);
// 调起支付参数:旺铺渠道在应答 wxjsapistr(JSON 串)中;微信渠道下单应答本身即调起参数
$wxjsapiData = $channel === self::CHANNEL_WANGPU
? json_decode((string) ($gatewayData['wxjsapistr'] ?? '{}'))
: $gatewayData;
return [$payment, $wxjsapiData];
}
+36 -9
View File
@@ -14,7 +14,11 @@ use Illuminate\Support\Str;
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature
* - 整体以 JSON 信封 POST{serialNo, version, timestamp, data, signature, extras, organizNo}
* - 应答/通知反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
* 应答报文反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
*
* 支付/退款后台通知为明文表单报文,不涉及加解密,仅 MD5 验签:
* sign 之外所有非空数据元按名称 ASCII 升序拼成 key=value&... 串,末尾拼接 &key=加签Key
* MD5utf-8)后转大写与报文 sign 比对,一致即视为核心平台合法通知。
*
* 配置优先级:后台「系统设置 → 旺铺支付」(site_config wangpu.*) > config/services.phpenv
*/
@@ -68,20 +72,43 @@ class WangpuPayService
}
/**
* 解密支付结果后台通知(报文 RSA+AES 双重加密,解密成功即视为合法通知)
* 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知)
*
* @param array<string, mixed> $envelope 通知信封(含 data/signature
* @return array<string, mixed> 解密后的业务报文(mer_order_id/order_status/order_amt/trade_no 等)
* @throws RepositoryException 报文解密失败
* @param array<string, mixed> $params 通知表单报文(含 sign
* @return array<string, mixed> 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等)
* @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败
*/
public function decryptNotify(array $envelope): array
public function verifyNotify(array $params): array
{
Log::driver('pay')->info('旺铺支付通知接收', ['envelope' => $envelope]);
$params = $this->decryptEnvelope($envelope);
Log::driver('pay')->info('旺铺支付通知解密', ['params' => $params]);
Log::driver('pay')->info('旺铺支付通知接收', ['params' => $params]);
$sign = strtoupper(trim((string) ($params['sign'] ?? '')));
if ($sign === '' || ! hash_equals($this->sign($params), $sign)) {
Log::driver('pay')->warning('旺铺支付通知验签失败', ['params' => $params]);
throw new RepositoryException('通知报文验签失败');
}
return $params;
}
/**
* 通知报文 MD5 加签:剔除 sign 与空值 → 按参数名 ASCII 升序拼 key=value&... → 末尾拼 &key=加签Key → MD5 大写
*
* @param array<string, mixed> $params 通知报文
* @throws RepositoryException 加签Key未配置
*/
protected function sign(array $params): string
{
unset($params['sign']);
$params = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== '');
ksort($params, SORT_STRING);
$str = implode('&', array_map(static fn (string $key, mixed $value): string => $key . '=' . $value, array_keys($params), $params));
$signKey = $this->config('sign_key');
if ($signKey === '') {
throw new RepositoryException('旺铺支付未配置通知加签Key,请联系管理员');
}
return strtoupper(md5($str . '&key=' . $signKey));
}
/**
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
*/
+1
View File
@@ -82,6 +82,7 @@ return [
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(小程序主扫必填)
'mp_payway_code' => env('WANGPU_MP_PAYWAY_CODE', ''), // 公众号场景支付方式代码(留空回退 payway_code
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 支付/退款后台通知 MD5 专用加签Key(旺铺分配)
],
/*
+1
View File
@@ -45,6 +45,7 @@ class SysDataSeeder extends Seeder
['id' => 18, 'group_id' => 5, 'key' => 'sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appidsub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
['id' => 19, 'group_id' => 5, 'key' => 'payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
['id' => 34, 'group_id' => 5, 'key' => 'mp_payway_code', 'title' => '旺铺公众号支付方式代码', 'describe' => '公众号 H5 场景支付方式代码 payway_code(如 WECHAT_JSPAY),留空则回退小程序支付方式代码', 'values' => 'WECHAT_JSPAY', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
['id' => 35, 'group_id' => 5, 'key' => 'sign_key', 'title' => '旺铺通知加签Key', 'describe' => '支付/退款后台通知 MD5 专用加签Key(旺铺分配),用于校验通知报文签名 sign,请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
['id' => 20, 'group_id' => 5, 'key' => 'private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
// 微信官方支付(wxpay):微信支付 APIv3
['id' => 22, 'group_id' => 6, 'key' => 'mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
+70 -25
View File
@@ -17,7 +17,8 @@ use ReflectionMethod;
* 小程序在线支付(旺铺网关行业版):下单 → 调起支付 → 后台通知/主动查询结账
*
* 通讯协议与官方示例 demo/IndexController.php 一致:
* 业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输
* 下单/查询的业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输
* 支付结果后台通知为明文表单报文,仅 MD5 验签(ASCII 升序拼接 + 专用加签Key)。
*
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
@@ -44,6 +45,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'services.wangpu.public_key' => self::TEST_PUBLIC_KEY,
'services.wangpu.private_key' => self::TEST_PRIVATE_KEY,
'services.wangpu.payway_code' => 'WECHAT_MINI',
'services.wangpu.sign_key' => 'notify-test-key',
]);
}
@@ -106,6 +108,14 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'order_id' => 'WP202608270001',
'tradeNo' => 'T20260827001',
'user_openid' => $openid,
'wxjsapistr' => (string) json_encode([
'appId' => 'wx-mini-test',
'timeStamp' => '1724745600',
'nonceStr' => 'nonce001',
'package' => 'prepay_id=wp-prepay-001',
'signType' => 'RSA',
'paySign' => 'sign001',
]),
]),
200
),
@@ -151,10 +161,10 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
return $payment;
}
/** 构造加密的支付成功通知信封 */
private function encryptedNotifyParams(PaymentModel $payment, array $overrides = []): array
/** 构造带 MD5 签名的支付成功通知表单报文(加签算法与官方文档一致,测试侧独立实现) */
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
{
return $this->gatewayEnvelope(array_merge([
$params = array_merge([
'mer_order_id' => $payment->payment_no,
'order_status' => '1',
'order_amt' => (string) $payment->amount,
@@ -166,7 +176,13 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'mer_no' => 'mer001',
'device_no' => 'dev001',
'order_title' => '账单合并付款',
], $overrides));
], $overrides);
$signParams = array_filter($params, static fn ($value): bool => $value !== null && $value !== '');
ksort($signParams, SORT_STRING);
$str = implode('&', array_map(static fn ($k, $v) => $k . '=' . $v, array_keys($signParams), $signParams));
$params['sign'] = strtoupper(md5($str . '&key=' . config('services.wangpu.sign_key')));
return $params;
}
/** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */
@@ -186,19 +202,41 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
);
}
/** 信封加解密回环:网关侧加密(demo 算法)→ 服务解密还原业务报文 */
public function test_notify_envelope_round_trip(): void
/** 通知 MD5 验签 golden test:官方文档示例报文 + SignKey 直算签名一致(含中文、空值剔除) */
public function test_notify_sign_matches_doc_golden(): void
{
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
$params = [
'mer_order_id' => 'ZF202608270001',
'mer_order_id' => 'CBC92E5GTL000083202004121010143',
'trade_no' => '11420200410120144102483',
'mer_no' => '2001071119360E5Riu',
'order_amt' => '0.01',
'payway_code' => 'QR_WECHAT_BARPAY',
'order_id' => '202004101201444525348059',
'order_status' => '1',
'order_amt' => '150.50',
'order_title' => '账单合并付款', // 中文报文
'order_title' => '住宿酒店',
'mer_code' => 'W00000000001381',
'device_no' => 'CBC92E5GTL000083',
'order_time' => '2020-04-10 12:01:44',
'trade_time' => '2020-04-10 12:01:47',
'gateway_mer_order_id' => '2020041012014445269',
'fee' => '', // 空值不参与签名(平台不下发空值数据元)
'sign' => 'A31998F2E0549E0A80B2A4B3A0473784', // 文档示例签名值
];
$decrypted = app(WangpuPayService::class)->decryptNotify($this->gatewayEnvelope($params));
$verified = app(WangpuPayService::class)->verifyNotify($params);
$this->assertSame($params, $decrypted);
$this->assertSame($params, $verified);
}
/** 通知验签失败(签名缺失/错误)抛异常 */
public function test_notify_verify_rejects_bad_sign(): void
{
$service = app(WangpuPayService::class);
$this->expectException(RepositoryException::class);
$service->verifyNotify(['mer_order_id' => 'ZF202608270001', 'sign' => 'INVALIDSIGN']);
}
/** 密钥配置错误:公钥栏误填私钥时给出明确中文报错(而非 openssl 警告) */
@@ -228,7 +266,9 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$paymentNo = $response->json('data.payment_no');
$this->assertSame('150.50', $response->json('data.amount'));
$this->assertSame('WP202608270001', $response->json('data.pay_params.order_id'));
// 调起支付参数取自网关应答 wxjsapistr(小程序 wx.requestPayment 直接透传)
$this->assertSame('wx-mini-test', $response->json('data.pay_params.appId'));
$this->assertSame('prepay_id=wp-prepay-001', $response->json('data.pay_params.package'));
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
@@ -336,7 +376,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
}
/** 支付成功通知:解密信封 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
/** 支付成功通知:验签 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
public function test_notify_settles_payment(): void
{
$store = StoreModel::factory()->create();
@@ -344,7 +384,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$bill2 = $this->makeBill($store, '50.00');
$payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2);
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
->assertJsonPath('code', '00');
$payment->refresh();
@@ -366,34 +406,39 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
);
// 重复通知幂等:仍应答成功,金额不重复累加
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
->assertJsonPath('code', '00');
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
$this->assertSame(1, NoticeModel::where('store_id', $store->id)->count());
}
/** 通知解密失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
/** 通知验签失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
public function test_notify_rejects_invalid_messages(): void
{
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '100.00');
$payment = $this->makeOnlinePayment($store, '100.00', $bill);
// 信封 signature 非法 → 解密失败
$badEnvelope = $this->encryptedNotifyParams($payment);
$badEnvelope['signature'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badEnvelope)->assertJsonPath('code', '01');
// 签名非法 → 验签失败
$badSign = $this->signedNotifyParams($payment);
$badSign['sign'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
// 金额不一致(防篡改)
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_amt' => '99.99']))
// 篡改金额后未重签 → 验签失败
$tampered = $this->signedNotifyParams($payment);
$tampered['order_amt'] = '99.99';
$this->postJson('/mini/payment/notify', $tampered)->assertJsonPath('code', '01');
// 金额不一致(防篡改,签名正确但金额与支付单不符)
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
->assertJsonPath('code', '01');
// 订单号不存在
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
->assertJsonPath('code', '01');
// 非支付成功状态
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_status' => '0']))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
->assertJsonPath('code', '01');
// 均未结账