支付优化

This commit is contained in:
liu
2026-09-01 12:47:02 +08:00
parent 95ea0714ad
commit d190bdaade
11 changed files with 342 additions and 633 deletions
File diff suppressed because one or more lines are too long
@@ -97,19 +97,20 @@ class OnlinePaymentController extends BaseMiniController
}
/**
* 旺铺支付结果后台通知(公开路由,验签后幂等结账)
* 旺铺支付结果后台通知(公开路由,解密信封后幂等结账)
*
* 网关约定:应答 {"code":"00"} 视为通知成功,否则按 2^n 分钟重试 7 次;
* 通知报文与网关应答同为加密信封(data + signature,商户私钥可解),
* 解密成功即视为合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
*/
#[PostRoute('/payment/notify', authorize: false)]
public function notify(Request $request): JsonResponse
{
$params = $request->all();
if (! $this->wangpu->verifyNotifySign($params)) {
Log::warning('旺铺支付通知验签失败', ['params' => $params]);
return $this->notifyAck('01', '验签失败');
try {
$params = $this->wangpu->decryptNotify($request->all());
} catch (Throwable $e) {
Log::warning('旺铺支付通知解密失败', ['error' => $e->getMessage()]);
return $this->notifyAck('01', '报文解密失败');
}
try {
+178 -60
View File
@@ -5,13 +5,16 @@ namespace App\Services;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 旺铺支付网关服务(统一下单B-JSAPI / 交易查询 / 后台通知验签
* 旺铺支付网关服务(行业版接口:统一下单 / 交易查询 / 后台通知解密
*
* 加签规则(请求与回调一致):
* 除 sign 外所有非空参数按参数名 ASCII 码升序排列,以 key=value 形式用 & 拼接成 Str
* 在 Str 后拼接 &key={SignKey} 得到 SignStr,对其做 MD5(utf-8)并转大写即为签名值。
* 通讯协议(与官方示例 demo/IndexController.php 一致):
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature
* - 整体以 JSON 信封 POST{serialNo, version, timestamp, data, signature, extras, organizNo}
* - 应答/通知反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
*
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.phpenv
*/
@@ -27,18 +30,20 @@ class WangpuPayService
public const int ORDER_STATUS_PAID = 1;
/**
* 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码
* 统一下单(小程序 JSAPI
*
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
* @return array<string, mixed> 网关 data 节点(含 order_id/trade_no/调起支付参数)
* @return array<string, mixed> 网关 data 解密后的业务报文(含 order_id/调起支付参数)
* @throws RepositoryException 网关应答失败
*/
public function createOrder(array $params): array
{
return $this->request('/industrial/payment/order', array_merge([
'mer_no' => $this->config('mer_no'),
'mer_code' => $this->config('mer_code'),
'term_code' => $this->config('term_code'),
'payway_code' => $this->config('payway_code'),
'organiz_no' => $this->config('organiz_no'),
], $params));
}
@@ -47,12 +52,15 @@ class WangpuPayService
*
* @param string $merOrderId 商户唯一订单号(本系统 payment_no
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
* @return array<string, mixed> 网关 data 节点
* @return array<string, mixed> 网关 data 解密后的业务报文
* @throws RepositoryException 网关应答失败
*/
public function queryOrder(string $merOrderId, string $orderId = ''): array
{
$params = ['mer_order_id' => $merOrderId];
$params = [
'organiz_no' => $this->config('organiz_no'),
'mer_order_id' => $merOrderId,
];
if ($orderId !== '') {
$params['order_id'] = $orderId;
}
@@ -60,44 +68,18 @@ class WangpuPayService
}
/**
* 校验后台通知签名(支付成功/退款成功通知通用
* 解密支付结果后台通知(报文 RSA+AES 双重加密,解密成功即视为合法通知
*
* @param array<string, mixed> $params 通知报文全量参数(含 sign
* @param array<string, mixed> $envelope 通知信封(含 data/signature
* @return array<string, mixed> 解密后的业务报文(mer_order_id/order_status/order_amt/trade_no 等)
* @throws RepositoryException 报文解密失败
*/
public function verifyNotifySign(array $params): bool
public function decryptNotify(array $envelope): array
{
$sign = (string) ($params['sign'] ?? '');
if ($sign === '') {
return false;
}
return hash_equals($this->sign($params), strtoupper($sign));
}
/**
* MD5 加签:非空参数(sign 除外)按参数名 ASCII 升序 key=value 以 & 拼接,
* 末尾拼接 &key={SignKey} 后 MD5 转大写
*
* @param array<string, mixed> $params
*/
public function sign(array $params): string
{
unset($params['sign']);
$pairs = [];
foreach ($params as $key => $value) {
if ($value === null || $value === '') {
continue;
}
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$pairs[(string) $key] = (string) $key . '=' . (string) $value;
}
ksort($pairs, SORT_STRING);
$signStr = implode('&', $pairs) . '&key=' . $this->config('sign_key');
return strtoupper(md5($signStr));
Log::driver('pay')->info('旺铺支付通知接收', ['envelope' => $envelope]);
$params = $this->decryptEnvelope($envelope);
Log::driver('pay')->info('旺铺支付通知解密', ['params' => $params]);
return $params;
}
/**
@@ -109,11 +91,11 @@ class WangpuPayService
}
/**
* 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
*
* @param array<string, mixed> $params 接口业务参数
* @return array<string, mixed> 应答 data 节点
* @throws RepositoryException 通讯失败应答码非成功
* @return array<string, mixed> 应答 data 解密后的业务报文
* @throws RepositoryException 通讯失败 / 应答码非成功 / 应答解密失败
*/
protected function request(string $path, array $params): array
{
@@ -122,24 +104,18 @@ class WangpuPayService
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
}
$body = array_merge([
'organiz_no' => $this->config('organiz_no'),
'mer_no' => $this->config('mer_no'),
], $params);
// 空值参数不下送(与网关签名口径一致)
$body = array_filter($body, static fn ($value): bool => $value !== null && $value !== '');
$body['sign'] = $this->sign($body);
$envelope = $this->encryptEnvelope($params);
Log::driver('pay')->info('旺铺网关请求', ['path' => $path, 'params' => $params, 'envelope' => $envelope]);
$response = Http::timeout(15)->post($baseUrl . $path, $envelope);
Log::driver('pay')->info('旺铺网关响应', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
$response = Http::asForm()->withOptions([
'verify' => false, // 禁用 SSL 证书验证
])->timeout(15)->post($baseUrl . $path, $body);
$result = $response->json();
if (! is_array($result)) {
Log::driver('pay')->error('旺铺网关应答异常', [
'path' => $path,
'status' => $response->status(),
'body' => $response->body(),
'request' => $body,
]);
throw new RepositoryException('支付网关通讯异常,请稍后重试');
}
@@ -147,13 +123,155 @@ class WangpuPayService
Log::driver('pay')->warning('旺铺网关应答失败', [
'path' => $path,
'response' => $result,
'request' => $body,
]);
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
}
$data = $result['data'] ?? [];
return is_array($data) ? $data : [];
try {
$data = $this->decryptEnvelope($result);
} catch (RepositoryException $e) {
Log::driver('pay')->error('旺铺网关应答解密失败', ['path' => $path, 'error' => $e->getMessage()]);
throw new RepositoryException('支付网关应答解密失败,请联系管理员');
}
Log::driver('pay')->info('旺铺网关应答解密', ['path' => $path, 'data' => $data]);
return $data;
}
/**
* 业务报文加密装信封:AES-128-ECB 加密报文 + 平台公钥加密 AES 密钥
*
* @param array<string, mixed> $params 业务参数
* @return array<string, string> 请求信封
* @throws RepositoryException 加密失败
*/
protected function encryptEnvelope(array $params): array
{
$key = Str::random(16); // AES 密钥(16 字节)
return [
'serialNo' => Str::random(32), // 请求流水号
'version' => '1.0',
'timestamp' => now()->format('YmdHis'),
'data' => urlencode($this->aesEncrypt($params, $key)),
'signature' => urlencode($this->rsaPublicEncrypt($key)),
'extras' => '',
'organizNo' => $this->config('organiz_no'),
];
}
/**
* 解密网关信封:商户私钥解 signature 得 AES 密钥 → AES 解 data → JSON
*
* @param array<string, mixed> $envelope
* @return array<string, mixed>
* @throws RepositoryException 解密失败
*/
protected function decryptEnvelope(array $envelope): array
{
$data = (string) ($envelope['data'] ?? '');
$signature = (string) ($envelope['signature'] ?? '');
if ($data === '' || $signature === '') {
throw new RepositoryException('报文缺少 data/signature 字段');
}
$plain = $this->aesDecrypt($data, $this->rsaPrivateDecrypt($signature));
$decoded = json_decode($plain, true);
if (! is_array($decoded)) {
throw new RepositoryException('报文 JSON 解析失败');
}
return $decoded;
}
/**
* AES-128-ECB 加密(base64 输出,与官方示例算法一致)
*
* @param array<string, mixed> $data
* @throws RepositoryException 加密失败
*/
protected function aesEncrypt(array $data, string $key): string
{
$cipher = openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
if ($cipher === false) {
throw new RepositoryException('报文 AES 加密失败');
}
return base64_encode($cipher);
}
/**
* AES-128-ECB 解密(base64 输入,与官方示例算法一致)
*
* @throws RepositoryException 解密失败
*/
protected function aesDecrypt(string $cipherBase64, string $key): string
{
$plain = openssl_decrypt((string) base64_decode($cipherBase64), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
if (! is_string($plain)) {
throw new RepositoryException('报文 AES 解密失败');
}
return $plain;
}
/**
* RSA 平台公钥加密(base64 输出,用于加密 AES 密钥)
*
* @throws RepositoryException 加密失败
*/
protected function rsaPublicEncrypt(string $data): string
{
if (! openssl_public_encrypt($data, $encrypted, $this->pemKey('public_key', 'PUBLIC'))) {
throw new RepositoryException('旺铺平台公钥加密失败,请检查密钥配置');
}
return base64_encode($encrypted);
}
/**
* RSA 商户私钥解密(base64 输入,用于解出 AES 密钥)
*
* @throws RepositoryException 解密失败
*/
protected function rsaPrivateDecrypt(string $cipherBase64): string
{
$ok = openssl_private_decrypt((string) base64_decode($cipherBase64), $decrypted, $this->pemKey('private_key', 'PRIVATE'));
if (! $ok || ! is_string($decrypted)) {
throw new RepositoryException('商户私钥解密失败,请检查密钥配置');
}
return $decrypted;
}
/**
* 读取密钥并包装为 PEM 格式(配置存 base64 单行或完整 PEM 均可,自动剔除头尾与空白字符)
*
* 预检密钥有效性(避免 openssl 抛警告式错误),并识别公/私钥填反的常见配置错误
*
* @throws RepositoryException 未配置或密钥无法解析
*/
protected function pemKey(string $configKey, string $kind): string
{
$name = $kind === 'PUBLIC' ? '旺铺平台公钥' : '商户RSA私钥';
$body = (string) preg_replace('/-----[A-Z ]*KEY-----|\s+/', '', $this->config($configKey));
if ($body === '') {
throw new RepositoryException('旺铺支付未配置' . $name . ',请联系管理员');
}
$wrap = static fn (string $header): string => "-----BEGIN {$header} KEY-----\n"
. wordwrap($body, 64, "\n", true) . "\n-----END {$header} KEY-----";
if ($kind === 'PUBLIC') {
// 公钥兼容 SPKIPUBLIC KEY)与 PKCS#1RSA PUBLIC KEY)两种格式
foreach (['PUBLIC', 'RSA PUBLIC'] as $header) {
if (openssl_pkey_get_public($wrap($header)) !== false) {
return $wrap($header);
}
}
$isPrivate = openssl_pkey_get_private($wrap('PRIVATE')) !== false;
throw new RepositoryException($name . '配置错误' . ($isPrivate ? '(当前内容是私钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
}
$pem = $wrap('PRIVATE');
if (openssl_pkey_get_private($pem) === false) {
$isPublic = openssl_pkey_get_public($wrap('PUBLIC')) !== false;
throw new RepositoryException($name . '配置错误' . ($isPublic ? '(当前内容是公钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
}
return $pem;
}
/**
+2 -1
View File
@@ -17,7 +17,8 @@
"laravel/tinker": "^3.0",
"maatwebsite/excel": "^3.1",
"predis/predis": "2.0",
"w7corp/easywechat": "^6.19"
"w7corp/easywechat": "^6.19",
"ext-openssl": "*"
},
"require-dev": {
"laravel/boost": "^2.0",
+2 -1
View File
@@ -63,7 +63,8 @@ return [
'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号
'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回)
'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回)
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 加签专用 KeyMD5 加签
'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥
'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文)
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填)
],
@@ -17,7 +17,7 @@ return new class extends Migration
$table->string('key', 50)->comment('设置项标示');
$table->string('title', 50)->comment('设置标题');
$table->string('describe', 500)->nullable()->default('')->comment('设置项描述');
$table->string('values', 255)->nullable()->default('')->comment('设置值');
$table->string('values', 2000)->nullable()->default('')->comment('设置值');
$table->string('type', 50)->comment('设置类型');
$table->string('options', 500)->nullable()->comment('options配置');
$table->string('props', 500)->nullable()->comment('props配置');
+2 -1
View File
@@ -34,9 +34,10 @@ class SysDataSeeder extends Seeder
['id' => 14, 'group_id' => 4, 'key' => 'wangpu_mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
['id' => 15, 'group_id' => 4, 'key' => 'wangpu_mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
['id' => 16, 'group_id' => 4, 'key' => 'wangpu_term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
['id' => 17, 'group_id' => 4, 'key' => 'wangpu_sign_key', 'title' => '旺铺加签Key', 'describe' => '旺铺报文加签专用 Key(SignKey,MD5 加签),请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
['id' => 17, 'group_id' => 4, 'key' => 'wangpu_public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
['id' => 18, 'group_id' => 4, 'key' => 'wangpu_sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appidsub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
['id' => 19, 'group_id' => 4, 'key' => 'wangpu_payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
['id' => 20, 'group_id' => 4, 'key' => 'wangpu_private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 11, 'created_at' => $date, 'updated_at' => $date],
]);
// 字典类型初始数据
DB::table('sys_dict')->insert([
-235
View File
@@ -1,235 +0,0 @@
# 小程序接口文档:在线支付(旺铺网关 JSAPI)
> 门店端在线支付:选择本店未支付账单合并付款,后端经旺铺支付网关(统一下单B-JSAPI)下单,
> 小程序调起 `wx.requestPayment` 完成支付;支付结果由**网关后台通知**自动结账,
> 小程序端也可**主动查询**同步结果(回调延迟/丢失时的兜底)。
>
> 结账效果与线下凭证审核一致:关联账单批量置「已支付」、累加门店总采购金额、门店收到支付成功通知。
## 整体流程
```
小程序 后端 旺铺网关 微信
│ wx.login → code │ │ │
│ POST /mini/payment/online (bill_ids, code) │ │
│────────────────────▶│ code2session 换 openid │──────────────────▶│
│ │ 创建支付单+锁定账单 │ │
│ │ 统一下单(mer_order_id=支付单号) ──────────▶│
│ 返回 pay_params │◀──────── order_id + 调起参数 ────────────│
│◀────────────────────│ │ │
│ wx.requestPayment(pay_params) ─────────────────────────────────▶│
│ │ POST /mini/payment/notify(支付成功通知) │
│ │◀───────────────────────│ │
│ │ 验签 → 幂等结账 → 应答 {"code":"00"} │
│ GET .../query 主动同步(兜底) │ │
│────────────────────▶│ 交易查询 → 已支付则结账 │ │
```
## 通用约定
| 项 | 值 |
|---|---|
| 鉴权 | 门店 token`Authorization: Bearer <token>`(登录见 `/mini/auth/login` |
| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` |
| 金额单位 | 元,字符串/数字两位小数(如 `150.50` |
---
## 1. 发起在线支付(合并账单下单)
| 项 | 值 |
|---|---|
| 请求方式 | `POST` |
| 路径 | `/mini/payment/online` |
| 鉴权 | 门店 token |
### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `bill_ids` | int[] | 是 | 要合并付款的账单 ID 数组(`GET /mini/bill?payable=1` 返回的 `id`),至少 1 个 |
| `code` | string | 是 | 小程序 `wx.login()` 返回的登录凭证(后端用它换付款人 openid) |
| `remark` | string | 否 | 付款备注,最长 255 字 |
### 校验规则(失败均返回 `success:false`
- 账单必须全部属于当前门店,且为「未支付」且未被其他支付单锁定;
- 任一账单已支付 / 正在支付中(含凭证审核中)→ 拒绝并提示对应账单号;
- 合计金额必须大于 0
- 微信 `code` 无效(code2session 失败)→ 报错,不产生支付单;
- 网关下单失败 → 支付单自动作废、账单释放,可重新发起。
### 响应示例
```json
{
"success": true,
"msg": "下单成功,请调起支付",
"data": {
"id": 12,
"payment_no": "ZF202608270001",
"amount": "150.50",
"pay_params": {
"order_id": "202608271201444525348059",
"tradeNo": "2021082722001407831438768160",
"user_openid": "oXxx123"
}
}
}
```
### 字段说明
| 字段 | 说明 |
|---|---|
| `payment_no` | 本系统支付单号(即上送网关的商户订单号 `mer_order_id`),后续查询/对账用 |
| `amount` | 应付金额(= 所选账单总额合计,元) |
| `pay_params` | 旺铺网关 `data` 节点原样透传。**调起支付所需参数以网关返回为准**(服务商配置后通常包含 `timeStamp`/`nonceStr`/`package`/`signType`/`paySign` 等),直接透传给 `wx.requestPayment` 即可 |
### 小程序端调用示例
```js
// 1. 获取登录凭证
const { code } = await wx.login();
// 2. 后端下单
const res = await request.post('/mini/payment/online', { bill_ids: [101, 102], code });
const { payment_no, pay_params } = res.data;
// 3. 调起微信支付(pay_params 以旺铺网关实际返回的调起参数为准)
await wx.requestPayment({
timeStamp: pay_params.timeStamp,
nonceStr: pay_params.nonceStr,
package: pay_params.package,
signType: pay_params.signType || 'RSA',
paySign: pay_params.paySign,
});
// 4. 支付完成后主动同步结果(回调兜底)
const q = await request.get(`/mini/payment/online/${payment_no}/query`);
// q.data.status === 1 → 支付成功,刷新账单列表
```
---
## 2. 查询支付结果(主动同步)
| 项 | 值 |
|---|---|
| 请求方式 | `GET` |
| 路径 | `/mini/payment/online/{payment_no}/query` |
| 鉴权 | 门店 token(仅可查询本店支付单) |
### 路径参数
| 参数 | 说明 |
|---|---|
| `payment_no` | 下单返回的支付单号(如 `ZF202608270001` |
### 行为说明
- 本地已是「支付成功」→ 直接返回,不再请求网关;
- 否则向旺铺网关发起「交易查询」,网关返回已支付(`order_status=1`)则**立即结账**(与后台通知同一幂等逻辑);
- 建议在 `wx.requestPayment` 成功回调后、以及付款页 `onShow` 时各调一次;
- 用户中途取消支付时本接口返回 `status: 0`,账单仍处于锁定中,可稍后重试或联系客服释放。
### 响应示例
```json
{
"success": true,
"msg": "支付成功",
"data": {
"payment_no": "ZF202608270001",
"status": 1,
"status_name": "支付成功",
"paid_at": "2026-08-27 10:00:00",
"trade_no": "2021082722001407831438768160"
}
}
```
### `status` 取值
| 值 | 含义 |
|---|---|
| `0` | 待支付(网关未确认) |
| `1` | 支付成功(账单已置已支付) |
| `2` | 支付失败(网关下单失败作废,账单已释放,可重新下单) |
---
## 3. 支付结果后台通知(服务端对接,无需小程序调用)
| 项 | 值 |
|---|---|
| 请求方式 | `POST`(表单) |
| 路径 | `/mini/payment/notify` |
| 鉴权 | 无(公开路由,以报文 `sign` 验签) |
| 来源 | 旺铺网关(下单时上送的 `notifyurl`,由后端自动生成:`{APP_URL}/mini/payment/notify` |
### 处理逻辑
1. 按旺铺加签规则验签(参数 ASCII 升序 `key=value``&` 拼接 + `&key=SignKey`MD5 大写);
2. 验签失败 → 应答 `{"code":"01"}`
3.`mer_order_id` 定位支付单,校验 `order_status=1``order_amt` 与支付单金额一致(防篡改);
4. **幂等结账**:支付单置成功(记录 `trade_no`/`order_id`/支付时间)→ 关联账单批量置已支付 → 累加门店总采购金额(只统计商品金额)→ 站内通知门店;
5. 重复通知直接应答成功,不重复结账。
### 应答报文(网关约定格式)
```json
{ "code": "00", "msg": "成功", "timestamp": "20260827100000123" }
```
网关收到 `code=00` 才认为通知成功,否则按 2ⁿ 分钟重试 7 次。
---
## 后台配置(上线前必配)
配置优先级:后台「系统设置 → 支付配置」> `.env`
### 后台支付配置(site_config `pay` 组,已内置配置项)
| 配置项 | 说明 |
|---|---|
| `wangpu_base_url` | 旺铺网关域名(接口地址前缀) |
| `wangpu_organiz_no` | 合作机构渠道号 organiz_no |
| `wangpu_mer_no` | 旺铺内部商户号 mer_no(进件入网后返回) |
| `wangpu_mer_code` | 商户号 mer_code |
| `wangpu_term_code` | 终端号 term_code |
| `wangpu_sign_key` | 加签专用 KeySignKey |
| `wangpu_sub_appid` | 下单微信子 appid,留空取小程序自身 appid |
| `wangpu_payway_code` | 支付方式代码(默认 `WECHAT_MINI`,以旺铺数据词典为准) |
### .env 备选配置
```dotenv
WECHAT_MINI_APPID= # 小程序 AppIDcode2session 必需)
WECHAT_MINI_SECRET= # 小程序 Secretcode2session 必需)
WANGPU_BASE_URL=
WANGPU_ORGANIZ_NO=
WANGPU_MER_NO=
WANGPU_MER_CODE=
WANGPU_TERM_CODE=
WANGPU_SIGN_KEY=
WANGPU_SUB_APPID=
WANGPU_PAYWAY_CODE=WECHAT_MINI
```
> 注意:`APP_URL` 必须是网关可访问的公网 HTTPS 地址,否则支付成功通知无法送达(此时依赖小程序主动查询兜底结账)。
## 与线下凭证支付的关系
两种付款方式并存,同一张账单同一时刻只能处于一条支付链路:
| | 在线支付(本档) | 线下凭证支付 |
|---|---|---|
| 入口 | `POST /mini/payment/online` | `POST /mini/payment` |
| 支付单 `pay_type` | `2` | `1` |
| 支付单 `pay_method` | `4` 旺铺支付 | `1` 微信 / `2` 支付宝 / `3` 对公汇款 |
| 结账方式 | 网关通知/主动查询自动结账 | 后台审核通过 |
| 失败/拒绝 | 账单自动释放可重新付款 | 审核拒绝后释放 |
账单被任一支付单锁定期间(`payment_id ≠ 0`),两种入口均会拒绝重复提交。
-125
View File
@@ -1,125 +0,0 @@
# 小程序接口文档:采购运营报表
> 门店端运营报表:拉取「一周 / 一月 / 任意选定时间段」内的采购总金额,以及每个单品的累计采购金额与金额占比。
>
> 示例场景:上个月我店一共采购了 10 万元,其中土豆 8764 元(占比 8.8%)、洋葱 3664 元(占比 3.7%)。
## 接口信息
| 项 | 值 |
|---|---|
| 请求方式 | `GET` |
| 路径 | `/mini/report/purchase` |
| 鉴权 | 门店 token`Authorization: Bearer <token>`),与其他小程序接口一致 |
| 数据范围 | 仅当前登录门店自身的订货数据 |
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `preset` | string | 否 | 周期预设:`week` 本周 / `last_week` 上周 / `month` 本月 / `last_month` 上月。**缺省为 `month`** |
| `start_date` | string | 否 | 自定义开始日期,格式 `Y-m-d`(如 `2026-07-01`)。需与 `end_date` 成对出现 |
| `end_date` | string | 否 | 自定义结束日期,格式 `Y-m-d`,不能早于 `start_date` |
规则:
- 同时传 `start_date` + `end_date` 时为**自定义区间,优先于 `preset`**(响应中 `preset` 返回 `custom`);
- 只传其中一个日期、日期格式错误、结束日期早于开始日期、preset 非枚举值,均返回参数错误;
- 进行中的周期(本周/本月)结束日期自动**封顶为今天**;周以**周一**为起点。
## 响应示例
`GET /mini/report/purchase?preset=last_month`
```json
{
"success": true,
"msg": "ok",
"data": {
"preset": "last_month",
"start_date": "2026-07-01",
"end_date": "2026-07-31",
"total_amount": "100000.00",
"total_quantity": 1700,
"order_count": 20,
"item_count": 2,
"items": [
{
"product_id": 11,
"product_name": "土豆",
"product_spec": "50斤/袋",
"unit": "斤",
"quantity": 500,
"weight": "0.000",
"amount": "8764.00",
"percent": 8.8
},
{
"product_id": 12,
"product_name": "洋葱",
"product_spec": "20斤/袋",
"unit": "斤",
"quantity": 200,
"weight": "0.000",
"amount": "3664.00",
"percent": 3.7
}
]
}
}
```
## 响应字段
### 顶层(`data`
| 字段 | 类型 | 说明 |
|---|---|---|
| `preset` | string | 实际生效的周期预设(`week`/`last_week`/`month`/`last_month`/`custom` |
| `start_date` | string | 实际统计开始日期 `Y-m-d` |
| `end_date` | string | 实际统计结束日期 `Y-m-d` |
| `total_amount` | string | 周期内采购总金额(元,2 位小数;金额一律以字符串返回防精度丢失) |
| `total_quantity` | number | 周期内订货总量(各单品数量之和) |
| `order_count` | number | 周期内有效订货单数 |
| `item_count` | number | 单品个数(= `items` 长度) |
| `items` | array | 单品累计列表,**按金额降序** |
### 单品行(`data.items[]`
| 字段 | 类型 | 说明 |
|---|---|---|
| `product_id` | number | 商品 ID |
| `product_name` | string | 品名(下单时快照,商品档案改名/删除不影响历史统计) |
| `product_spec` | string | 规格/包规(快照) |
| `unit` | string | 计价单位(快照) |
| `quantity` | number | 周期内累计订货量 |
| `weight` | string | 周期内累计重量(3 位小数,未称重为 `0.000` |
| `amount` | string | 周期内累计采购金额(元,2 位小数) |
| `percent` | number | 金额占比(%,1 位小数,四舍五入;如 `8.8` 表示 8.8%。周期内无数据时为 `0` |
## 统计口径
- 数据源为**门店订货单明细**(下单时快照的等级单价 × 数量),即门店视角的应付采购金额;
- **已取消**订单与后台**已删除**订单不计入统计,其余状态(待接单/已接单/采购中/配送中/已完成)全部计入;
- 单品按 `product_id` 聚合,同一商品在周期内多次下单会累计为一行;
- 占比 = 单品金额 ÷ 总金额 × 100,保留 1 位小数(四舍五入),各行占比之和可能因舍入存在 ±0.1 的误差,属正常现象。
## 错误响应
与小程序其他接口一致:`success: false` + `msg` 描述原因。
| 场景 | msg 示例 |
|---|---|
| 未登录 / token 失效 | `Unauthenticated`(按全局鉴权约定) |
| 账号被停用 | `账号不存在或已被停用` |
| preset 非法 | `preset 参数只能是 week/last_week/month/last_month` |
| 日期格式错误 | `开始日期格式为 Y-m-d` |
| 结束早于开始 | `结束日期不能早于开始日期` |
| 自定义区间只传一侧 | `自定义区间需同时提供开始与结束日期` |
## 前端对接建议
- 顶部放周期切换 Tab(本周 / 上周 / 本月 / 上月 / 自定义),自定义时弹出日期选择器,选中后传 `start_date` + `end_date`
- 头部卡片展示 `total_amount`(总金额)与 `order_count`(单数);
- 列表直接渲染 `items`(已按金额降序),每行展示品名、规格、累计金额与 `percent`%;如需饼图可直接用 `items[].percent`
- 区间无数据时 `items` 为空数组、`total_amount``"0.00"`,前端展示空状态即可。
-146
View File
@@ -1,146 +0,0 @@
# 小程序接口文档:特价推荐
> 小程序首页「特价推荐」商品区:后台在「客户端配置 → 特价推荐」批量选择商品标记为推荐,
> 小程序端通过本接口分页获取推荐商品列表。
>
> 推荐仅标记商品、不设特价字段:**价格按登录门店的客户等级价展示**(售价 = 成本价 × (100 + 等级上浮比例) / 100),
> 与 `/mini/product/list` 价格口径一致。
## 通用约定
| 项 | 值 |
|---|---|
| 鉴权 | 免登录;携带门店 token(`Authorization: Bearer <token>`,登录见 `/mini/auth/login`)时返回等级价与购物车数量 |
| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` |
| 金额单位 | 元,字符串两位小数(如 `26.00` |
---
## 1. 特价推荐商品列表
| 项 | 值 |
|---|---|
| 请求方式 | `GET` |
| 路径 | `/mini/special/list` |
| 鉴权 | 无(可带门店 token) |
### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `page` | int | 否 | 页码,默认 1 |
| `pageSize` | int | 否 | 每页条数,默认 10 |
### 行为说明
- 仅返回「状态正常」且商品「已上架」的推荐,按推荐排序 `sort` 升序(越小越靠前);
- 行结构与 `/mini/product/list` 完全一致:商品基础字段 + `price` + `cart_id` / `cart_quantity`
- 未登录 / 门店未设客户等级时 `price = null`,前端应引导登录后查看价格;
- `cost_price`(成本价)属于商业敏感字段,任何情况下都不会输出;
- `data.cart` 为购物车悬浮球汇总,未登录返回零值结构。
### 响应示例
```json
{
"success": true,
"msg": "ok",
"data": {
"current_page": 1,
"per_page": 10,
"total": 2,
"data": [
{
"id": 15,
"category_id": 2,
"name": "西红柿",
"spec": "约5斤/份",
"unit": "斤",
"price_unit": "元/斤",
"market": "新发地",
"image_ids": "321,322",
"images_arr": [
{ "id": 321, "preview_url": "https://example.com/storage/xxx.jpg" }
],
"status": 1,
"sort": 0,
"price": "3.50",
"cart_id": 88,
"cart_quantity": "2.00"
},
{
"id": 23,
"category_id": 3,
"name": "麒麟西瓜",
"spec": "约8斤/个",
"unit": "个",
"price_unit": "元/个",
"market": "岳各庄",
"image_ids": "340",
"images_arr": [
{ "id": 340, "preview_url": "https://example.com/storage/yyy.jpg" }
],
"status": 1,
"sort": 1,
"price": "26.00",
"cart_id": 0,
"cart_quantity": "0.00"
}
],
"cart": {
"count": 2,
"quantity": "2.00",
"amount": "7.00"
}
}
}
```
### 字段说明
| 字段 | 说明 |
|---|---|
| `id` | 商品 ID,点击跳转商品详情 `/mini/product/{id}` |
| `price` | 当前登录门店的等级售价;未登录 / 未设等级为 `null` |
| `cart_id` | 该商品在当前门店购物车中的行 ID(0 = 不在购物车),列表直接加减购物车用 |
| `cart_quantity` | 购物车中该商品数量(不在购物车为 `"0.00"` |
| `cart` | 购物车悬浮球汇总:`count` 行数 / `quantity` 总数量 / `amount` 总金额(元) |
### 小程序端调用示例
```js
// 首页特价推荐区(带上拉加载更多)
Page({
data: { specials: [], page: 1, hasMore: true },
async loadSpecials() {
const res = await request.get('/mini/special/list', {
page: this.data.page,
pageSize: 10,
});
const { data: rows, total } = res.data;
this.setData({
specials: this.data.page === 1 ? rows : [...this.data.specials, ...rows],
hasMore: this.data.specials.length + rows.length < total,
});
},
onReachBottom() {
if (!this.data.hasMore) return;
this.setData({ page: this.data.page + 1 });
this.loadSpecials();
},
});
```
---
## 后台配置入口
PC 后台「客户端配置 → 特价推荐」:
| 操作 | 说明 |
|---|---|
| 添加商品 | 按商品名称搜索(仅上架商品可选),支持一次多选批量添加;已在推荐中的商品自动跳过 |
| 批量删除 | 勾选后批量移除推荐(不影响商品档案本身) |
| 编辑 | 仅可调整排序与状态(正常/停用) |
+148 -55
View File
@@ -7,18 +7,27 @@ use App\Models\NoticeModel;
use App\Models\PaymentModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Exceptions\RepositoryException;
use App\Services\WangpuPayService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use ReflectionMethod;
/**
* 小程序在线支付(旺铺网关 JSAPI):下单 → 调起支付 → 后台通知/主动查询结账
* 小程序在线支付(旺铺网关行业版):下单 → 调起支付 → 后台通知/主动查询结账
*
* 通讯协议与官方示例 demo/IndexController.php 一致:
* 业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输。
*
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
*/
class MiniOnlinePaymentTest extends ProcurementTestCase
{
private const string SIGN_KEY = 'test-wangpu-sign-key';
/** 测试专用 RSA 密钥对(仅测试使用,与生产密钥无关;公私钥同源便于加解密回环) */
private const string TEST_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqZ4z7UatOnbBolcpFhW2q585II7atHK9w/opQ/k8QUqjwkwIVNYzC9qf/SFDoOnMBufZwS2tM8UUPcgNoy0hbmR5QqBsp9ugBaSL1ZHb5jStgpikzWEjFkyVR5WLtxl4nxKv0R5pV/mfq0RXsIpbutZqugnXRvHPYaqbKomXzf0FKlxmbpvPOXsH9L9rYrnOSKQ1t16vAa6UrqUdkgdQBH7+hI4kiDwY0MIZVOhDhB4r0ODrpWMd0U3yrbhwpHZdtF2B3dtn3JSS92F16Yo7Hec8+TUzNKX0Mu1rn07F6yGZeUf+5/egXsBc2Mx1TgZswFlvkmHS96kbH9gqJyGOVwIDAQAB';
private const string TEST_PRIVATE_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpnjPtRq06dsGiVykWFbarnzkgjtq0cr3D+ilD+TxBSqPCTAhU1jML2p/9IUOg6cwG59nBLa0zxRQ9yA2jLSFuZHlCoGyn26AFpIvVkdvmNK2CmKTNYSMWTJVHlYu3GXifEq/RHmlX+Z+rRFewilu61mq6CddG8c9hqpsqiZfN/QUqXGZum885ewf0v2tiuc5IpDW3Xq8BrpSupR2SB1AEfv6EjiSIPBjQwhlU6EOEHivQ4OulYx3RTfKtuHCkdl20XYHd22fclJL3YXXpijsd5zz5NTM0pfQy7WufTsXrIZl5R/7n96BewFzYzHVOBmzAWW+SYdL3qRsf2ConIY5XAgMBAAECggEAANg+h97uaNFYF9c5XjNeLYU0ULqkeD6SKAApIpgTY+6w6FkBoWIuT1y+q0jNYqKpwN+Ds2+vfizT7GX5Bz1iLoN8etHawBeLCmIus83A75VeAr20gOyy6pWv3Qlty1yvMsczEydqsL3vA0+gdQoB2RW+ib4uKmxl66WNR+xQE0DaGp3GGe117l4PrhqZWsMMugQ2sLJy6ZZ/L6+ILnTPYwz+6NdOor8owoSEYHpKJ03wTcDGdMtRkzanEtfBNklHolymof+aDwXeugw5teGm+/AtaTf8dINeUIGp6AFrUa+g78wyO/piUf+inLNa+e6vgGqck+ErsbRPb6VFQFBsQQKBgQDqkgfXGETdBtE5g9QiV2aY+DDHzcmQZBLgRGDoHFhn2SgRdG2E66Xq6uGEnwIpqLuKsCYe4t2JvNjSVFZ14Ul8DOow6NQfWLPxU6YT3fShAh/gTMgccKb7/Pe3fQqwl+6aEV3oMOKNFErKSGrkSptLh9Nf/ii9khpMFhUARErgjQKBgQC5HRwRb45CIkEeS2Q1Gz2xS4ezXACw9P0h4Qp5bMQmqgn/fjN+t4HJRlHCuDfLTnHZ48Vpihwu7BU1RmK1ozXJswDzQXCYE4ejEXTGVoUNxCHht4/VlAbBVbcnOsIREgvtZRIOHvPaMqUQqeSX9e3HcEAHfIaJRkVAhalQnKMrcwKBgDUtH7viU5Irvnikaw3R9H9PHHffLgeeuCzBM5rK+juong2+8CkG5tknoDJZfbsF9mYNYsbztTdJaXndBrC4ftkxcFHgJl5o1Hor9WVhlth9S86keWUBIMnVYi7lmOvJtZyVvU0q7+D9rarH2fug8i2gQAnt6zx2h6GiC+bAlJztAoGBAI/YxgnqhUJw+ec/sKPwAjW2usGu2b6o8deU153Z3mcpNVG70OpEUW+F3F0S6BBtad1muO41a4cu36AhjO0W4eJV3oQpMwSKEJmwI+1IKGa1JZsQGI5gVAuCvyuV5l57hpc4NhqRBO9m8YwMaV2ItviHCsqGgslDuObVtue0gLtvAoGAWSPjf+POrVy2VJ0yY9zJ6Ks1oGLYzejUTx6Ue/DmjZcOcn6IBGqvgj10D5Gu9vX847GiC8D632A3US4FMLJZki0IxEYWME3d3pg0vhqS24YxZwIo97JvxnrHOqMvd486K+Cyvlft8xgxlZdL79Ez5eec4Lvw7OoQaaoxcdDvf08=';
protected function setUp(): void
{
@@ -32,21 +41,74 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'services.wangpu.mer_no' => 'mer001',
'services.wangpu.mer_code' => 'code001',
'services.wangpu.term_code' => 'term001',
'services.wangpu.sign_key' => self::SIGN_KEY,
'services.wangpu.public_key' => self::TEST_PUBLIC_KEY,
'services.wangpu.private_key' => self::TEST_PRIVATE_KEY,
'services.wangpu.payway_code' => 'WECHAT_MINI',
]);
}
/** 密钥 PEM 包装(与服务内实现一致) */
private function pem(string $body, string $kind): string
{
return "-----BEGIN {$kind} KEY-----\n" . wordwrap($body, 64, "\n", true) . "\n-----END {$kind} KEY-----";
}
/**
* 模拟网关加密信封(demo 协议:AES-128-ECB 加密报文 + RSA 公钥加密 AES 密钥)
*
* @param array<string, mixed> $data 业务报文
* @return array<string, string>
*/
private function gatewayEnvelope(array $data): array
{
$key = Str::random(16);
openssl_public_encrypt($key, $encryptedKey, $this->pem(self::TEST_PUBLIC_KEY, 'PUBLIC'));
return [
'serialNo' => Str::random(32),
'version' => '1.0',
'timestamp' => now()->format('YmdHis'),
'data' => base64_encode((string) openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA)),
'signature' => base64_encode($encryptedKey),
'extras' => '',
'organizNo' => 'org001',
];
}
/**
* 解密我方发往网关的请求信封(断言上送报文用;服务侧 urlencode 需先解码)
*
* @param array<string, mixed> $body 请求信封
* @return array<string, mixed>
*/
private function decryptRequest(array $body): array
{
openssl_private_decrypt(
(string) base64_decode(urldecode((string) $body['signature'])),
$key,
$this->pem(self::TEST_PRIVATE_KEY, 'PRIVATE')
);
$plain = openssl_decrypt(
(string) base64_decode(urldecode((string) $body['data'])),
'AES-128-ECB',
(string) $key,
OPENSSL_RAW_DATA
);
return (array) json_decode((string) $plain, true);
}
/** 模拟微信 code2session + 旺铺统一下单均成功 */
private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void
{
Http::fake([
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200),
'https://wangpu.test/industrial/payment/order' => Http::response([
'code' => '0000',
'msg' => '调用成功',
'data' => ['order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid],
], 200),
'https://wangpu.test/industrial/payment/order' => Http::response(
['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope([
'order_id' => 'WP202608270001',
'tradeNo' => 'T20260827001',
'user_openid' => $openid,
]),
200
),
]);
}
@@ -89,10 +151,10 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
return $payment;
}
/** 构造已加签的支付成功通知报文 */
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
/** 构造加密的支付成功通知信封 */
private function encryptedNotifyParams(PaymentModel $payment, array $overrides = []): array
{
$params = array_merge([
return $this->gatewayEnvelope(array_merge([
'mer_order_id' => $payment->payment_no,
'order_status' => '1',
'order_amt' => (string) $payment->amount,
@@ -104,36 +166,53 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'mer_no' => 'mer001',
'device_no' => 'dev001',
'order_title' => '账单合并付款',
], $overrides);
$params['sign'] = app(WangpuPayService::class)->sign($params);
return $params;
], $overrides));
}
/** 旺铺加签算法:按接口文档示例 golden testMD5 升序拼接 + key */
public function test_sign_matches_document_example(): void
/** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */
public function test_aes_encryption_matches_demo_golden(): void
{
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
$key = 'AbCdEfGh12345678'; // 16 字节密钥
$data = ['mer_order_id' => 'ZF202608270001', 'order_amt' => '0.01', 'organiz_no' => '100005'];
$sign = app(WangpuPayService::class)->sign([
'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_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',
]);
$service = app(WangpuPayService::class);
$method = new ReflectionMethod($service, 'aesEncrypt');
$cipher = $method->invoke($service, $data, $key);
$this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign);
// demo 算法直算的固定密文(算法/填充/编码任何偏差都会改变该值)
$this->assertSame(
'jl7iWrSNXWQ4D5CCOCENDYG/3JBxiTYWB2XJSMCFGZjfDr5ned8ZLvfXMC+05bVvbBu5aYc6/245g+wuHU0Mo7zUvmMTrPOvgEfHzsnxHa0=',
$cipher
);
}
/** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */
/** 信封加解密回环:网关侧加密(demo 算法)→ 服务解密还原业务报文 */
public function test_notify_envelope_round_trip(): void
{
$params = [
'mer_order_id' => 'ZF202608270001',
'order_status' => '1',
'order_amt' => '150.50',
'order_title' => '账单合并付款', // 中文报文
];
$decrypted = app(WangpuPayService::class)->decryptNotify($this->gatewayEnvelope($params));
$this->assertSame($params, $decrypted);
}
/** 密钥配置错误:公钥栏误填私钥时给出明确中文报错(而非 openssl 警告) */
public function test_swapped_key_config_fails_with_clear_message(): void
{
config(['services.wangpu.public_key' => self::TEST_PRIVATE_KEY]); // 公钥栏误填私钥
$this->expectException(RepositoryException::class);
$this->expectExceptionMessage('旺铺平台公钥配置错误(当前内容是私钥,请检查是否填反)');
app(WangpuPayService::class)->createOrder(['mer_order_id' => 'ZF202609010001', 'order_amt' => '0.01']);
}
/** 发起在线支付:锁定账单、创建支付单、上送网关报文正确、openid 绑定到门店 */
public function test_create_online_payment_success(): void
{
$this->fakeGatewaySuccess();
@@ -163,16 +242,30 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
// 上送网关的报文:商户订单号=支付单号、金额openid、带签名
Http::assertSent(static function ($request) use ($paymentNo) {
// 上送网关的加密信封:解出业务报文校验商户订单号/金额/openid/商户信息
Http::assertSent(function ($request) use ($paymentNo) {
if (! str_contains($request->url(), '/industrial/payment/order')) {
return false;
}
$body = $request->data();
return str_contains($request->url(), '/industrial/payment/order')
&& ($body['mer_order_id'] ?? '') === $paymentNo
&& ($body['order_amt'] ?? '') === '150.50'
&& ($body['open_id'] ?? '') === 'oOpenidTest001'
&& ($body['sub_appid'] ?? '') === 'wx-mini-test'
&& ! empty($body['sign'])
&& ! empty($body['notifyurl']);
if (($body['organizNo'] ?? '') !== 'org001'
|| empty($body['serialNo'])
|| ($body['version'] ?? '') !== '1.0'
|| empty($body['timestamp'])
) {
return false;
}
$plain = $this->decryptRequest($body);
return ($plain['mer_order_id'] ?? '') === $paymentNo
&& ($plain['order_amt'] ?? '') === '150.50'
&& ($plain['open_id'] ?? '') === 'oOpenidTest001'
&& ($plain['sub_appid'] ?? '') === 'wx-mini-test'
&& ($plain['mer_no'] ?? '') === 'mer001'
&& ($plain['mer_code'] ?? '') === 'code001'
&& ($plain['term_code'] ?? '') === 'term001'
&& ($plain['payway_code'] ?? '') === 'WECHAT_MINI'
&& ($plain['organiz_no'] ?? '') === 'org001'
&& ! empty($plain['notifyurl']);
});
}
@@ -243,7 +336,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
}
/** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
/** 支付成功通知:解密信封 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
public function test_notify_settles_payment(): void
{
$store = StoreModel::factory()->create();
@@ -251,7 +344,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->signedNotifyParams($payment))
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
->assertJsonPath('code', '00');
$payment->refresh();
@@ -273,34 +366,34 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
);
// 重复通知幂等:仍应答成功,金额不重复累加
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($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);
// 验签失败
$badSign = $this->signedNotifyParams($payment);
$badSign['sign'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
// 信封 signature 非法 → 解密失败
$badEnvelope = $this->encryptedNotifyParams($payment);
$badEnvelope['signature'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badEnvelope)->assertJsonPath('code', '01');
// 金额不一致(防篡改)
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_amt' => '99.99']))
->assertJsonPath('code', '01');
// 订单号不存在
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
->assertJsonPath('code', '01');
// 非支付成功状态
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_status' => '0']))
->assertJsonPath('code', '01');
// 均未结账
@@ -331,7 +424,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'order_id' => 'WP202608270002',
'trade_time' => '2026-08-27 11:00:00',
];
return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 200);
return Http::response(['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope($data), 200);
},
]);