本文档指导开发者如何为本系统开发支付插件。
目录结构
所有支付插件必须存放在 public/plugins/pay/ 目录下,每个插件一个独立文件夹:
public/plugins/pay/
├── epay/ # 插件目录名(小写英文)
│ ├── description.php # 插件基本信息(必需)
│ ├── set.php # 后台配置定义(必需)
│ ├── go.php # 发起支付(必需)
│ ├── notify.php # 异步通知处理(必需)
│ └── return.php # 同步回调处理(可选)
├── alipay/
│ └── ...
└── wxpay/
└── ...核心流程
用户下单 → 系统调用 go.php → 返回支付参数 → 用户完成支付
↓
支付平台 → 异步通知 notify.php → 验签成功 → 系统更新订单
↓
用户返回 → 同步回调 return.php → 显示支付结果文件详解
1. description.php - 插件信息
定义插件在后台显示的基本信息。
<?php
return [
"DisplayName" => "易支付", // 后台显示名称
"APIVersion" => "1.0.0", // 版本号
"HelpDoc" => "https://..." // 帮助文档链接(可选)
];返回字段说明:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| DisplayName | string | 是 | 插件显示名称 |
| APIVersion | string | 是 | 版本号 |
| HelpDoc | string | 否 | 帮助文档URL |
2. set.php - 后台配置
定义后台配置页面需要填写的字段。
<?php
return [
[
"name" => "商户ID", // 配置项标识(英文)
"title" => "商户号", // 显示标题
"type" => "input", // 类型:input/select/textarea/switch
"prompt" => "请输入商户号", // 提示文字
"value" => "" // 默认值
],
[
"name" => "密钥",
"title" => "API密钥",
"type" => "input",
"prompt" => "请输入API密钥",
"value" => ""
],
[
"name" => "支付方式",
"title" => "支付方式",
"type" => "select",
"prompt" => "选择支付方式",
"value" => "alipay",
"option" => [ // select类型专用
"支付宝" => "alipay",
"微信" => "wxpay",
"QQ" => "qqpay"
]
],
[
"name" => "调试模式",
"title" => "调试模式",
"type" => "switch",
"prompt" => "是否开启调试",
"value" => "0" // 0=关,1=开
]
];字段类型说明:
| 类型 | 说明 |
|---|---|
| input | 单行文本输入 |
| password | 密码输入(内容隐藏) |
| textarea | 多行文本输入 |
| select | 下拉选择,需配合 option |
| switch | 开关,value为 "0" 或 "1" |
| number | 数字输入 |
配置数据使用方式:
系统在调用 go.php、notify.php、return.php 时,会将用户在后台填写的配置值通过 $params['data'] 传入。
3. go.php - 发起支付
函数名: go($params)
作用: 接收订单信息,向支付平台发起请求,返回支付参数。
入参 $params:
| 字段 | 类型 | 说明 |
|---|---|---|
| order_no | string | 系统订单号 |
| subject | string | 订单标题 |
| amount | float | 订单金额(元) |
| notify_url | string | 异步通知URL(系统生成) |
| return_url | string | 同步回调URL(系统生成) |
| client_ip | string | 用户IP地址 |
| data | array/string | 插件配置数据 |
返回值:
// 页面跳转支付
return [
'code' => 1, // 1=成功,0=失败
'type' => 'jump', // 固定值:jump
'data' => 'https://...' // 支付平台跳转URL
];
// API二维码支付
return [
'code' => 1,
'type' => 'qrcode', // 固定值:qrcode
'data' => 'https://...' // 二维码图片URL或支付链接
];
// 失败
return [
'code' => 0,
'msg' => '错误信息'
];示例代码:
<?php
function go($params)
{
// 解析配置
$cfg = is_array($params['data'])
? $params['data']
: json_decode($params['data'], true);
// 检查配置
if (empty($cfg['商户ID']) || empty($cfg['密钥'])) {
return ['code' => 0, 'msg' => '请先配置支付参数'];
}
// 组装请求参数
$order = [
'mch_id' => $cfg['商户ID'],
'out_trade_no' => $params['order_no'],
'total_fee' => intval($params['amount'] * 100), // 转为分
'body' => $params['subject'],
'notify_url' => $params['notify_url'],
'return_url' => $params['return_url'],
'nonce_str' => md5(uniqid()),
];
// 生成签名(按支付平台规则)
$order['sign'] = sign($order, $cfg['密钥']);
// 请求支付平台
$response = httpPost('https://pay.xxx.com/api', $order);
$result = json_decode($response, true);
if ($result['code'] == 'SUCCESS') {
return [
'code' => 1,
'type' => 'qrcode',
'data' => $result['code_url'] // 二维码链接
];
}
return ['code' => 0, 'msg' => $result['msg']];
}
// 签名函数示例
function sign($data, $key)
{
ksort($data);
$str = '';
foreach ($data as $k => $v) {
if ($v !== '' && $k !== 'sign') {
$str .= $k . '=' . $v . '&';
}
}
$str .= 'key=' . $key;
return strtoupper(md5($str));
}4. notify.php - 异步通知
函数名: notify($params)
作用: 接收支付平台的支付结果通知,验证签名,确认支付成功。
入参 $params:
| 字段 | 类型 | 说明 |
|---|---|---|
| order_no | string | 系统订单号 |
| subject | string | 订单标题 |
| amount | float | 订单金额 |
| all | array | 支付平台POST的所有原始数据 |
| data | array/string | 插件配置数据 |
返回值:
// 验签成功,系统会更新订单为已支付
return ['code' => 1, 'msg' => 'success'];
// 验签失败
return ['code' => 0, 'msg' => '签名错误'];⚠️ 重要:
- 必须返回
['code' => 1, 'msg' => 'success']才算成功 - 不要输出任何其他内容(如echo、var_dump)
- 系统只负责验签,订单状态更新由系统统一处理
示例代码:
<?php
function notify($params)
{
$cfg = is_array($params['data'])
? $params['data']
: json_decode($params['data'], true);
$post = $params['all']; // 原始POST数据
// 1. 验证签名
$sign = $post['sign'] ?? '';
unset($post['sign']);
$mySign = sign($post, $cfg['密钥']);
if (strtoupper($sign) !== strtoupper($mySign)) {
return ['code' => 0, 'msg' => '签名错误'];
}
// 2. 验证订单号
if ($post['out_trade_no'] !== $params['order_no']) {
return ['code' => 0, 'msg' => '订单号不匹配'];
}
// 3. 验证金额(使用bccomp精确比较)
$notifyAmount = $post['total_fee'] / 100; // 分转元
if (bccomp($notifyAmount, $params['amount'], 2) !== 0) {
return ['code' => 0, 'msg' => '金额不一致'];
}
// 4. 验证支付状态
if ($post['status'] !== 'SUCCESS') {
return ['code' => 0, 'msg' => '未支付'];
}
// 5. 返回成功
return ['code' => 1, 'msg' => 'success'];
}5. return.php - 同步回调(可选)
函数名: returns($params) (注意是 returns 不是 return)
作用: 用户支付完成后,从支付平台跳转回系统的处理。
入参: 同 notify.php
返回值: 同 notify.php
与 notify.php 的区别:
| notify.php | return.php | |
|---|---|---|
| 触发 | 服务器主动通知 | 用户浏览器跳转 |
| 可靠性 | 高(会重试) | 低(可能关闭页面) |
| 更新订单 | 是 | 否(仅展示结果) |
示例代码:
<?php
function returns($params)
{
// 逻辑同notify,但只用于页面展示
// 不实际修改订单状态
$cfg = is_array($params['data'])
? $params['data']
: json_decode($params['data'], true);
$get = $params['all']; // URL参数
// 验证签名、订单号、金额、状态...
// 返回结果用于页面显示支付成功/失败
return ['code' => 1, 'msg' => 'success'];
}系统调用流程
发起支付时
// 系统内部逻辑示意
$params = [
'order_no' => '20240201123456',
'subject' => '商品购买',
'amount' => 99.99,
'notify_url' => 'https://网站/notify/epay',
'return_url' => 'https://网站/return/epay',
'client_ip' => '192.168.1.1',
'data' => ['商户ID' => 'xxx', '密钥' => 'yyy', ...] // 后台配置
];
$result = go($params);
if ($result['code'] == 1) {
if ($result['type'] == 'jump') {
header('Location: ' . $result['data']);
} else {
// 显示二维码
showQrcode($result['data']);
}
} else {
showError($result['msg']);
}异步通知时
// 系统接收到支付平台通知后
$params = [
'order_no' => '20240201123456',
'subject' => '商品购买',
'amount' => 99.99,
'all' => $_POST, // 原始POST数据
'data' => ['商户ID' => 'xxx', '密钥' => 'yyy', ...]
];
$result = notify($params);
if ($result['code'] == 1) {
// 系统更新订单状态为已支付
updateOrderStatus($params['order_no'], 'paid');
echo 'success'; // 响应支付平台
} else {
echo $result['msg']; // 响应错误
}开发规范
1. 目录命名
- 使用小写英文字母+数字
- 简短有意义,如
epay、alipay、wxpay、paypal
2. 配置安全
- 敏感信息(密钥等)在后台配置,不要硬编码
- 通过
$params['data']获取配置
3. 金额处理
- 使用
bccomp()进行金额比较,避免浮点误差 - 注意单位转换(元/分)
4. 签名验证
- 严格按照支付平台文档实现签名算法
- 空值字段通常不参与签名
- 签名比较忽略大小写
5. 错误处理
- 所有错误返回
['code' => 0, 'msg' => '错误描述'] - 不要直接输出错误,通过返回值传递
6. 网络请求
- 设置合理的超时时间(建议10-30秒)
- 处理网络异常情况
快速开始:创建新插件
以创建 "示例支付" 插件为例:
步骤1:创建目录
mkdir public/plugins/pay/demo步骤2:创建 description.php
<?php
return [
"DisplayName" => "示例支付",
"APIVersion" => "1.0.0",
"HelpDoc" => ""
];步骤3:创建 set.php
<?php
return [
[
"name" => "mch_id",
"title" => "商户号",
"type" => "input",
"prompt" => "请输入商户号",
"value" => ""
],
[
"name" => "key",
"title" => "API密钥",
"type" => "input",
"prompt" => "请输入API密钥",
"value" => ""
]
];步骤4:创建 go.php
<?php
function go($params)
{
$cfg = is_array($params['data'])
? $params['data']
: json_decode($params['data'], true);
if (empty($cfg['mch_id']) || empty($cfg['key'])) {
return ['code' => 0, 'msg' => '配置不完整'];
}
// TODO: 根据支付平台API实现
return ['code' => 0, 'msg' => '待实现'];
}步骤5:创建 notify.php
<?php
function notify($params)
{
$cfg = is_array($params['data'])
? $params['data']
: json_decode($params['data'], true);
$post = $params['all'];
// TODO: 验证签名、订单号、金额、状态
return ['code' => 1, 'msg' => 'success'];
}步骤6:后台启用
进入系统后台 → 支付设置 → 启用 "示例支付" 插件 → 填写配置
调试技巧
1. 记录日志
在插件中写入日志便于调试:
function logDebug($msg, $data = null)
{
file_put_contents(
__DIR__ . '/debug.log',
date('Y-m-d H:i:s') . ' ' . $msg . ' ' . json_encode($data) . "\n",
FILE_APPEND
);
}
// 使用
logDebug('收到通知', $params);2. 签名调试
function debugSign($data, $key)
{
ksort($data);
$str = http_build_query($data) . '&key=' . $key;
logDebug('签名字符串', $str);
logDebug('签名结果', md5($str));
return md5($str);
}附录:常用工具函数
<?php
/**
* HTTP POST请求
*/
function httpPost($url, $data, $timeout = 30)
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* 生成签名(字典序+MD5)
*/
function makeSign($data, $key, $exclude = ['sign', 'sign_type'])
{
ksort($data);
$pairs = [];
foreach ($data as $k => $v) {
if ($v !== '' && !in_array($k, $exclude)) {
$pairs[] = $k . '=' . $v;
}
}
return md5(implode('&', $pairs) . $key);
}
/**
* 获取客户端IP
*/
function getClientIp()
{
$keys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
foreach ($keys as $k) {
if (!empty($_SERVER[$k])) {
$ips = explode(',', $_SERVER[$k]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '127.0.0.1';
}