mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-03-28 05:34:26 +08:00
更新数据库结构
This commit is contained in:
229
app/common/contracts/AbstractPayPlugin.php
Normal file
229
app/common/contracts/AbstractPayPlugin.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\contracts;
|
||||
|
||||
use app\common\contracts\PayPluginInterface;
|
||||
use support\Log;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 支付插件抽象基类
|
||||
*
|
||||
* 提供通用的环境检测、HTTP请求、日志记录等功能
|
||||
*/
|
||||
abstract class AbstractPayPlugin implements PayPluginInterface
|
||||
{
|
||||
/**
|
||||
* 环境常量
|
||||
*/
|
||||
const ENV_PC = 'PC';
|
||||
const ENV_H5 = 'H5';
|
||||
const ENV_WECHAT = 'WECHAT';
|
||||
const ENV_ALIPAY_CLIENT = 'ALIPAY_CLIENT';
|
||||
|
||||
/**
|
||||
* 当前支付方式
|
||||
*/
|
||||
protected string $currentMethod = '';
|
||||
|
||||
/**
|
||||
* 当前通道配置
|
||||
*/
|
||||
protected array $currentConfig = [];
|
||||
|
||||
/**
|
||||
* 初始化插件(切换到指定支付方式)
|
||||
*/
|
||||
public function init(string $methodCode, array $channelConfig): void
|
||||
{
|
||||
if (!in_array($methodCode, static::getSupportedMethods())) {
|
||||
throw new \RuntimeException("插件不支持支付方式:{$methodCode}");
|
||||
}
|
||||
$this->currentMethod = $methodCode;
|
||||
$this->currentConfig = $channelConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测请求环境
|
||||
*
|
||||
* @param Request $request
|
||||
* @return string 环境代码(PC/H5/WECHAT/ALIPAY_CLIENT)
|
||||
*/
|
||||
protected function detectEnvironment(Request $request): string
|
||||
{
|
||||
$ua = strtolower($request->header('User-Agent', ''));
|
||||
|
||||
// 支付宝客户端
|
||||
if (strpos($ua, 'alipayclient') !== false) {
|
||||
return self::ENV_ALIPAY_CLIENT;
|
||||
}
|
||||
|
||||
// 微信内浏览器
|
||||
if (strpos($ua, 'micromessenger') !== false) {
|
||||
return self::ENV_WECHAT;
|
||||
}
|
||||
|
||||
// 移动设备
|
||||
$mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
|
||||
foreach ($mobileKeywords as $keyword) {
|
||||
if (strpos($ua, $keyword) !== false) {
|
||||
return self::ENV_H5;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认PC
|
||||
return self::ENV_PC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据环境选择产品
|
||||
*
|
||||
* @param array $enabledProducts 已启用的产品列表
|
||||
* @param string $env 环境代码
|
||||
* @param array $allProducts 所有可用产品(产品代码 => 产品名称)
|
||||
* @return string|null 选择的产品代码,如果没有匹配则返回null
|
||||
*/
|
||||
protected function selectProductByEnv(array $enabledProducts, string $env, array $allProducts): ?string
|
||||
{
|
||||
// 环境到产品的映射规则(子类可以重写此方法实现自定义逻辑)
|
||||
$envProductMap = [
|
||||
self::ENV_PC => ['pc', 'web', 'wap'],
|
||||
self::ENV_H5 => ['h5', 'wap', 'mobile'],
|
||||
self::ENV_WECHAT => ['jsapi', 'wechat', 'h5'],
|
||||
self::ENV_ALIPAY_CLIENT => ['app', 'alipay', 'h5'],
|
||||
];
|
||||
|
||||
$candidates = $envProductMap[$env] ?? [];
|
||||
|
||||
// 优先匹配已启用的产品
|
||||
foreach ($candidates as $candidate) {
|
||||
if (in_array($candidate, $enabledProducts)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配,返回第一个已启用的产品
|
||||
if (!empty($enabledProducts)) {
|
||||
return $enabledProducts[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP POST JSON请求
|
||||
*
|
||||
* @param string $url 请求URL
|
||||
* @param array $data 请求数据
|
||||
* @param array $headers 额外请求头
|
||||
* @return array 响应数据(已解析JSON)
|
||||
*/
|
||||
protected function httpPostJson(string $url, array $data, array $headers = []): array
|
||||
{
|
||||
$headers['Content-Type'] = 'application/json';
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $this->buildHeaders($headers));
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new \RuntimeException("HTTP请求失败:{$error}");
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new \RuntimeException("HTTP请求失败,状态码:{$httpCode}");
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException("JSON解析失败:" . json_last_error_msg());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP POST Form请求
|
||||
*
|
||||
* @param string $url 请求URL
|
||||
* @param array $data 请求数据
|
||||
* @param array $headers 额外请求头
|
||||
* @return string 响应内容
|
||||
*/
|
||||
protected function httpPostForm(string $url, array $data, array $headers = []): string
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $this->buildHeaders($headers));
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new \RuntimeException("HTTP请求失败:{$error}");
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new \RuntimeException("HTTP请求失败,状态码:{$httpCode}");
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建HTTP请求头数组
|
||||
*
|
||||
* @param array $headers 请求头数组
|
||||
* @return array
|
||||
*/
|
||||
private function buildHeaders(array $headers): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($headers as $key => $value) {
|
||||
$result[] = "{$key}: {$value}";
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录请求日志
|
||||
*
|
||||
* @param string $action 操作名称
|
||||
* @param array $data 请求数据
|
||||
* @param mixed $response 响应数据
|
||||
* @return void
|
||||
*/
|
||||
protected function logRequest(string $action, array $data, $response = null): void
|
||||
{
|
||||
$logData = [
|
||||
'plugin' => static::getCode(),
|
||||
'method' => $this->currentMethod,
|
||||
'action' => $action,
|
||||
'request' => $data,
|
||||
'response' => $response,
|
||||
'time' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
Log::debug('支付插件请求', $logData);
|
||||
}
|
||||
}
|
||||
|
||||
103
app/common/contracts/PayPluginInterface.php
Normal file
103
app/common/contracts/PayPluginInterface.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\contracts;
|
||||
|
||||
/**
|
||||
* 支付插件接口
|
||||
*
|
||||
* 所有支付插件必须实现此接口
|
||||
*/
|
||||
interface PayPluginInterface
|
||||
{
|
||||
/**
|
||||
* 获取插件代码(唯一标识)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function getCode(): string;
|
||||
|
||||
/**
|
||||
* 获取插件名称
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function getName(): string;
|
||||
|
||||
/**
|
||||
* 获取插件支持的支付方式列表
|
||||
*
|
||||
* @return array<string> 支付方式代码数组,如 ['alipay', 'wechat']
|
||||
*/
|
||||
public static function getSupportedMethods(): array;
|
||||
|
||||
/**
|
||||
* 获取指定支付方式支持的产品列表
|
||||
*
|
||||
* @param string $methodCode 支付方式代码
|
||||
* @return array<string, string> 产品代码 => 产品名称
|
||||
*/
|
||||
public static function getSupportedProducts(string $methodCode): array;
|
||||
|
||||
/**
|
||||
* 获取指定支付方式的配置表单结构
|
||||
*
|
||||
* @param string $methodCode 支付方式代码
|
||||
* @return array 表单字段定义数组
|
||||
*/
|
||||
public static function getConfigSchema(string $methodCode): array;
|
||||
|
||||
/**
|
||||
* 初始化插件(切换到指定支付方式)
|
||||
*
|
||||
* @param string $methodCode 支付方式代码
|
||||
* @param array $channelConfig 通道配置
|
||||
* @return void
|
||||
*/
|
||||
public function init(string $methodCode, array $channelConfig): void;
|
||||
|
||||
/**
|
||||
* 统一下单
|
||||
*
|
||||
* @param array $orderData 订单数据
|
||||
* @param array $channelConfig 通道配置
|
||||
* @param string $requestEnv 请求环境(PC/H5/WECHAT/ALIPAY_CLIENT)
|
||||
* @return array 支付结果,包含:
|
||||
* - product_code: 选择的产品代码
|
||||
* - channel_order_no: 渠道订单号(如果有)
|
||||
* - pay_params: 支付参数(根据产品类型不同,结构不同)
|
||||
*/
|
||||
public function unifiedOrder(array $orderData, array $channelConfig, string $requestEnv): array;
|
||||
|
||||
/**
|
||||
* 查询订单
|
||||
*
|
||||
* @param array $orderData 订单数据(至少包含 pay_order_id 或 channel_order_no)
|
||||
* @param array $channelConfig 通道配置
|
||||
* @return array 订单状态信息
|
||||
*/
|
||||
public function query(array $orderData, array $channelConfig): array;
|
||||
|
||||
/**
|
||||
* 退款
|
||||
*
|
||||
* @param array $refundData 退款数据
|
||||
* @param array $channelConfig 通道配置
|
||||
* @return array 退款结果
|
||||
*/
|
||||
public function refund(array $refundData, array $channelConfig): array;
|
||||
|
||||
/**
|
||||
* 解析回调通知
|
||||
*
|
||||
* @param array $requestData 回调请求数据
|
||||
* @param array $channelConfig 通道配置
|
||||
* @return array 解析结果,包含:
|
||||
* - status: 订单状态(SUCCESS/FAIL/PENDING)
|
||||
* - pay_order_id: 系统订单号
|
||||
* - channel_trade_no: 渠道交易号
|
||||
* - amount: 支付金额
|
||||
* - pay_time: 支付时间
|
||||
*/
|
||||
public function parseNotify(array $requestData, array $channelConfig): array;
|
||||
}
|
||||
|
||||
180
app/common/payment/LakalaPayment.php
Normal file
180
app/common/payment/LakalaPayment.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\contracts\AbstractPayPlugin;
|
||||
|
||||
/**
|
||||
* 拉卡拉支付插件示例
|
||||
*
|
||||
* 支持多个支付方式:alipay、wechat、unionpay
|
||||
*/
|
||||
class LakalaPayment extends AbstractPayPlugin
|
||||
{
|
||||
public static function getCode(): string
|
||||
{
|
||||
return 'lakala';
|
||||
}
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return '拉卡拉支付';
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持多个支付方式
|
||||
*/
|
||||
public static function getSupportedMethods(): array
|
||||
{
|
||||
return ['alipay', 'wechat', 'unionpay'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付方式返回支持的产品
|
||||
*/
|
||||
public static function getSupportedProducts(string $methodCode): array
|
||||
{
|
||||
return match ($methodCode) {
|
||||
'alipay' => [
|
||||
['code' => 'alipay_h5', 'name' => '支付宝H5', 'device_type' => 'H5'],
|
||||
['code' => 'alipay_life', 'name' => '支付宝生活号', 'device_type' => 'ALIPAY_CLIENT'],
|
||||
['code' => 'alipay_app', 'name' => '支付宝APP', 'device_type' => 'ALIPAY_CLIENT'],
|
||||
['code' => 'alipay_qr', 'name' => '支付宝扫码', 'device_type' => 'PC'],
|
||||
],
|
||||
'wechat' => [
|
||||
['code' => 'wechat_jsapi', 'name' => '微信JSAPI', 'device_type' => 'WECHAT'],
|
||||
['code' => 'wechat_h5', 'name' => '微信H5', 'device_type' => 'H5'],
|
||||
['code' => 'wechat_native', 'name' => '微信扫码', 'device_type' => 'PC'],
|
||||
['code' => 'wechat_app', 'name' => '微信APP', 'device_type' => 'H5'],
|
||||
],
|
||||
'unionpay' => [
|
||||
['code' => 'unionpay_h5', 'name' => '云闪付H5', 'device_type' => 'H5'],
|
||||
['code' => 'unionpay_app', 'name' => '云闪付APP', 'device_type' => 'H5'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置Schema
|
||||
*/
|
||||
public static function getConfigSchema(string $methodCode): array
|
||||
{
|
||||
$baseFields = [
|
||||
['field' => 'merchant_id', 'label' => '商户号', 'type' => 'input', 'required' => true],
|
||||
['field' => 'secret_key', 'label' => '密钥', 'type' => 'input', 'required' => true],
|
||||
['field' => 'api_url', 'label' => '接口地址', 'type' => 'input', 'required' => true],
|
||||
];
|
||||
|
||||
// 根据支付方式添加特定字段
|
||||
if ($methodCode === 'alipay') {
|
||||
$baseFields[] = ['field' => 'alipay_app_id', 'label' => '支付宝AppId', 'type' => 'input'];
|
||||
} elseif ($methodCode === 'wechat') {
|
||||
$baseFields[] = ['field' => 'wechat_app_id', 'label' => '微信AppId', 'type' => 'input'];
|
||||
}
|
||||
|
||||
return ['fields' => $baseFields];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一下单
|
||||
*/
|
||||
public function unifiedOrder(array $orderData, array $channelConfig, string $requestEnv): array
|
||||
{
|
||||
// 1. 从通道已开通产品中选择(根据环境)
|
||||
$enabledProducts = $channelConfig['enabled_products'] ?? [];
|
||||
$allProducts = static::getSupportedProducts($this->currentMethod);
|
||||
$productCode = $this->selectProductByEnv($enabledProducts, $requestEnv, $allProducts);
|
||||
|
||||
if (!$productCode) {
|
||||
throw new \RuntimeException('当前环境无可用支付产品');
|
||||
}
|
||||
|
||||
// 2. 根据当前支付方式和产品调用不同的接口
|
||||
// 这里简化处理,实际应调用拉卡拉的API
|
||||
return match ($this->currentMethod) {
|
||||
'alipay' => $this->createAlipayOrder($orderData, $channelConfig, $productCode),
|
||||
'wechat' => $this->createWechatOrder($orderData, $channelConfig, $productCode),
|
||||
'unionpay' => $this->createUnionpayOrder($orderData, $channelConfig, $productCode),
|
||||
default => throw new \RuntimeException('未初始化的支付方式'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单
|
||||
*/
|
||||
public function query(array $orderData, array $channelConfig): array
|
||||
{
|
||||
// TODO: 实现查询逻辑
|
||||
return ['status' => 'PENDING'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款
|
||||
*/
|
||||
public function refund(array $refundData, array $channelConfig): array
|
||||
{
|
||||
// TODO: 实现退款逻辑
|
||||
return ['status' => 'SUCCESS'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析回调
|
||||
*/
|
||||
public function parseNotify(array $requestData, array $channelConfig): array
|
||||
{
|
||||
// TODO: 实现回调解析和验签
|
||||
return [
|
||||
'status' => 'SUCCESS',
|
||||
'pay_order_id' => $requestData['out_trade_no'] ?? '',
|
||||
'channel_trade_no'=> $requestData['trade_no'] ?? '',
|
||||
'amount' => $requestData['total_amount'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function createAlipayOrder(array $orderData, array $config, string $productCode): array
|
||||
{
|
||||
// TODO: 调用拉卡拉的支付宝接口
|
||||
return [
|
||||
'product_code' => $productCode,
|
||||
'channel_order_no'=> '',
|
||||
'pay_params' => [
|
||||
'type' => 'redirect',
|
||||
'url' => 'https://example.com/pay?order=' . $orderData['pay_order_id'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function createWechatOrder(array $orderData, array $config, string $productCode): array
|
||||
{
|
||||
// TODO: 调用拉卡拉的微信接口
|
||||
return [
|
||||
'product_code' => $productCode,
|
||||
'channel_order_no'=> '',
|
||||
'pay_params' => [
|
||||
'type' => 'jsapi',
|
||||
'appId' => $config['wechat_app_id'] ?? '',
|
||||
'timeStamp' => time(),
|
||||
'nonceStr' => uniqid(),
|
||||
'package' => 'prepay_id=xxx',
|
||||
'signType' => 'MD5',
|
||||
'paySign' => 'xxx',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function createUnionpayOrder(array $orderData, array $config, string $productCode): array
|
||||
{
|
||||
// TODO: 调用拉卡拉的云闪付接口
|
||||
return [
|
||||
'product_code' => $productCode,
|
||||
'channel_order_no'=> '',
|
||||
'pay_params' => [
|
||||
'type' => 'redirect',
|
||||
'url' => 'https://example.com/unionpay?order=' . $orderData['pay_order_id'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
app/http/admin/controller/AdminController.php
Normal file
34
app/http/admin/controller/AdminController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\AdminService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 管理员控制器
|
||||
*/
|
||||
class AdminController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected AdminService $adminService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/getUserInfo
|
||||
*
|
||||
* 获取当前登录管理员信息
|
||||
*/
|
||||
public function getUserInfo(Request $request)
|
||||
{
|
||||
$adminId = $this->currentUserId($request);
|
||||
if ($adminId <= 0) {
|
||||
return $this->fail('未获取到用户信息,请先登录', 401);
|
||||
}
|
||||
|
||||
$data = $this->adminService->getInfoById($adminId);
|
||||
return $this->success($data);
|
||||
}
|
||||
}
|
||||
164
app/http/admin/controller/ChannelController.php
Normal file
164
app/http/admin/controller/ChannelController.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\repositories\{PaymentChannelRepository, PaymentMethodRepository};
|
||||
use app\services\PluginService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 通道管理控制器
|
||||
*/
|
||||
class ChannelController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PluginService $pluginService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通道列表
|
||||
* GET /adminapi/channel/list
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
$merchantId = (int)$request->get('merchant_id', 0);
|
||||
$appId = (int)$request->get('app_id', 0);
|
||||
$methodCode = trim((string)$request->get('method_code', ''));
|
||||
|
||||
$where = [];
|
||||
if ($merchantId > 0) {
|
||||
$where['merchant_id'] = $merchantId;
|
||||
}
|
||||
if ($appId > 0) {
|
||||
$where['merchant_app_id'] = $appId;
|
||||
}
|
||||
if ($methodCode !== '') {
|
||||
$method = $this->methodRepository->findByCode($methodCode);
|
||||
if ($method) {
|
||||
$where['method_id'] = $method->id;
|
||||
}
|
||||
}
|
||||
|
||||
$page = (int)($request->get('page', 1));
|
||||
$pageSize = (int)($request->get('page_size', 10));
|
||||
|
||||
$result = $this->channelRepository->paginate($where, $page, $pageSize);
|
||||
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通道详情
|
||||
* GET /adminapi/channel/detail
|
||||
*/
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->get('id', 0);
|
||||
if (!$id) {
|
||||
return $this->fail('通道ID不能为空', 400);
|
||||
}
|
||||
|
||||
$channel = $this->channelRepository->find($id);
|
||||
if (!$channel) {
|
||||
return $this->fail('通道不存在', 404);
|
||||
}
|
||||
|
||||
$methodCode = '';
|
||||
if ($channel->method_id) {
|
||||
$method = $this->methodRepository->find($channel->method_id);
|
||||
$methodCode = $method ? $method->method_code : '';
|
||||
}
|
||||
|
||||
try {
|
||||
$configSchema = $this->pluginService->getConfigSchema($channel->plugin_code, $methodCode);
|
||||
|
||||
// 合并当前配置值
|
||||
$currentConfig = $channel->getConfigArray();
|
||||
if (isset($configSchema['fields']) && is_array($configSchema['fields'])) {
|
||||
foreach ($configSchema['fields'] as &$field) {
|
||||
if (isset($field['field']) && isset($currentConfig[$field['field']])) {
|
||||
$field['value'] = $currentConfig[$field['field']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'channel' => $channel,
|
||||
'config_schema' => $configSchema,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->success([
|
||||
'channel' => $channel,
|
||||
'config_schema' => ['fields' => []],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存通道
|
||||
* POST /adminapi/channel/save
|
||||
*/
|
||||
public function save(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$pluginCode = $data['plugin_code'] ?? '';
|
||||
$methodCode = $data['method_code'] ?? '';
|
||||
$enabledProducts = $data['enabled_products'] ?? [];
|
||||
|
||||
if (empty($pluginCode) || empty($methodCode)) {
|
||||
return $this->fail('插件编码和支付方式不能为空', 400);
|
||||
}
|
||||
|
||||
// 提取配置参数(从表单字段中提取)
|
||||
try {
|
||||
$configJson = $this->pluginService->buildConfigFromForm($pluginCode, $methodCode, $data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('插件不存在或配置错误:' . $e->getMessage(), 400);
|
||||
}
|
||||
|
||||
$method = $this->methodRepository->findByCode($methodCode);
|
||||
if (!$method) {
|
||||
return $this->fail('支付方式不存在', 400);
|
||||
}
|
||||
|
||||
$configWithProducts = array_merge($configJson, ['enabled_products' => is_array($enabledProducts) ? $enabledProducts : []]);
|
||||
|
||||
$channelData = [
|
||||
'merchant_id' => (int)($data['merchant_id'] ?? 0),
|
||||
'merchant_app_id' => (int)($data['app_id'] ?? 0),
|
||||
'chan_code' => $data['channel_code'] ?? $data['chan_code'] ?? '',
|
||||
'chan_name' => $data['channel_name'] ?? $data['chan_name'] ?? '',
|
||||
'plugin_code' => $pluginCode,
|
||||
'method_id' => $method->id,
|
||||
'config_json' => $configWithProducts,
|
||||
'split_ratio' => isset($data['split_ratio']) ? (float)$data['split_ratio'] : 100.00,
|
||||
'chan_cost' => isset($data['channel_cost']) ? (float)$data['channel_cost'] : 0.00,
|
||||
'chan_mode' => $data['channel_mode'] ?? 'wallet',
|
||||
'daily_limit' => isset($data['daily_limit']) ? (float)$data['daily_limit'] : 0.00,
|
||||
'daily_cnt' => isset($data['daily_count']) ? (int)$data['daily_count'] : 0,
|
||||
'min_amount' => isset($data['min_amount']) && $data['min_amount'] !== '' ? (float)$data['min_amount'] : null,
|
||||
'max_amount' => isset($data['max_amount']) && $data['max_amount'] !== '' ? (float)$data['max_amount'] : null,
|
||||
'status' => (int)($data['status'] ?? 1),
|
||||
'sort' => (int)($data['sort'] ?? 0),
|
||||
];
|
||||
|
||||
if ($id > 0) {
|
||||
// 更新
|
||||
$this->channelRepository->updateById($id, $channelData);
|
||||
} else {
|
||||
if (empty($channelData['chan_code'])) {
|
||||
$channelData['chan_code'] = 'CH' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
}
|
||||
$this->channelRepository->create($channelData);
|
||||
}
|
||||
|
||||
return $this->success(null, '保存成功');
|
||||
}
|
||||
}
|
||||
|
||||
71
app/http/admin/controller/PluginController.php
Normal file
71
app/http/admin/controller/PluginController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\PluginService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 插件管理控制器
|
||||
*/
|
||||
class PluginController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PluginService $pluginService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用插件列表
|
||||
* GET /adminapi/channel/plugins
|
||||
*/
|
||||
public function plugins()
|
||||
{
|
||||
$plugins = $this->pluginService->listPlugins();
|
||||
return $this->success($plugins);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置Schema
|
||||
* GET /adminapi/channel/plugin/config-schema
|
||||
*/
|
||||
public function configSchema(Request $request)
|
||||
{
|
||||
$pluginCode = $request->get('plugin_code', '');
|
||||
$methodCode = $request->get('method_code', '');
|
||||
|
||||
if (empty($pluginCode) || empty($methodCode)) {
|
||||
return $this->fail('插件编码和支付方式不能为空', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$schema = $this->pluginService->getConfigSchema($pluginCode, $methodCode);
|
||||
return $this->success($schema);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('获取配置Schema失败:' . $e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件支持的支付产品列表
|
||||
* GET /adminapi/channel/plugin/products
|
||||
*/
|
||||
public function products(Request $request)
|
||||
{
|
||||
$pluginCode = $request->get('plugin_code', '');
|
||||
$methodCode = $request->get('method_code', '');
|
||||
|
||||
if (empty($pluginCode) || empty($methodCode)) {
|
||||
return $this->fail('插件编码和支付方式不能为空', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$products = $this->pluginService->getSupportedProducts($pluginCode, $methodCode);
|
||||
return $this->success($products);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('获取产品列表失败:' . $e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\UserService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 用户接口示例控制器
|
||||
*
|
||||
* 主要用于演示 BaseController / Service / Repository / Model 的调用链路。
|
||||
*/
|
||||
class UserController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected UserService $userService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /user/getUserInfo
|
||||
*
|
||||
* 从 JWT token 中获取当前登录用户信息
|
||||
* 前端通过 Authorization: Bearer {token} 请求头传递 token
|
||||
*/
|
||||
public function getUserInfo(Request $request)
|
||||
{
|
||||
// 从JWT中间件注入的用户信息中获取用户ID
|
||||
$userId = $this->currentUserId($request);
|
||||
|
||||
if ($userId <= 0) {
|
||||
return $this->fail('未获取到用户信息,请先登录', 401);
|
||||
}
|
||||
|
||||
$data = $this->userService->getUserInfoById($userId);
|
||||
return $this->success($data);
|
||||
}
|
||||
}
|
||||
|
||||
132
app/http/api/controller/EpayController.php
Normal file
132
app/http/api/controller/EpayController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\api\EpayService;
|
||||
use app\validation\EpayValidator;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 易支付控制器
|
||||
*/
|
||||
class EpayController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected EpayService $epayService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 页面跳转支付
|
||||
*/
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$data = array_merge($request->get(), $request->post());
|
||||
|
||||
try {
|
||||
// 参数校验(使用自定义 Validator + 场景)
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('submit')
|
||||
->validate();
|
||||
|
||||
// 业务处理:创建订单并获取支付参数
|
||||
$result = $this->epayService->submit($params, $request);
|
||||
$payParams = $result['pay_params'] ?? [];
|
||||
|
||||
// 根据支付参数类型返回响应
|
||||
if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) {
|
||||
return redirect($payParams['url']);
|
||||
}
|
||||
|
||||
if (($payParams['type'] ?? '') === 'form') {
|
||||
return $this->renderForm($payParams);
|
||||
}
|
||||
|
||||
// 如果没有匹配的类型,返回错误
|
||||
return $this->fail('支付参数生成失败');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API接口支付
|
||||
*/
|
||||
public function mapi(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
|
||||
try {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('mapi')
|
||||
->validate();
|
||||
|
||||
$result = $this->epayService->mapi($params, $request);
|
||||
|
||||
return json($result);
|
||||
} catch (\Throwable $e) {
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API接口
|
||||
*/
|
||||
public function api(Request $request)
|
||||
{
|
||||
$data = array_merge($request->get(), $request->post());
|
||||
|
||||
try {
|
||||
$act = strtolower($data['act'] ?? '');
|
||||
|
||||
if ($act === 'order') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_order')
|
||||
->validate();
|
||||
$result = $this->epayService->api($params);
|
||||
} elseif ($act === 'refund') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_refund')
|
||||
->validate();
|
||||
$result = $this->epayService->api($params);
|
||||
} else {
|
||||
$result = [
|
||||
'code' => 0,
|
||||
'msg' => '不支持的操作类型',
|
||||
];
|
||||
}
|
||||
|
||||
return json($result);
|
||||
} catch (\Throwable $e) {
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染表单提交 HTML(用于页面跳转支付)
|
||||
*/
|
||||
private function renderForm(array $formParams): Response
|
||||
{
|
||||
$html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>跳转支付</title></head><body>';
|
||||
$html .= '<form id="payForm" method="' . htmlspecialchars($formParams['method'] ?? 'POST') . '" action="' . htmlspecialchars($formParams['action'] ?? '') . '">';
|
||||
|
||||
if (isset($formParams['fields']) && is_array($formParams['fields'])) {
|
||||
foreach ($formParams['fields'] as $name => $value) {
|
||||
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars((string)$value) . '">';
|
||||
}
|
||||
}
|
||||
|
||||
$html .= '</form>';
|
||||
$html .= '<script>document.getElementById("payForm").submit();</script>';
|
||||
$html .= '</body></html>';
|
||||
|
||||
return response($html)->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
41
app/http/api/controller/PayController.php
Normal file
41
app/http/api/controller/PayController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\PayOrderService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 支付控制器(OpenAPI)
|
||||
*/
|
||||
class PayController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PayOrderService $payOrderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
public function create(Request $request) {}
|
||||
|
||||
/**
|
||||
* 查询订单
|
||||
*/
|
||||
public function query(Request $request) {}
|
||||
|
||||
/**
|
||||
* 关闭订单
|
||||
*/
|
||||
public function close(Request $request) {}
|
||||
|
||||
/**
|
||||
* 订单退款
|
||||
*/
|
||||
public function refund(Request $request) {}
|
||||
/**
|
||||
* 异步通知
|
||||
*/
|
||||
public function notify(Request $request) {}
|
||||
}
|
||||
69
app/http/api/middleware/EpayAuthMiddleware.php
Normal file
69
app/http/api/middleware/EpayAuthMiddleware.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use app\exceptions\UnauthorizedException;
|
||||
use app\repositories\MerchantAppRepository;
|
||||
|
||||
/**
|
||||
* OpenAPI 签名认证中间件
|
||||
*
|
||||
* 验证 AppId + 签名
|
||||
*/
|
||||
class EpayAuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
protected MerchantAppRepository $merchantAppRepository;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 延迟加载,避免循环依赖
|
||||
$this->merchantAppRepository = new MerchantAppRepository();
|
||||
}
|
||||
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$appId = $request->header('X-App-Id', '') ?: ($request->post('app_id', '') ?: $request->get('app_id', ''));
|
||||
$timestamp = $request->header('X-Timestamp', '') ?: ($request->post('timestamp', '') ?: $request->get('timestamp', ''));
|
||||
$nonce = $request->header('X-Nonce', '') ?: ($request->post('nonce', '') ?: $request->get('nonce', ''));
|
||||
$signature = $request->header('X-Signature', '') ?: ($request->post('signature', '') ?: $request->get('signature', ''));
|
||||
|
||||
if (empty($appId) || empty($timestamp) || empty($nonce) || empty($signature)) {
|
||||
throw new UnauthorizedException('缺少认证参数');
|
||||
}
|
||||
|
||||
// 验证时间戳(5分钟内有效)
|
||||
if (abs(time() - (int)$timestamp) > 300) {
|
||||
throw new UnauthorizedException('请求已过期');
|
||||
}
|
||||
|
||||
// 查询应用
|
||||
$app = $this->merchantAppRepository->findByAppId($appId);
|
||||
if (!$app) {
|
||||
throw new UnauthorizedException('应用不存在或已禁用');
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
$method = $request->method();
|
||||
$path = $request->path();
|
||||
$body = $request->rawBody();
|
||||
$bodySha256 = hash('sha256', $body);
|
||||
|
||||
$signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method={$method}&path={$path}&body_sha256={$bodySha256}";
|
||||
$expectedSignature = hash_hmac('sha256', $signString, $app->app_secret);
|
||||
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
throw new UnauthorizedException('签名验证失败');
|
||||
}
|
||||
|
||||
// 将应用信息注入到请求对象
|
||||
$request->app = $app;
|
||||
$request->merchantId = $app->merchant_id;
|
||||
$request->appId = $app->id;
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
|
||||
69
app/http/api/middleware/OpenApiAuthMiddleware.php
Normal file
69
app/http/api/middleware/OpenApiAuthMiddleware.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use app\exceptions\UnauthorizedException;
|
||||
use app\repositories\MerchantAppRepository;
|
||||
|
||||
/**
|
||||
* OpenAPI 签名认证中间件
|
||||
*
|
||||
* 验证 AppId + 签名
|
||||
*/
|
||||
class OpenApiAuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
protected MerchantAppRepository $merchantAppRepository;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 延迟加载,避免循环依赖
|
||||
$this->merchantAppRepository = new MerchantAppRepository();
|
||||
}
|
||||
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$appId = $request->header('X-App-Id', '') ?: ($request->post('app_id', '') ?: $request->get('app_id', ''));
|
||||
$timestamp = $request->header('X-Timestamp', '') ?: ($request->post('timestamp', '') ?: $request->get('timestamp', ''));
|
||||
$nonce = $request->header('X-Nonce', '') ?: ($request->post('nonce', '') ?: $request->get('nonce', ''));
|
||||
$signature = $request->header('X-Signature', '') ?: ($request->post('signature', '') ?: $request->get('signature', ''));
|
||||
|
||||
if (empty($appId) || empty($timestamp) || empty($nonce) || empty($signature)) {
|
||||
throw new UnauthorizedException('缺少认证参数');
|
||||
}
|
||||
|
||||
// 验证时间戳(5分钟内有效)
|
||||
if (abs(time() - (int)$timestamp) > 300) {
|
||||
throw new UnauthorizedException('请求已过期');
|
||||
}
|
||||
|
||||
// 查询应用
|
||||
$app = $this->merchantAppRepository->findByAppId($appId);
|
||||
if (!$app) {
|
||||
throw new UnauthorizedException('应用不存在或已禁用');
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
$method = $request->method();
|
||||
$path = $request->path();
|
||||
$body = $request->rawBody();
|
||||
$bodySha256 = hash('sha256', $body);
|
||||
|
||||
$signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method={$method}&path={$path}&body_sha256={$bodySha256}";
|
||||
$expectedSignature = hash_hmac('sha256', $signString, $app->app_secret);
|
||||
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
throw new UnauthorizedException('签名验证失败');
|
||||
}
|
||||
|
||||
// 将应用信息注入到请求对象
|
||||
$request->app = $app;
|
||||
$request->merchantId = $app->merchant_id;
|
||||
$request->appId = $app->id;
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
|
||||
51
app/jobs/NotifyMerchantJob.php
Normal file
51
app/jobs/NotifyMerchantJob.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace app\jobs;
|
||||
|
||||
use app\models\PaymentNotifyTask;
|
||||
use app\repositories\PaymentNotifyTaskRepository;
|
||||
use app\services\NotifyService;
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 商户通知任务
|
||||
*
|
||||
* 异步发送支付结果通知给商户
|
||||
*/
|
||||
class NotifyMerchantJob
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentNotifyTaskRepository $notifyTaskRepository,
|
||||
protected NotifyService $notifyService
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$tasks = $this->notifyTaskRepository->getPendingRetryTasks(100);
|
||||
|
||||
foreach ($tasks as $taskData) {
|
||||
try {
|
||||
$task = $this->notifyTaskRepository->find($taskData['id']);
|
||||
if (!$task) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($task->retry_cnt >= 10) {
|
||||
$this->notifyTaskRepository->updateById($task->id, [
|
||||
'status' => PaymentNotifyTask::STATUS_FAIL,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->notifyService->sendNotify($task);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('通知任务处理失败', [
|
||||
'task_id' => $taskData['id'] ?? 0,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
app/models/Admin.php
Normal file
36
app/models/Admin.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 管理员模型
|
||||
*
|
||||
* 对应表:ma_admin
|
||||
*/
|
||||
class Admin extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_admin';
|
||||
|
||||
protected $fillable = [
|
||||
'user_name',
|
||||
'password',
|
||||
'nick_name',
|
||||
'avatar',
|
||||
'mobile',
|
||||
'email',
|
||||
'status',
|
||||
'login_ip',
|
||||
'login_at',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'status' => 'integer',
|
||||
'login_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = ['password'];
|
||||
}
|
||||
27
app/models/Merchant.php
Normal file
27
app/models/Merchant.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 商户模型
|
||||
*/
|
||||
class Merchant extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_merchant';
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_no',
|
||||
'merchant_name',
|
||||
'funds_mode',
|
||||
'status',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'status' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
30
app/models/MerchantApp.php
Normal file
30
app/models/MerchantApp.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 商户应用模型
|
||||
*/
|
||||
class MerchantApp extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_merchant_app';
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id',
|
||||
'api_type',
|
||||
'app_id',
|
||||
'app_secret',
|
||||
'app_name',
|
||||
'status',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'merchant_id' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
33
app/models/PaymentCallbackLog.php
Normal file
33
app/models/PaymentCallbackLog.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 支付回调日志模型
|
||||
*
|
||||
* 对应表:ma_pay_callback_log
|
||||
*/
|
||||
class PaymentCallbackLog extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_pay_callback_log';
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'channel_id',
|
||||
'callback_type',
|
||||
'request_data',
|
||||
'verify_status',
|
||||
'process_status',
|
||||
'process_result',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'channel_id' => 'integer',
|
||||
'verify_status' => 'integer',
|
||||
'process_status' => 'integer',
|
||||
];
|
||||
}
|
||||
62
app/models/PaymentChannel.php
Normal file
62
app/models/PaymentChannel.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 支付通道模型
|
||||
*
|
||||
* 对应表:ma_pay_channel
|
||||
*/
|
||||
class PaymentChannel extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_pay_channel';
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id',
|
||||
'merchant_app_id',
|
||||
'chan_code',
|
||||
'chan_name',
|
||||
'plugin_code',
|
||||
'method_id',
|
||||
'config_json',
|
||||
'split_ratio',
|
||||
'chan_cost',
|
||||
'chan_mode',
|
||||
'daily_limit',
|
||||
'daily_cnt',
|
||||
'min_amount',
|
||||
'max_amount',
|
||||
'status',
|
||||
'sort',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'merchant_id' => 'integer',
|
||||
'merchant_app_id' => 'integer',
|
||||
'method_id' => 'integer',
|
||||
'config_json' => 'array',
|
||||
'split_ratio' => 'decimal:2',
|
||||
'chan_cost' => 'decimal:2',
|
||||
'daily_limit' => 'decimal:2',
|
||||
'daily_cnt' => 'integer',
|
||||
'min_amount' => 'decimal:2',
|
||||
'max_amount' => 'decimal:2',
|
||||
'status' => 'integer',
|
||||
'sort' => 'integer',
|
||||
];
|
||||
|
||||
public function getConfigArray(): array
|
||||
{
|
||||
return $this->config_json ?? [];
|
||||
}
|
||||
|
||||
public function getEnabledProducts(): array
|
||||
{
|
||||
$config = $this->getConfigArray();
|
||||
return $config['enabled_products'] ?? [];
|
||||
}
|
||||
}
|
||||
30
app/models/PaymentMethod.php
Normal file
30
app/models/PaymentMethod.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 支付方式模型
|
||||
*
|
||||
* 对应表:ma_pay_method
|
||||
*/
|
||||
class PaymentMethod extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_pay_method';
|
||||
|
||||
protected $fillable = [
|
||||
'method_code',
|
||||
'method_name',
|
||||
'icon',
|
||||
'sort',
|
||||
'status',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
}
|
||||
42
app/models/PaymentNotifyTask.php
Normal file
42
app/models/PaymentNotifyTask.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 商户通知任务模型
|
||||
*
|
||||
* 对应表:ma_notify_task
|
||||
*/
|
||||
class PaymentNotifyTask extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_notify_task';
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'merchant_id',
|
||||
'merchant_app_id',
|
||||
'notify_url',
|
||||
'notify_data',
|
||||
'status',
|
||||
'retry_cnt',
|
||||
'next_retry_at',
|
||||
'last_notify_at',
|
||||
'last_response',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'merchant_id' => 'integer',
|
||||
'merchant_app_id' => 'integer',
|
||||
'retry_cnt' => 'integer',
|
||||
'next_retry_at' => 'datetime',
|
||||
'last_notify_at' => 'datetime',
|
||||
];
|
||||
|
||||
const STATUS_PENDING = 'PENDING';
|
||||
const STATUS_SUCCESS = 'SUCCESS';
|
||||
const STATUS_FAIL = 'FAIL';
|
||||
}
|
||||
62
app/models/PaymentOrder.php
Normal file
62
app/models/PaymentOrder.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 支付订单模型
|
||||
*
|
||||
* 对应表:ma_pay_order
|
||||
*/
|
||||
class PaymentOrder extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_pay_order';
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'merchant_id',
|
||||
'merchant_app_id',
|
||||
'mch_order_no',
|
||||
'method_id',
|
||||
'channel_id',
|
||||
'amount',
|
||||
'real_amount',
|
||||
'fee',
|
||||
'currency',
|
||||
'subject',
|
||||
'body',
|
||||
'status',
|
||||
'chan_order_no',
|
||||
'chan_trade_no',
|
||||
'pay_at',
|
||||
'expire_at',
|
||||
'client_ip',
|
||||
'notify_stat',
|
||||
'notify_cnt',
|
||||
'extra',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'merchant_id' => 'integer',
|
||||
'merchant_app_id' => 'integer',
|
||||
'method_id' => 'integer',
|
||||
'channel_id' => 'integer',
|
||||
'amount' => 'decimal:2',
|
||||
'real_amount' => 'decimal:2',
|
||||
'fee' => 'decimal:2',
|
||||
'status' => 'integer',
|
||||
'notify_stat' => 'integer',
|
||||
'notify_cnt' => 'integer',
|
||||
'extra' => 'array',
|
||||
'pay_at' => 'datetime',
|
||||
'expire_at' => 'datetime',
|
||||
];
|
||||
|
||||
const STATUS_PENDING = 0;
|
||||
const STATUS_SUCCESS = 1;
|
||||
const STATUS_FAIL = 2;
|
||||
const STATUS_CLOSED = 3;
|
||||
}
|
||||
34
app/models/PaymentPlugin.php
Normal file
34
app/models/PaymentPlugin.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 支付插件模型
|
||||
*
|
||||
* 对应表:ma_pay_plugin(主键 plugin_code)
|
||||
*/
|
||||
class PaymentPlugin extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_pay_plugin';
|
||||
|
||||
protected $primaryKey = 'plugin_code';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'plugin_code',
|
||||
'plugin_name',
|
||||
'class_name',
|
||||
'status',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'status' => 'integer',
|
||||
];
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 用户模型
|
||||
*
|
||||
* 对应表:users
|
||||
*/
|
||||
class User extends BaseModel
|
||||
{
|
||||
/**
|
||||
* 表名
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'users';
|
||||
|
||||
/**
|
||||
* 关联角色(多对多)
|
||||
*/
|
||||
public function roles()
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
app/repositories/AdminRepository.php
Normal file
31
app/repositories/AdminRepository.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\Admin;
|
||||
|
||||
/**
|
||||
* 管理员仓储
|
||||
*/
|
||||
class AdminRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new Admin());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户名查询
|
||||
*/
|
||||
public function findByUserName(string $userName): ?Admin
|
||||
{
|
||||
/** @var Admin|null $admin */
|
||||
$admin = $this->model
|
||||
->newQuery()
|
||||
->where('user_name', $userName)
|
||||
->first();
|
||||
|
||||
return $admin;
|
||||
}
|
||||
}
|
||||
41
app/repositories/MerchantAppRepository.php
Normal file
41
app/repositories/MerchantAppRepository.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\MerchantApp;
|
||||
|
||||
/**
|
||||
* 商户应用仓储
|
||||
*/
|
||||
class MerchantAppRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new MerchantApp());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据AppId查询
|
||||
*/
|
||||
public function findByAppId(string $appId): ?MerchantApp
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('app_id', $appId)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据商户ID和应用ID查询
|
||||
*/
|
||||
public function findByMerchantAndApp(int $merchantId, int $appId): ?MerchantApp
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_id', $merchantId)
|
||||
->where('id', $appId)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
28
app/repositories/MerchantRepository.php
Normal file
28
app/repositories/MerchantRepository.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\Merchant;
|
||||
|
||||
/**
|
||||
* 商户仓储
|
||||
*/
|
||||
class MerchantRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new Merchant());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据商户号查询
|
||||
*/
|
||||
public function findByMerchantNo(string $merchantNo): ?Merchant
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_no', $merchantNo)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
22
app/repositories/PaymentCallbackLogRepository.php
Normal file
22
app/repositories/PaymentCallbackLogRepository.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\PaymentCallbackLog;
|
||||
|
||||
/**
|
||||
* 支付回调日志仓储
|
||||
*/
|
||||
class PaymentCallbackLogRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new PaymentCallbackLog());
|
||||
}
|
||||
|
||||
public function createLog(array $data): PaymentCallbackLog
|
||||
{
|
||||
return $this->model->newQuery()->create($data);
|
||||
}
|
||||
}
|
||||
38
app/repositories/PaymentChannelRepository.php
Normal file
38
app/repositories/PaymentChannelRepository.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\PaymentChannel;
|
||||
|
||||
/**
|
||||
* 支付通道仓储
|
||||
*/
|
||||
class PaymentChannelRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new PaymentChannel());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据商户、应用、支付方式查找可用通道
|
||||
*/
|
||||
public function findAvailableChannel(int $merchantId, int $merchantAppId, int $methodId): ?PaymentChannel
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_id', $merchantId)
|
||||
->where('merchant_app_id', $merchantAppId)
|
||||
->where('method_id', $methodId)
|
||||
->where('status', 1)
|
||||
->orderBy('sort', 'asc')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function findByChanCode(string $chanCode): ?PaymentChannel
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('chan_code', $chanCode)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
34
app/repositories/PaymentMethodRepository.php
Normal file
34
app/repositories/PaymentMethodRepository.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* 支付方式仓储
|
||||
*/
|
||||
class PaymentMethodRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new PaymentMethod());
|
||||
}
|
||||
|
||||
public function getAllEnabled(): array
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('status', 1)
|
||||
->orderBy('sort', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function findByCode(string $methodCode): ?PaymentMethod
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('method_code', $methodCode)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
34
app/repositories/PaymentNotifyTaskRepository.php
Normal file
34
app/repositories/PaymentNotifyTaskRepository.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\PaymentNotifyTask;
|
||||
|
||||
/**
|
||||
* 商户通知任务仓储
|
||||
*/
|
||||
class PaymentNotifyTaskRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new PaymentNotifyTask());
|
||||
}
|
||||
|
||||
public function findByOrderId(string $orderId): ?PaymentNotifyTask
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('order_id', $orderId)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function getPendingRetryTasks(int $limit = 100): array
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('status', PaymentNotifyTask::STATUS_PENDING)
|
||||
->where('next_retry_at', '<=', date('Y-m-d H:i:s'))
|
||||
->limit($limit)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
56
app/repositories/PaymentOrderRepository.php
Normal file
56
app/repositories/PaymentOrderRepository.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\PaymentOrder;
|
||||
|
||||
/**
|
||||
* 支付订单仓储
|
||||
*/
|
||||
class PaymentOrderRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new PaymentOrder());
|
||||
}
|
||||
|
||||
public function findByOrderId(string $orderId): ?PaymentOrder
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('order_id', $orderId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据商户订单号查询(幂等校验)
|
||||
*/
|
||||
public function findByMchNo(int $merchantId, int $merchantAppId, string $mchOrderNo): ?PaymentOrder
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_id', $merchantId)
|
||||
->where('merchant_app_id', $merchantAppId)
|
||||
->where('mch_order_no', $mchOrderNo)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function updateStatus(string $orderId, int $status, array $extra = []): bool
|
||||
{
|
||||
$data = array_merge(['status' => $status], $extra);
|
||||
$order = $this->findByOrderId($orderId);
|
||||
return $order ? $this->updateById($order->id, $data) : false;
|
||||
}
|
||||
|
||||
public function updateChannelInfo(string $orderId, string $chanOrderNo, string $chanTradeNo = ''): bool
|
||||
{
|
||||
$order = $this->findByOrderId($orderId);
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
$data = ['chan_order_no' => $chanOrderNo];
|
||||
if ($chanTradeNo !== '') {
|
||||
$data['chan_trade_no'] = $chanTradeNo;
|
||||
}
|
||||
return $this->updateById($order->id, $data);
|
||||
}
|
||||
}
|
||||
32
app/repositories/PaymentPluginRepository.php
Normal file
32
app/repositories/PaymentPluginRepository.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\PaymentPlugin;
|
||||
|
||||
/**
|
||||
* 支付插件仓储
|
||||
*/
|
||||
class PaymentPluginRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new PaymentPlugin());
|
||||
}
|
||||
|
||||
public function getActivePlugins()
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('status', 1)
|
||||
->get(['plugin_code', 'class_name']);
|
||||
}
|
||||
|
||||
public function findActiveByCode(string $pluginCode): ?PaymentPlugin
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('plugin_code', $pluginCode)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\User;
|
||||
|
||||
/**
|
||||
* 用户仓储
|
||||
*/
|
||||
class UserRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new User());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户名查询用户
|
||||
*/
|
||||
public function findByUserName(string $userName): ?User
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $this->model
|
||||
->newQuery()
|
||||
->where('user_name', $userName)
|
||||
->first();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据主键查询并预加载角色
|
||||
*/
|
||||
public function findWithRoles(int $id): ?User
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $this->model
|
||||
->newQuery()
|
||||
->with('roles')
|
||||
->find($id);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,31 +9,43 @@
|
||||
|
||||
use Webman\Route;
|
||||
use app\http\admin\controller\AuthController;
|
||||
use app\http\admin\controller\UserController;
|
||||
use app\http\admin\controller\AdminController;
|
||||
use app\http\admin\controller\MenuController;
|
||||
use app\http\admin\controller\SystemController;
|
||||
use app\http\admin\controller\ChannelController;
|
||||
use app\http\admin\controller\PluginController;
|
||||
use app\common\middleware\Cors;
|
||||
use app\http\admin\middleware\AuthMiddleware;
|
||||
|
||||
Route::group('/adminapi', function () {
|
||||
// 认证相关(无需JWT验证)
|
||||
Route::get('/captcha', [AuthController::class, 'captcha']);
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
Route::get('/captcha', [AuthController::class, 'captcha'])->name('captcha')->setParams(['real_name' => '验证码']);
|
||||
Route::post('/login', [AuthController::class, 'login'])->name('login')->setParams(['real_name' => '登录']);
|
||||
|
||||
// 需要认证的路由组
|
||||
Route::group('', function () {
|
||||
// 用户相关(需要JWT验证)
|
||||
Route::get('/user/getUserInfo', [UserController::class, 'getUserInfo']);
|
||||
Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo'])->name('getUserInfo')->setParams(['real_name' => '获取管理员信息']);
|
||||
|
||||
// 菜单相关(需要JWT验证)
|
||||
Route::get('/menu/getRouters', [MenuController::class, 'getRouters']);
|
||||
Route::get('/menu/getRouters', [MenuController::class, 'getRouters'])->name('getRouters')->setParams(['real_name' => '获取菜单']);
|
||||
|
||||
// 系统相关(需要JWT验证)
|
||||
Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict']);
|
||||
Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict'])->name('getDict')->setParams(['real_name' => '获取字典']);
|
||||
|
||||
// 系统配置相关(需要JWT验证)
|
||||
Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig']);
|
||||
Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig']);
|
||||
Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig']);
|
||||
Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig'])->name('getTabsConfig')->setParams(['real_name' => '获取系统配置tabs']);
|
||||
Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig'])->name('getFormConfig')->setParams(['real_name' => '获取系统配置form']);
|
||||
Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig'])->name('submitConfig')->setParams(['real_name' => '提交系统配置']);
|
||||
|
||||
// 通道管理相关(需要JWT验证)
|
||||
Route::get('/channel/list', [ChannelController::class, 'list'])->name('list')->setParams(['real_name' => '获取通道列表']);
|
||||
Route::get('/channel/detail', [ChannelController::class, 'detail'])->name('detail')->setParams(['real_name' => '获取通道详情']);
|
||||
Route::post('/channel/save', [ChannelController::class, 'save'])->name('save')->setParams(['real_name' => '保存通道']);
|
||||
|
||||
// 插件管理相关(需要JWT验证)
|
||||
Route::get('/channel/plugins', [PluginController::class, 'plugins'])->name('plugins')->setParams(['real_name' => '获取插件列表']);
|
||||
Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema'])->name('configSchema')->setParams(['real_name' => '获取插件配置schema']);
|
||||
Route::get('/channel/plugin/products', [PluginController::class, 'products'])->name('products')->setParams(['real_name' => '获取插件产品列表']);
|
||||
})->middleware([AuthMiddleware::class]);
|
||||
})->middleware([Cors::class]);
|
||||
@@ -1,8 +1,20 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API 路由定义
|
||||
* API 路由定义(易支付接口标准)
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
use app\http\api\controller\EpayController;
|
||||
use app\http\api\middleware\EpayAuthMiddleware;
|
||||
|
||||
Route::group('', function () {
|
||||
// 页面跳转支付
|
||||
Route::any('/submit.php', [EpayController::class, 'submit']);
|
||||
|
||||
// API接口支付
|
||||
Route::post('/mapi.php', [EpayController::class, 'mapi']);
|
||||
|
||||
// API接口
|
||||
Route::get('/api.php', [EpayController::class, 'api']);
|
||||
})->middleware([EpayAuthMiddleware::class]);
|
||||
|
||||
37
app/services/AdminService.php
Normal file
37
app/services/AdminService.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exceptions\NotFoundException;
|
||||
use app\repositories\AdminRepository;
|
||||
|
||||
/**
|
||||
* 管理员业务服务
|
||||
*/
|
||||
class AdminService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected AdminRepository $adminRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取管理员信息
|
||||
*
|
||||
* @return array ['user' => array, 'roles' => array, 'permissions' => array]
|
||||
*/
|
||||
public function getInfoById(int $id): array
|
||||
{
|
||||
$admin = $this->adminRepository->find($id);
|
||||
if (!$admin) {
|
||||
throw new NotFoundException('管理员不存在');
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $admin->toArray(),
|
||||
'roles' => ['admin'],
|
||||
'permissions' => ['*:*:*'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,26 +5,25 @@ namespace app\services;
|
||||
use app\common\base\BaseService;
|
||||
use app\common\utils\JwtUtil;
|
||||
use app\exceptions\{BadRequestException, ForbiddenException, UnauthorizedException};
|
||||
use app\repositories\UserRepository;
|
||||
use app\models\Admin;
|
||||
use app\repositories\AdminRepository;
|
||||
use support\Cache;
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*
|
||||
* 处理登录、token 生成等认证相关业务
|
||||
* 处理管理员登录、token 生成等认证相关业务
|
||||
*/
|
||||
class AuthService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected UserRepository $userRepository,
|
||||
protected AdminRepository $adminRepository,
|
||||
protected CaptchaService $captchaService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* 登录成功后返回 token,前端使用该 token 通过 Authorization 请求头访问需要认证的接口
|
||||
* 管理员登录
|
||||
*
|
||||
* @param string $username 用户名
|
||||
* @param string $password 密码
|
||||
@@ -34,110 +33,64 @@ class AuthService extends BaseService
|
||||
*/
|
||||
public function login(string $username, string $password, string $verifyCode, string $captchaId): array
|
||||
{
|
||||
// 1. 校验验证码
|
||||
if (!$this->captchaService->validate($captchaId, $verifyCode)) {
|
||||
throw new BadRequestException('验证码错误或已失效');
|
||||
}
|
||||
|
||||
// 2. 查询用户
|
||||
$user = $this->userRepository->findByUserName($username);
|
||||
if (!$user) {
|
||||
$admin = $this->adminRepository->findByUserName($username);
|
||||
if (!$admin) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
// 3. 校验密码
|
||||
if (!$this->validatePassword($password, $user->password)) {
|
||||
if (!$this->validatePassword($password, $admin->password)) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
// 4. 检查用户状态
|
||||
if ($user->status !== 1) {
|
||||
if ($admin->status !== 1) {
|
||||
throw new ForbiddenException('账号已被禁用');
|
||||
}
|
||||
|
||||
// 5. 生成 JWT token(包含用户ID、用户名、昵称等信息)
|
||||
$token = $this->generateToken($user);
|
||||
$token = $this->generateToken($admin);
|
||||
$this->cacheToken($token, $admin->id);
|
||||
$this->updateLoginInfo($admin);
|
||||
|
||||
// 6. 将 token 信息存入 Redis(用于后续刷新、黑名单等)
|
||||
$this->cacheToken($token, $user->id);
|
||||
|
||||
// 7. 更新用户最后登录信息
|
||||
$this->updateLoginInfo($user);
|
||||
|
||||
// 返回 token,前端使用该 token 访问需要认证的接口
|
||||
return [
|
||||
'token' => $token,
|
||||
];
|
||||
return ['token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验密码
|
||||
*
|
||||
* @param string $password 明文密码
|
||||
* @param string|null $hash 数据库中的密码hash
|
||||
* @return bool
|
||||
*/
|
||||
private function validatePassword(string $password, ?string $hash): bool
|
||||
{
|
||||
// 如果数据库密码为空,允许使用默认密码(仅用于开发/演示)
|
||||
if ($hash === null || $hash === '') {
|
||||
// 开发环境:允许 admin/123456 和 common/123456 无密码登录
|
||||
// 生产环境应移除此逻辑
|
||||
return in_array($password, ['123456'], true);
|
||||
}
|
||||
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JWT token
|
||||
*
|
||||
* @param \app\models\User $user
|
||||
* @return string
|
||||
*/
|
||||
private function generateToken($user): string
|
||||
private function generateToken(Admin $admin): string
|
||||
{
|
||||
$payload = [
|
||||
'user_id' => $user->id,
|
||||
'user_name' => $user->user_name,
|
||||
'nick_name' => $user->nick_name,
|
||||
'user_id' => $admin->id,
|
||||
'user_name' => $admin->user_name,
|
||||
'nick_name' => $admin->nick_name,
|
||||
];
|
||||
|
||||
return JwtUtil::generateToken($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 token 信息缓存到 Redis
|
||||
*
|
||||
* @param string $token
|
||||
* @param int $userId
|
||||
*/
|
||||
private function cacheToken(string $token, int $userId): void
|
||||
private function cacheToken(string $token, int $adminId): void
|
||||
{
|
||||
$key = JwtUtil::getCachePrefix() . $token;
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$data = ['user_id' => $adminId, 'created_at' => time()];
|
||||
Cache::set($key, $data, JwtUtil::getTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户登录信息
|
||||
*
|
||||
* @param \app\models\User $user
|
||||
*/
|
||||
private function updateLoginInfo($user): void
|
||||
private function updateLoginInfo(Admin $admin): void
|
||||
{
|
||||
// 获取客户端真实IP(优先使用 x-real-ip,其次 x-forwarded-for,最后 remoteIp)
|
||||
$request = request();
|
||||
$ip = $request->header('x-real-ip', '')
|
||||
?: ($request->header('x-forwarded-for', '') ? explode(',', $request->header('x-forwarded-for', ''))[0] : '')
|
||||
$ip = $request->header('x-real-ip', '')
|
||||
?: ($request->header('x-forwarded-for', '') ? trim(explode(',', $request->header('x-forwarded-for', ''))[0]) : '')
|
||||
?: $request->getRemoteIp();
|
||||
|
||||
$user->login_ip = trim($ip);
|
||||
$user->login_at = date('Y-m-d H:i:s');
|
||||
$user->save();
|
||||
$admin->login_ip = trim($ip);
|
||||
$admin->login_at = date('Y-m-d H:i:s');
|
||||
$admin->save();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
app/services/ChannelRouterService.php
Normal file
42
app/services/ChannelRouterService.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exceptions\NotFoundException;
|
||||
use app\models\PaymentChannel;
|
||||
use app\repositories\PaymentChannelRepository;
|
||||
|
||||
/**
|
||||
* 通道路由服务
|
||||
*
|
||||
* 负责根据商户、应用、支付方式选择合适的通道
|
||||
*/
|
||||
class ChannelRouterService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentChannelRepository $channelRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择通道
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $merchantAppId 商户应用ID
|
||||
* @param int $methodId 支付方式ID
|
||||
* @return PaymentChannel
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function chooseChannel(int $merchantId, int $merchantAppId, int $methodId): PaymentChannel
|
||||
{
|
||||
$channel = $this->channelRepository->findAvailableChannel($merchantId, $merchantAppId, $methodId);
|
||||
|
||||
if (!$channel) {
|
||||
throw new NotFoundException("未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}");
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}
|
||||
}
|
||||
|
||||
121
app/services/NotifyService.php
Normal file
121
app/services/NotifyService.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\models\PaymentNotifyTask;
|
||||
use app\repositories\{PaymentNotifyTaskRepository, PaymentOrderRepository};
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 商户通知服务
|
||||
*
|
||||
* 负责向商户发送支付结果通知
|
||||
*/
|
||||
class NotifyService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentNotifyTaskRepository $notifyTaskRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通知任务
|
||||
* notify_url 从订单 extra 中获取(下单时由请求传入)
|
||||
*/
|
||||
public function createNotifyTask(string $orderId): void
|
||||
{
|
||||
$order = $this->orderRepository->findByOrderId($orderId);
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->notifyTaskRepository->findByOrderId($orderId);
|
||||
if ($existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notifyUrl = $order->extra['notify_url'] ?? '';
|
||||
if (empty($notifyUrl)) {
|
||||
Log::warning('订单缺少 notify_url,跳过创建通知任务', ['order_id' => $orderId]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifyTaskRepository->create([
|
||||
'order_id' => $orderId,
|
||||
'merchant_id' => $order->merchant_id,
|
||||
'merchant_app_id' => $order->merchant_app_id,
|
||||
'notify_url' => $notifyUrl,
|
||||
'notify_data' => json_encode([
|
||||
'order_id' => $order->order_id,
|
||||
'mch_order_no' => $order->mch_order_no,
|
||||
'status' => $order->status,
|
||||
'amount' => $order->amount,
|
||||
'pay_time' => $order->pay_at,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
'status' => PaymentNotifyTask::STATUS_PENDING,
|
||||
'retry_cnt' => 0,
|
||||
'next_retry_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通知
|
||||
*/
|
||||
public function sendNotify(PaymentNotifyTask $task): bool
|
||||
{
|
||||
try {
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $task->notify_url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $task->notify_data);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$success = ($httpCode === 200 && strtolower(trim($response)) === 'success');
|
||||
|
||||
$this->notifyTaskRepository->updateById($task->id, [
|
||||
'status' => $success ? PaymentNotifyTask::STATUS_SUCCESS : PaymentNotifyTask::STATUS_PENDING,
|
||||
'retry_cnt' => $task->retry_cnt + 1,
|
||||
'last_notify_at' => date('Y-m-d H:i:s'),
|
||||
'last_response' => $response,
|
||||
'next_retry_at' => $success ? null : $this->calculateNextRetryTime($task->retry_cnt + 1),
|
||||
]);
|
||||
|
||||
return $success;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('发送通知失败', [
|
||||
'task_id' => $task->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->notifyTaskRepository->updateById($task->id, [
|
||||
'retry_cnt' => $task->retry_cnt + 1,
|
||||
'last_notify_at' => date('Y-m-d H:i:s'),
|
||||
'last_response' => $e->getMessage(),
|
||||
'next_retry_at' => $this->calculateNextRetryTime($task->retry_cnt + 1),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算下次重试时间(指数退避)
|
||||
*/
|
||||
private function calculateNextRetryTime(int $retryCount): string
|
||||
{
|
||||
$intervals = [60, 300, 900, 3600]; // 1分钟、5分钟、15分钟、1小时
|
||||
$interval = $intervals[min($retryCount - 1, count($intervals) - 1)] ?? 3600;
|
||||
return date('Y-m-d H:i:s', time() + $interval);
|
||||
}
|
||||
}
|
||||
|
||||
182
app/services/PayOrderService.php
Normal file
182
app/services/PayOrderService.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exceptions\{BadRequestException, NotFoundException};
|
||||
use app\models\PaymentOrder;
|
||||
use app\repositories\{MerchantAppRepository, PaymentChannelRepository, PaymentMethodRepository, PaymentOrderRepository};
|
||||
|
||||
/**
|
||||
* 支付订单服务
|
||||
*
|
||||
* 负责订单创建、统一下单、状态管理等
|
||||
*/
|
||||
class PayOrderService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantAppRepository $merchantAppRepository,
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PluginService $pluginService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
public function createOrder(array $data)
|
||||
{
|
||||
// 1. 基本参数校验
|
||||
$mchId = (int)($data['mch_id'] ?? $data['merchant_id'] ?? 0);
|
||||
$appId = (int)($data['app_id'] ?? 0);
|
||||
$mchNo = trim((string)($data['mch_no'] ?? $data['mch_order_no'] ?? ''));
|
||||
$methodCode = trim((string)($data['method_code'] ?? ''));
|
||||
$amount = (float)($data['amount'] ?? 0);
|
||||
$subject = trim((string)($data['subject'] ?? ''));
|
||||
|
||||
if ($mchId <= 0 || $appId <= 0) {
|
||||
throw new BadRequestException('商户或应用信息不完整');
|
||||
}
|
||||
if ($mchNo === '') {
|
||||
throw new BadRequestException('商户订单号不能为空');
|
||||
}
|
||||
if ($methodCode === '') {
|
||||
throw new BadRequestException('支付方式不能为空');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw new BadRequestException('订单金额必须大于0');
|
||||
}
|
||||
if ($subject === '') {
|
||||
throw new BadRequestException('订单标题不能为空');
|
||||
}
|
||||
|
||||
// 2. 查询支付方式ID
|
||||
$method = $this->methodRepository->findByCode($methodCode);
|
||||
if (!$method) {
|
||||
throw new BadRequestException('支付方式不存在');
|
||||
}
|
||||
|
||||
// 3. 幂等校验:同一商户应用下相同商户订单号只保留一条
|
||||
$existing = $this->orderRepository->findByMchNo($mchId, $appId, $mchNo);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// 4. 生成系统订单号
|
||||
$orderId = $this->generateOrderId();
|
||||
|
||||
// 5. 创建订单
|
||||
return $this->orderRepository->create([
|
||||
'order_id' => $orderId,
|
||||
'merchant_id' => $mchId,
|
||||
'merchant_app_id' => $appId,
|
||||
'mch_order_no' => $mchNo,
|
||||
'method_id' => $method->id,
|
||||
'channel_id' => $data['channel_id'] ?? $data['chan_id'] ?? 0,
|
||||
'amount' => $amount,
|
||||
'real_amount' => $amount,
|
||||
'fee' => $data['fee'] ?? 0.00,
|
||||
'subject' => $subject,
|
||||
'body' => $data['body'] ?? $subject,
|
||||
'status' => PaymentOrder::STATUS_PENDING,
|
||||
'client_ip' => $data['client_ip'] ?? '',
|
||||
'expire_at' => $data['expire_at'] ?? $data['expire_time'] ?? date('Y-m-d H:i:s', time() + 1800),
|
||||
'extra' => $data['extra'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单退款(供易支付等接口调用)
|
||||
*
|
||||
* @param array $data
|
||||
* - order_id: 系统订单号(必填)
|
||||
* - refund_amount: 退款金额(必填)
|
||||
* - refund_reason: 退款原因(可选)
|
||||
* @return array
|
||||
*/
|
||||
public function refundOrder(array $data): array
|
||||
{
|
||||
$orderId = (string)($data['order_id'] ?? $data['pay_order_id'] ?? '');
|
||||
$refundAmount = (float)($data['refund_amount'] ?? 0);
|
||||
|
||||
if ($orderId === '') {
|
||||
throw new BadRequestException('订单号不能为空');
|
||||
}
|
||||
if ($refundAmount <= 0) {
|
||||
throw new BadRequestException('退款金额必须大于0');
|
||||
}
|
||||
|
||||
// 1. 查询订单
|
||||
$order = $this->orderRepository->findByOrderId($orderId);
|
||||
if (!$order) {
|
||||
throw new NotFoundException('订单不存在');
|
||||
}
|
||||
|
||||
// 2. 验证订单状态
|
||||
if ($order->status !== PaymentOrder::STATUS_SUCCESS) {
|
||||
throw new BadRequestException('订单状态不允许退款');
|
||||
}
|
||||
|
||||
// 3. 验证退款金额
|
||||
if ($refundAmount > $order->amount) {
|
||||
throw new BadRequestException('退款金额不能大于订单金额');
|
||||
}
|
||||
|
||||
// 4. 查询通道
|
||||
$channel = $this->channelRepository->find($order->channel_id);
|
||||
if (!$channel) {
|
||||
throw new NotFoundException('支付通道不存在');
|
||||
}
|
||||
|
||||
// 5. 查询支付方式
|
||||
$method = $this->methodRepository->find($order->method_id);
|
||||
if (!$method) {
|
||||
throw new NotFoundException('支付方式不存在');
|
||||
}
|
||||
|
||||
// 6. 实例化插件并初始化(通过插件服务)
|
||||
$plugin = $this->pluginService->getPluginInstance($channel->plugin_code);
|
||||
|
||||
$channelConfig = array_merge(
|
||||
$channel->getConfigArray(),
|
||||
['enabled_products' => $channel->getEnabledProducts()]
|
||||
);
|
||||
$plugin->init($method->method_code, $channelConfig);
|
||||
|
||||
// 7. 调用插件退款
|
||||
$refundData = [
|
||||
'order_id' => $order->order_id,
|
||||
'chan_order_no' => $order->chan_order_no,
|
||||
'chan_trade_no' => $order->chan_trade_no,
|
||||
'refund_amount' => $refundAmount,
|
||||
'refund_reason' => $data['refund_reason'] ?? '',
|
||||
];
|
||||
|
||||
$refundResult = $plugin->refund($refundData, $channelConfig);
|
||||
|
||||
// 8. 如果是全额退款则关闭订单
|
||||
if ($refundAmount >= $order->amount) {
|
||||
$this->orderRepository->updateById($order->id, [
|
||||
'status' => PaymentOrder::STATUS_CLOSED,
|
||||
'extra' => array_merge($order->extra ?? [], [
|
||||
'refund_info' => $refundResult,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'order_id' => $order->order_id,
|
||||
'refund_amount' => $refundAmount,
|
||||
'refund_result' => $refundResult,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成支付订单号
|
||||
*/
|
||||
private function generateOrderId(): string
|
||||
{
|
||||
return 'P' . date('YmdHis') . mt_rand(100000, 999999);
|
||||
}
|
||||
}
|
||||
161
app/services/PayService.php
Normal file
161
app/services/PayService.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exceptions\NotFoundException;
|
||||
use app\models\PaymentOrder;
|
||||
use app\repositories\{PaymentMethodRepository, PaymentOrderRepository};
|
||||
use app\common\contracts\AbstractPayPlugin;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 支付服务
|
||||
*
|
||||
* 负责聚合支付流程:通道路由、插件调用、订单更新等
|
||||
*/
|
||||
class PayService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PayOrderService $payOrderService,
|
||||
protected ChannelRouterService $channelRouterService,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PluginService $pluginService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 统一支付:创建订单(含幂等)、选择通道、调用插件统一下单
|
||||
*
|
||||
* @param array $orderData 内部订单数据
|
||||
* - mch_id, app_id, mch_no, method_code, amount, subject, body, client_ip, extra...
|
||||
* @param array $options 额外选项
|
||||
* - device: 设备类型(pc/mobile/wechat/alipay/qq/jump)
|
||||
* - request: Request 对象(用于从 UA 检测环境)
|
||||
* @return array
|
||||
* - order_id
|
||||
* - mch_no
|
||||
* - pay_params
|
||||
*/
|
||||
public function unifiedPay(array $orderData, array $options = []): array
|
||||
{
|
||||
// 1. 创建订单(幂等)
|
||||
/** @var PaymentOrder $order */
|
||||
$order = $this->payOrderService->createOrder($orderData);
|
||||
|
||||
// 2. 查询支付方式
|
||||
$method = $this->methodRepository->find($order->method_id);
|
||||
if (!$method) {
|
||||
throw new NotFoundException('支付方式不存在');
|
||||
}
|
||||
|
||||
// 3. 通道路由
|
||||
$channel = $this->channelRouterService->chooseChannel(
|
||||
$order->merchant_id,
|
||||
$order->merchant_app_id,
|
||||
$order->method_id
|
||||
);
|
||||
|
||||
// 4. 实例化插件并初始化(通过插件服务)
|
||||
$plugin = $this->pluginService->getPluginInstance($channel->plugin_code);
|
||||
|
||||
$channelConfig = array_merge(
|
||||
$channel->getConfigArray(),
|
||||
['enabled_products' => $channel->getEnabledProducts()]
|
||||
);
|
||||
$plugin->init($method->method_code, $channelConfig);
|
||||
|
||||
// 5. 环境检测
|
||||
$device = $options['device'] ?? '';
|
||||
/** @var Request|null $request */
|
||||
$request = $options['request'] ?? null;
|
||||
|
||||
if ($device) {
|
||||
$env = $this->mapDeviceToEnv($device);
|
||||
} elseif ($request instanceof Request) {
|
||||
$env = $this->detectEnvironment($request);
|
||||
} else {
|
||||
$env = AbstractPayPlugin::ENV_PC;
|
||||
}
|
||||
|
||||
// 6. 调用插件统一下单
|
||||
$pluginOrderData = [
|
||||
'order_id' => $order->order_id,
|
||||
'mch_no' => $order->mch_order_no,
|
||||
'amount' => $order->amount,
|
||||
'subject' => $order->subject,
|
||||
'body' => $order->body,
|
||||
];
|
||||
|
||||
$payResult = $plugin->unifiedOrder($pluginOrderData, $channelConfig, $env);
|
||||
|
||||
// 7. 计算实际支付金额(扣除手续费)
|
||||
$fee = $order->fee > 0 ? $order->fee : ($order->amount * ($channel->chan_cost / 100));
|
||||
$realAmount = $order->amount - $fee;
|
||||
|
||||
// 8. 更新订单(通道、支付参数、实际金额)
|
||||
$extra = $order->extra ?? [];
|
||||
$extra['pay_params'] = $payResult['pay_params'] ?? null;
|
||||
$chanOrderNo = $payResult['chan_order_no'] ?? $payResult['channel_order_no'] ?? '';
|
||||
$chanTradeNo = $payResult['chan_trade_no'] ?? $payResult['channel_trade_no'] ?? '';
|
||||
|
||||
$this->orderRepository->updateById($order->id, [
|
||||
'channel_id' => $channel->id,
|
||||
'chan_order_no' => $chanOrderNo,
|
||||
'chan_trade_no' => $chanTradeNo,
|
||||
'real_amount' => $realAmount,
|
||||
'fee' => $fee,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
return [
|
||||
'order_id' => $order->order_id,
|
||||
'mch_no' => $order->mch_order_no,
|
||||
'pay_params' => $payResult['pay_params'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求 UA 检测环境
|
||||
*/
|
||||
private function detectEnvironment(Request $request): string
|
||||
{
|
||||
$ua = strtolower($request->header('User-Agent', ''));
|
||||
|
||||
if (strpos($ua, 'alipayclient') !== false) {
|
||||
return AbstractPayPlugin::ENV_ALIPAY_CLIENT;
|
||||
}
|
||||
|
||||
if (strpos($ua, 'micromessenger') !== false) {
|
||||
return AbstractPayPlugin::ENV_WECHAT;
|
||||
}
|
||||
|
||||
$mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
|
||||
foreach ($mobileKeywords as $keyword) {
|
||||
if (strpos($ua, $keyword) !== false) {
|
||||
return AbstractPayPlugin::ENV_H5;
|
||||
}
|
||||
}
|
||||
|
||||
return AbstractPayPlugin::ENV_PC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射设备类型到环境代码
|
||||
*/
|
||||
private function mapDeviceToEnv(string $device): string
|
||||
{
|
||||
$mapping = [
|
||||
'pc' => AbstractPayPlugin::ENV_PC,
|
||||
'mobile' => AbstractPayPlugin::ENV_H5,
|
||||
'qq' => AbstractPayPlugin::ENV_H5,
|
||||
'wechat' => AbstractPayPlugin::ENV_WECHAT,
|
||||
'alipay' => AbstractPayPlugin::ENV_ALIPAY_CLIENT,
|
||||
'jump' => AbstractPayPlugin::ENV_PC,
|
||||
];
|
||||
|
||||
return $mapping[strtolower($device)] ?? AbstractPayPlugin::ENV_PC;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
122
app/services/PluginService.php
Normal file
122
app/services/PluginService.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\contracts\AbstractPayPlugin;
|
||||
use app\exceptions\NotFoundException;
|
||||
use app\repositories\PaymentPluginRepository;
|
||||
|
||||
/**
|
||||
* 插件服务
|
||||
*
|
||||
* 负责与支付插件注册表和具体插件交互,供后台控制器等调用
|
||||
*/
|
||||
class PluginService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $pluginRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用插件列表
|
||||
*
|
||||
* @return array<array{code:string,name:string,supported_methods:array}>
|
||||
*/
|
||||
public function listPlugins(): array
|
||||
{
|
||||
$rows = $this->pluginRepository->getActivePlugins();
|
||||
|
||||
$plugins = [];
|
||||
foreach ($rows as $row) {
|
||||
$pluginCode = $row->plugin_code;
|
||||
|
||||
try {
|
||||
$plugin = $this->resolvePlugin($pluginCode, $row->class_name);
|
||||
$plugins[] = [
|
||||
'code' => $pluginCode,
|
||||
'name' => $plugin::getName(),
|
||||
'supported_methods'=> $plugin::getSupportedMethods(),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略无法实例化的插件
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置 Schema
|
||||
*/
|
||||
public function getConfigSchema(string $pluginCode, string $methodCode): array
|
||||
{
|
||||
$plugin = $this->getPluginInstance($pluginCode);
|
||||
return $plugin::getConfigSchema($methodCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件支持的支付产品列表
|
||||
*/
|
||||
public function getSupportedProducts(string $pluginCode, string $methodCode): array
|
||||
{
|
||||
$plugin = $this->getPluginInstance($pluginCode);
|
||||
return $plugin::getSupportedProducts($methodCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从表单数据中提取插件配置参数(根据插件 Schema)
|
||||
*/
|
||||
public function buildConfigFromForm(string $pluginCode, string $methodCode, array $formData): array
|
||||
{
|
||||
$plugin = $this->getPluginInstance($pluginCode);
|
||||
$configSchema = $plugin::getConfigSchema($methodCode);
|
||||
|
||||
$configJson = [];
|
||||
if (isset($configSchema['fields']) && is_array($configSchema['fields'])) {
|
||||
foreach ($configSchema['fields'] as $field) {
|
||||
$fieldName = $field['field'] ?? '';
|
||||
if ($fieldName && array_key_exists($fieldName, $formData)) {
|
||||
$configJson[$fieldName] = $formData[$fieldName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $configJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外统一提供:根据插件编码获取插件实例
|
||||
*/
|
||||
public function getPluginInstance(string $pluginCode): AbstractPayPlugin
|
||||
{
|
||||
$row = $this->pluginRepository->findActiveByCode($pluginCode);
|
||||
if (!$row) {
|
||||
throw new NotFoundException('支付插件未注册或已禁用:' . $pluginCode);
|
||||
}
|
||||
|
||||
return $this->resolvePlugin($pluginCode, $row->class_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据插件编码和 class_name 解析并实例化插件
|
||||
*/
|
||||
private function resolvePlugin(string $pluginCode, ?string $className = null): AbstractPayPlugin
|
||||
{
|
||||
$class = $className ?: 'app\\common\\payment\\' . ucfirst($pluginCode) . 'Payment';
|
||||
|
||||
if (!class_exists($class)) {
|
||||
throw new NotFoundException('支付插件类不存在:' . $class);
|
||||
}
|
||||
|
||||
$plugin = new $class();
|
||||
if (!$plugin instanceof AbstractPayPlugin) {
|
||||
throw new NotFoundException('支付插件类型错误:' . $class);
|
||||
}
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constants\RoleCode;
|
||||
use app\exceptions\NotFoundException;
|
||||
use app\repositories\UserRepository;
|
||||
|
||||
/**
|
||||
* 用户相关业务服务示例
|
||||
*/
|
||||
class UserService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected UserRepository $users
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取用户信息(附带角色与权限)
|
||||
*
|
||||
* 返回结构尽量与前端 mock 的 /user/getUserInfo 保持一致:
|
||||
* {
|
||||
* "user": {...}, // 用户信息,roles 字段为角色对象数组
|
||||
* "roles": ["admin"], // 角色 code 数组
|
||||
* "permissions": ["*:*:*"] // 权限标识数组
|
||||
* }
|
||||
*/
|
||||
public function getUserInfoById(int $id): array
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
if (!$user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
$userArray = $user->toArray();
|
||||
|
||||
return [
|
||||
'user' => $userArray,
|
||||
'roles' => ['admin'],
|
||||
'permissions' => ['*:*:*'],
|
||||
];
|
||||
}
|
||||
}
|
||||
288
app/services/api/EpayService.php
Normal file
288
app/services/api/EpayService.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace app\services\api;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\services\PayOrderService;
|
||||
use app\services\PayService;
|
||||
use app\repositories\{MerchantAppRepository, PaymentMethodRepository, PaymentOrderRepository};
|
||||
use app\models\PaymentOrder;
|
||||
use app\exceptions\{BadRequestException, NotFoundException};
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 易支付服务
|
||||
*/
|
||||
class EpayService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PayOrderService $payOrderService,
|
||||
protected MerchantAppRepository $merchantAppRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PayService $payService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 页面跳转支付(submit.php)
|
||||
*
|
||||
* @param array $data 已通过验证的请求参数
|
||||
* @param Request $request 请求对象(用于环境检测)
|
||||
* @return array 包含 pay_order_id 与 pay_params
|
||||
*/
|
||||
public function submit(array $data, Request $request): array
|
||||
{
|
||||
// type 在文档中可选,这里如果为空暂不支持收银台模式
|
||||
if (empty($data['type'])) {
|
||||
throw new BadRequestException('暂不支持收银台模式,请指定支付方式 type');
|
||||
}
|
||||
|
||||
return $this->createUnifiedOrder($data, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 接口支付(mapi.php)
|
||||
*
|
||||
* @param array $data
|
||||
* @param Request $request
|
||||
* @return array 符合易支付文档的返回结构
|
||||
*/
|
||||
public function mapi(array $data, Request $request): array
|
||||
{
|
||||
$result = $this->createUnifiedOrder($data, $request);
|
||||
$payParams = $result['pay_params'] ?? [];
|
||||
|
||||
$response = [
|
||||
'code' => 1,
|
||||
'msg' => 'success',
|
||||
'trade_no' => $result['order_id'],
|
||||
];
|
||||
|
||||
if (!empty($payParams['type'])) {
|
||||
switch ($payParams['type']) {
|
||||
case 'redirect':
|
||||
$response['payurl'] = $payParams['url'] ?? '';
|
||||
break;
|
||||
case 'qrcode':
|
||||
$response['qrcode'] = $payParams['qrcode_url'] ?? $payParams['qrcode_data'] ?? '';
|
||||
break;
|
||||
case 'jsapi':
|
||||
if (!empty($payParams['urlscheme'])) {
|
||||
$response['urlscheme'] = $payParams['urlscheme'];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// 不识别的类型不返回额外字段
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 接口(api.php)- 处理 act=order / refund 等
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function api(array $data): array
|
||||
{
|
||||
$act = strtolower($data['act'] ?? '');
|
||||
|
||||
return match ($act) {
|
||||
'order' => $this->apiOrder($data),
|
||||
'refund' => $this->apiRefund($data),
|
||||
default => [
|
||||
'code' => 0,
|
||||
'msg' => '不支持的操作类型',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* api.php?act=order 查询单个订单
|
||||
*/
|
||||
private function apiOrder(array $data): array
|
||||
{
|
||||
$pid = (int)($data['pid'] ?? 0);
|
||||
$key = (string)($data['key'] ?? '');
|
||||
|
||||
if ($pid <= 0 || $key === '') {
|
||||
throw new BadRequestException('商户参数错误');
|
||||
}
|
||||
|
||||
$app = $this->merchantAppRepository->findByAppId((string)$pid);
|
||||
if (!$app || $app->app_secret !== $key) {
|
||||
throw new NotFoundException('商户不存在或密钥错误');
|
||||
}
|
||||
|
||||
$tradeNo = $data['trade_no'] ?? '';
|
||||
$outTradeNo = $data['out_trade_no'] ?? '';
|
||||
|
||||
if ($tradeNo === '' && $outTradeNo === '') {
|
||||
throw new BadRequestException('系统订单号与商户订单号不能同时为空');
|
||||
}
|
||||
|
||||
if ($tradeNo !== '') {
|
||||
$order = $this->orderRepository->findByOrderId($tradeNo);
|
||||
} else {
|
||||
$order = $this->orderRepository->findByMchNo($app->merchant_id, $app->id, $outTradeNo);
|
||||
}
|
||||
|
||||
if (!$order) {
|
||||
throw new NotFoundException('订单不存在');
|
||||
}
|
||||
|
||||
$methodCode = $this->getMethodCodeByOrder($order);
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询订单号成功!',
|
||||
'trade_no' => $order->order_id,
|
||||
'out_trade_no' => $order->mch_order_no,
|
||||
'api_trade_no' => $order->chan_trade_no ?? '',
|
||||
'type' => $this->mapMethodToEpayType($methodCode),
|
||||
'pid' => (int)$pid,
|
||||
'addtime' => $order->created_at,
|
||||
'endtime' => $order->pay_at,
|
||||
'name' => $order->subject,
|
||||
'money' => (string)$order->amount,
|
||||
'status' => $order->status === PaymentOrder::STATUS_SUCCESS ? 1 : 0,
|
||||
'param' => $order->extra['param'] ?? '',
|
||||
'buyer' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* api.php?act=refund 提交订单退款
|
||||
*/
|
||||
private function apiRefund(array $data): array
|
||||
{
|
||||
$pid = (int)($data['pid'] ?? 0);
|
||||
$key = (string)($data['key'] ?? '');
|
||||
|
||||
if ($pid <= 0 || $key === '') {
|
||||
throw new BadRequestException('商户参数错误');
|
||||
}
|
||||
|
||||
$app = $this->merchantAppRepository->findByAppId((string)$pid);
|
||||
if (!$app || $app->app_secret !== $key) {
|
||||
throw new NotFoundException('商户不存在或密钥错误');
|
||||
}
|
||||
|
||||
$tradeNo = $data['trade_no'] ?? '';
|
||||
$outTradeNo = $data['out_trade_no'] ?? '';
|
||||
$money = (float)($data['money'] ?? 0);
|
||||
|
||||
if ($tradeNo === '' && $outTradeNo === '') {
|
||||
throw new BadRequestException('系统订单号与商户订单号不能同时为空');
|
||||
}
|
||||
if ($money <= 0) {
|
||||
throw new BadRequestException('退款金额必须大于0');
|
||||
}
|
||||
|
||||
if ($tradeNo !== '') {
|
||||
$order = $this->orderRepository->findByOrderId($tradeNo);
|
||||
} else {
|
||||
$order = $this->orderRepository->findByMchNo($app->merchant_id, $app->id, $outTradeNo);
|
||||
}
|
||||
|
||||
if (!$order) {
|
||||
throw new NotFoundException('订单不存在');
|
||||
}
|
||||
|
||||
$refundResult = $this->payOrderService->refundOrder([
|
||||
'order_id' => $order->order_id,
|
||||
'refund_amount' => $money,
|
||||
]);
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'msg' => '退款成功',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单并调用插件统一下单
|
||||
*
|
||||
* @param array $data
|
||||
* @param Request $request
|
||||
* @return array
|
||||
*/
|
||||
private function createUnifiedOrder(array $data, Request $request): array
|
||||
{
|
||||
$pid = (int)($data['pid'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
throw new BadRequestException('商户ID不能为空');
|
||||
}
|
||||
|
||||
// 根据 pid 映射应用(约定 pid = app_id)
|
||||
$app = $this->merchantAppRepository->findByAppId((string)$pid);
|
||||
if (!$app || $app->status !== 1) {
|
||||
throw new NotFoundException('商户应用不存在或已禁用');
|
||||
}
|
||||
|
||||
$methodCode = $this->mapEpayTypeToMethod($data['type'] ?? '');
|
||||
$orderData = [
|
||||
'merchant_id' => $app->merchant_id,
|
||||
'app_id' => $app->id,
|
||||
'mch_order_no' => $data['out_trade_no'],
|
||||
'method_code' => $methodCode,
|
||||
'amount' => (float)$data['money'],
|
||||
'currency' => 'CNY',
|
||||
'subject' => $data['name'],
|
||||
'body' => $data['name'],
|
||||
'client_ip' => $data['clientip'] ?? $request->getRemoteIp(),
|
||||
'extra' => [
|
||||
'param' => $data['param'] ?? '',
|
||||
'notify_url' => $data['notify_url'] ?? '',
|
||||
'return_url' => $data['return_url'] ?? '',
|
||||
],
|
||||
];
|
||||
|
||||
// 调用通用支付服务完成通道选择与插件下单
|
||||
return $this->payService->unifiedPay($orderData, [
|
||||
'device' => $data['device'] ?? '',
|
||||
'request' => $request,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射易支付 type 到内部 method_code
|
||||
*/
|
||||
private function mapEpayTypeToMethod(string $type): string
|
||||
{
|
||||
$mapping = [
|
||||
'alipay' => 'alipay',
|
||||
'wxpay' => 'wechat',
|
||||
'qqpay' => 'qq',
|
||||
];
|
||||
|
||||
return $mapping[$type] ?? $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单获取支付方式编码
|
||||
*/
|
||||
private function getMethodCodeByOrder(PaymentOrder $order): string
|
||||
{
|
||||
$method = $this->methodRepository->find($order->method_id);
|
||||
return $method ? $method->method_code : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射内部 method_code 到易支付 type
|
||||
*/
|
||||
private function mapMethodToEpayType(string $methodCode): string
|
||||
{
|
||||
$mapping = [
|
||||
'alipay' => 'alipay',
|
||||
'wechat' => 'wxpay',
|
||||
'qq' => 'qqpay',
|
||||
];
|
||||
|
||||
return $mapping[$methodCode] ?? $methodCode;
|
||||
}
|
||||
|
||||
}
|
||||
122
app/validation/EpayValidator.php
Normal file
122
app/validation/EpayValidator.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\validation;
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
/**
|
||||
* 易支付参数验证器
|
||||
*
|
||||
* 根据 doc/epay.md 定义各接口所需参数规则
|
||||
*/
|
||||
class EpayValidator extends Validator
|
||||
{
|
||||
/**
|
||||
* 通用规则定义
|
||||
*
|
||||
* 通过场景选择实际需要的字段
|
||||
*/
|
||||
protected array $rules = [
|
||||
// 基础认证相关
|
||||
'pid' => 'required|integer',
|
||||
'key' => 'sometimes|string',
|
||||
|
||||
// 支付相关
|
||||
'type' => 'sometimes|string',
|
||||
'out_trade_no' => 'required|string|max:64',
|
||||
'trade_no' => 'sometimes|string|max:64',
|
||||
'notify_url' => 'required|url|max:255',
|
||||
'return_url' => 'sometimes|url|max:255',
|
||||
'name' => 'required|string|max:127',
|
||||
'money' => 'required|numeric|min:0.01',
|
||||
'clientip' => 'sometimes|ip',
|
||||
'device' => 'sometimes|string|in:pc,mobile,qq,wechat,alipay,jump',
|
||||
'param' => 'sometimes|string|max:255',
|
||||
|
||||
// 签名相关
|
||||
'sign' => 'required|string|size:32',
|
||||
'sign_type' => 'required|string|in:MD5,md5',
|
||||
|
||||
// API 动作
|
||||
'act' => 'required|string',
|
||||
'limit' => 'sometimes|integer|min:1|max:50',
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
];
|
||||
|
||||
protected array $messages = [];
|
||||
|
||||
protected array $attributes = [
|
||||
'pid' => '商户ID',
|
||||
'key' => '商户密钥',
|
||||
'type' => '支付方式',
|
||||
'out_trade_no' => '商户订单号',
|
||||
'trade_no' => '系统订单号',
|
||||
'notify_url' => '异步通知地址',
|
||||
'return_url' => '跳转通知地址',
|
||||
'name' => '商品名称',
|
||||
'money' => '商品金额',
|
||||
'clientip' => '用户IP地址',
|
||||
'device' => '设备类型',
|
||||
'param' => '业务扩展参数',
|
||||
'sign' => '签名字符串',
|
||||
'sign_type' => '签名类型',
|
||||
'act' => '操作类型',
|
||||
'limit' => '查询数量',
|
||||
'page' => '页码',
|
||||
];
|
||||
|
||||
/**
|
||||
* 不同接口场景
|
||||
*/
|
||||
protected array $scenes = [
|
||||
// 页面跳转支付 submit.php
|
||||
'submit' => [
|
||||
'pid',
|
||||
'type',
|
||||
'out_trade_no',
|
||||
'notify_url',
|
||||
'return_url',
|
||||
'name',
|
||||
'money',
|
||||
'param',
|
||||
'sign',
|
||||
'sign_type',
|
||||
],
|
||||
|
||||
// API 接口支付 mapi.php
|
||||
'mapi' => [
|
||||
'pid',
|
||||
'type',
|
||||
'out_trade_no',
|
||||
'notify_url',
|
||||
'return_url',
|
||||
'name',
|
||||
'money',
|
||||
'clientip',
|
||||
'device',
|
||||
'param',
|
||||
'sign',
|
||||
'sign_type',
|
||||
],
|
||||
|
||||
// api.php?act=order 查询单个订单
|
||||
'api_order' => [
|
||||
'act',
|
||||
'pid',
|
||||
'key',
|
||||
// trade_no 与 out_trade_no 至少一个,由业务层进一步校验
|
||||
],
|
||||
|
||||
// api.php?act=refund 提交退款
|
||||
'api_refund' => [
|
||||
'act',
|
||||
'pid',
|
||||
'key',
|
||||
'money',
|
||||
// trade_no/out_trade_no 至少一个
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ return [
|
||||
config_path(),
|
||||
base_path() . '/process',
|
||||
base_path() . '/support',
|
||||
base_path() . '/resource',
|
||||
// base_path() . '/resource',
|
||||
base_path() . '/.env',
|
||||
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
|
||||
// Files with these suffixes will be monitored
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
-- 系统配置表
|
||||
CREATE TABLE IF NOT EXISTS `ma_system_config` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`config_key` varchar(100) NOT NULL DEFAULT '' COMMENT '配置项键名(唯一标识,直接使用字段名)',
|
||||
`config_value` text COMMENT '配置项值(支持字符串、数字、JSON等)',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
|
||||
|
||||
248
database/mvp_payment_tables.sql
Normal file
248
database/mvp_payment_tables.sql
Normal file
@@ -0,0 +1,248 @@
|
||||
-- ============================================
|
||||
-- 支付系统核心表结构(优化版)
|
||||
-- ============================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- =======================
|
||||
-- 1. 商户表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_merchant`;
|
||||
CREATE TABLE `ma_merchant` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`merchant_no` varchar(32) NOT NULL DEFAULT '' COMMENT '商户号(唯一,对外标识)',
|
||||
`merchant_name` varchar(100) NOT NULL DEFAULT '' COMMENT '商户名称',
|
||||
`funds_mode` varchar(20) NOT NULL DEFAULT 'direct' COMMENT '资金模式:direct-直连, wallet-归集, hybrid-混合',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_merchant_no` (`merchant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户表';
|
||||
|
||||
-- =======================
|
||||
-- 2. 商户应用表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_merchant_app`;
|
||||
CREATE TABLE `ma_merchant_app` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID',
|
||||
`api_type` varchar(32) NOT NULL DEFAULT 'default' COMMENT '接口类型:openapi, epay, custom 等',
|
||||
`app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '应用ID',
|
||||
`app_secret` varchar(128) NOT NULL DEFAULT '' COMMENT '应用密钥',
|
||||
`app_name` varchar(100) NOT NULL DEFAULT '' COMMENT '应用名称',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_app_id` (`app_id`),
|
||||
KEY `idx_merchant_id` (`merchant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户应用表';
|
||||
|
||||
-- =======================
|
||||
-- 3. 支付方式字典表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_pay_method`;
|
||||
CREATE TABLE `ma_pay_method` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`method_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付方式编码,如 alipay,wechat',
|
||||
`method_name` varchar(50) NOT NULL DEFAULT '' COMMENT '支付方式名称',
|
||||
`icon` varchar(255) NOT NULL DEFAULT '' COMMENT '图标',
|
||||
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_method_code` (`method_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付方式字典表';
|
||||
|
||||
-- =======================
|
||||
-- 4. 支付插件注册表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_pay_plugin`;
|
||||
CREATE TABLE `ma_pay_plugin` (
|
||||
`plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码(主键)',
|
||||
`plugin_name` varchar(50) NOT NULL DEFAULT '' COMMENT '插件名称',
|
||||
`class_name` varchar(255) NOT NULL DEFAULT '' COMMENT '插件类名(完整命名空间)',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`plugin_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付插件注册表';
|
||||
|
||||
-- =======================
|
||||
-- 5. 支付通道表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_pay_channel`;
|
||||
CREATE TABLE `ma_pay_channel` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID(冗余,方便统计)',
|
||||
`merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID(关联 ma_merchant_app.id)',
|
||||
`chan_code` varchar(32) NOT NULL DEFAULT '' COMMENT '通道编码(唯一)',
|
||||
`chan_name` varchar(100) NOT NULL DEFAULT '' COMMENT '通道显示名称',
|
||||
`plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付插件编码',
|
||||
`method_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID(关联 ma_pay_method.id)',
|
||||
`config_json` json DEFAULT NULL COMMENT '通道插件配置参数(JSON,对应插件配置,包括 enabled_products 等)',
|
||||
`split_ratio` decimal(5,2) NOT NULL DEFAULT 100.00 COMMENT '分成比例(%)',
|
||||
`chan_cost` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '通道成本(%)',
|
||||
`chan_mode` varchar(50) NOT NULL DEFAULT 'wallet' COMMENT '通道模式:wallet-入余额, direct-直连到商户',
|
||||
`daily_limit` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '单日限额(元,0表示不限制)',
|
||||
`daily_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '单日限笔(0表示不限制)',
|
||||
`min_amount` decimal(12,2) DEFAULT NULL COMMENT '单笔最小金额(元,NULL表示不限制)',
|
||||
`max_amount` decimal(12,2) DEFAULT NULL COMMENT '单笔最大金额(元,NULL表示不限制)',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用',
|
||||
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序,越小优先级越高',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_chan_code` (`chan_code`),
|
||||
KEY `idx_mch_app_method` (`merchant_id`,`merchant_app_id`,`method_id`,`status`,`sort`),
|
||||
KEY `idx_plugin_method` (`plugin_code`,`method_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付通道表';
|
||||
|
||||
-- =======================
|
||||
-- 6. 支付订单表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_pay_order`;
|
||||
CREATE TABLE `ma_pay_order` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统生成,唯一)',
|
||||
`merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID',
|
||||
`merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID',
|
||||
`mch_order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号(幂等)',
|
||||
`method_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID(关联 ma_pay_method.id)',
|
||||
`channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID(关联 ma_pay_channel.id)',
|
||||
`amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)',
|
||||
`real_amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '实际支付金额(元,扣除手续费后)',
|
||||
`fee` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '手续费(元,可选,用于对账)',
|
||||
`currency` varchar(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,如 CNY',
|
||||
`subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题',
|
||||
`body` varchar(500) NOT NULL DEFAULT '' COMMENT '订单描述',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '订单状态:0-PENDING,1-SUCCESS,2-FAIL,3-CLOSED',
|
||||
`chan_order_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道订单号(渠道返回)',
|
||||
`chan_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道交易号(部分渠道有)',
|
||||
`pay_at` datetime DEFAULT NULL COMMENT '支付时间',
|
||||
`expire_at` datetime DEFAULT NULL COMMENT '订单过期时间',
|
||||
`client_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '客户端IP',
|
||||
`notify_stat` tinyint(1) NOT NULL DEFAULT 0 COMMENT '商户通知状态:0-未通知,1-已通知成功',
|
||||
`notify_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '通知次数',
|
||||
`extra` json DEFAULT NULL COMMENT '扩展字段(JSON,存储支付参数、退款信息等)',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_order_id` (`order_id`),
|
||||
UNIQUE KEY `uk_mch_order` (`merchant_id`,`merchant_app_id`,`mch_order_no`),
|
||||
KEY `idx_mch_app_created` (`merchant_id`,`merchant_app_id`,`created_at`),
|
||||
KEY `idx_method_id` (`method_id`),
|
||||
KEY `idx_channel_id` (`channel_id`),
|
||||
KEY `idx_status_created` (`status`,`created_at`),
|
||||
KEY `idx_pay_at` (`pay_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
|
||||
|
||||
-- =======================
|
||||
-- 7. 支付回调日志表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_pay_callback_log`;
|
||||
CREATE TABLE `ma_pay_callback_log` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统订单号)',
|
||||
`channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '通道ID(关联 ma_pay_channel.id)',
|
||||
`callback_type` varchar(20) NOT NULL DEFAULT '' COMMENT '回调类型:notify-异步通知, return-同步返回',
|
||||
`request_data` text COMMENT '请求原始数据(完整回调参数)',
|
||||
`verify_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '验签状态:0-失败,1-成功',
|
||||
`process_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '处理状态:0-未处理,1-已处理',
|
||||
`process_result` text COMMENT '处理结果(JSON或文本)',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order_created` (`order_id`,`created_at`),
|
||||
KEY `idx_channel_created` (`channel_id`,`created_at`),
|
||||
KEY `idx_callback_type` (`callback_type`),
|
||||
KEY `idx_verify_status` (`verify_status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调日志表';
|
||||
|
||||
-- =======================
|
||||
-- 8. 商户通知任务表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_notify_task`;
|
||||
CREATE TABLE `ma_notify_task` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统订单号)',
|
||||
`merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID',
|
||||
`merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID',
|
||||
`notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '通知地址',
|
||||
`notify_data` text COMMENT '通知数据(JSON格式)',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING-待通知,SUCCESS-成功,FAIL-失败',
|
||||
`retry_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数',
|
||||
`next_retry_at` datetime DEFAULT NULL COMMENT '下次重试时间',
|
||||
`last_notify_at` datetime DEFAULT NULL COMMENT '最后通知时间',
|
||||
`last_response` text COMMENT '最后响应内容',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_order_id` (`order_id`),
|
||||
KEY `idx_status_retry` (`status`,`next_retry_at`),
|
||||
KEY `idx_mch_app` (`merchant_id`,`merchant_app_id`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户通知任务表';
|
||||
|
||||
-- =======================
|
||||
-- 9. 系统配置表
|
||||
-- =======================
|
||||
DROP TABLE IF EXISTS `ma_system_config`;
|
||||
CREATE TABLE IF NOT EXISTS `ma_system_config` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`config_key` varchar(100) NOT NULL DEFAULT '' COMMENT '配置项键名(唯一标识,直接使用字段名)',
|
||||
`config_value` text COMMENT '配置项值(支持字符串、数字、JSON等)',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
|
||||
|
||||
-- =======================
|
||||
-- 10. 初始化基础数据
|
||||
-- =======================
|
||||
|
||||
-- 初始化支付方式字典
|
||||
INSERT INTO `ma_pay_method` (`method_code`, `method_name`, `icon`, `sort`, `status`) VALUES
|
||||
('alipay', '支付宝', '', 1, 1),
|
||||
('wechat', '微信支付', '', 2, 1),
|
||||
('unionpay','云闪付', '', 3, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`method_name` = VALUES(`method_name`),
|
||||
`icon` = VALUES(`icon`),
|
||||
`sort` = VALUES(`sort`),
|
||||
`status` = VALUES(`status`);
|
||||
|
||||
-- =======================
|
||||
-- 11. 管理员用户表(ma_admin)
|
||||
-- =======================
|
||||
|
||||
DROP TABLE IF EXISTS `ma_admin`;
|
||||
CREATE TABLE `ma_admin` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`user_name` varchar(50) NOT NULL DEFAULT '' COMMENT '用户名(登录账号,唯一)',
|
||||
`password` varchar(255) DEFAULT NULL COMMENT '登录密码hash(NULL 或空表示使用默认开发密码)',
|
||||
`nick_name` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称',
|
||||
`avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像地址',
|
||||
`mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号',
|
||||
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用',
|
||||
`login_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '最后登录IP',
|
||||
`login_at` datetime DEFAULT NULL COMMENT '最后登录时间',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_name` (`user_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员用户表';
|
||||
|
||||
-- 初始化一个超级管理员账号(开发环境默认密码 123456,对应 AuthService::validatePassword 逻辑)
|
||||
INSERT INTO `ma_admin` (`user_name`, `password`, `nick_name`, `status`, `created_at`)
|
||||
VALUES ('admin', NULL, '超级管理员', 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`nick_name` = VALUES(`nick_name`),
|
||||
`status` = VALUES(`status`);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
145
doc/auth_strategy_design.md
Normal file
145
doc/auth_strategy_design.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 认证策略设计说明
|
||||
|
||||
## 设计理念
|
||||
|
||||
采用**策略模式**替代中间件方式处理认证,具有以下优势:
|
||||
|
||||
1. **灵活扩展**:可以轻松添加新的接口标准(如易支付、OpenAPI、自定义标准等)
|
||||
2. **按需使用**:控制器可以根据需要选择认证策略,而不是在路由层面强制
|
||||
3. **易于测试**:策略类可以独立测试,不依赖中间件
|
||||
4. **代码复用**:不同接口可以共享相同的认证逻辑
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 1. 核心接口
|
||||
|
||||
**`AuthStrategyInterface`** - 认证策略接口
|
||||
```php
|
||||
interface AuthStrategyInterface
|
||||
{
|
||||
public function authenticate(Request $request): MerchantApp;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 策略实现
|
||||
|
||||
#### EpayAuthStrategy(易支付认证)
|
||||
- 使用 `pid` + `key` + `MD5签名`
|
||||
- 参数格式:`application/x-www-form-urlencoded`
|
||||
- 签名算法:MD5(排序后的参数字符串 + KEY)
|
||||
|
||||
#### OpenApiAuthStrategy(OpenAPI认证)
|
||||
- 使用 `app_id` + `timestamp` + `nonce` + `HMAC-SHA256签名`
|
||||
- 支持请求头或参数传递
|
||||
- 签名算法:HMAC-SHA256(签名字符串, app_secret)
|
||||
|
||||
### 3. 认证服务
|
||||
|
||||
**`AuthService`** - 认证服务,负责:
|
||||
- 自动检测接口标准类型
|
||||
- 根据类型选择对应的认证策略
|
||||
- 支持手动注册新的认证策略
|
||||
|
||||
```php
|
||||
// 自动检测
|
||||
$app = $authService->authenticate($request);
|
||||
|
||||
// 指定策略类型
|
||||
$app = $authService->authenticate($request, 'epay');
|
||||
|
||||
// 注册新策略
|
||||
$authService->registerStrategy('custom', CustomAuthStrategy::class);
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 控制器中使用
|
||||
|
||||
```php
|
||||
class PayController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PayOrderService $payOrderService,
|
||||
protected AuthService $authService
|
||||
) {
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
// 自动检测或指定策略类型
|
||||
$app = $this->authService->authenticate($request, 'epay');
|
||||
|
||||
// 使用 $app 进行后续业务处理
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的认证策略
|
||||
|
||||
1. **实现策略接口**
|
||||
```php
|
||||
class CustomAuthStrategy implements AuthStrategyInterface
|
||||
{
|
||||
public function authenticate(Request $request): MerchantApp
|
||||
{
|
||||
// 实现自定义认证逻辑
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **注册策略**
|
||||
```php
|
||||
// 在服务提供者或启动文件中
|
||||
$authService = new AuthService();
|
||||
$authService->registerStrategy('custom', CustomAuthStrategy::class);
|
||||
```
|
||||
|
||||
3. **在控制器中使用**
|
||||
```php
|
||||
$app = $this->authService->authenticate($request, 'custom');
|
||||
```
|
||||
|
||||
## 自动检测机制
|
||||
|
||||
`AuthService` 会根据请求特征自动检测接口标准:
|
||||
|
||||
- **易支付**:检测到 `pid` 参数
|
||||
- **OpenAPI**:检测到 `X-App-Id` 请求头或 `app_id` 参数
|
||||
|
||||
如果无法自动检测,可以手动指定策略类型。
|
||||
|
||||
## 优势对比
|
||||
|
||||
### 中间件方式(旧方案)
|
||||
- ❌ 路由配置复杂,每个接口标准需要不同的中间件
|
||||
- ❌ 难以在同一路由支持多种认证方式
|
||||
- ❌ 扩展新标准需要修改路由配置
|
||||
|
||||
### 策略模式(新方案)
|
||||
- ✅ 控制器按需选择认证策略
|
||||
- ✅ 同一路由可以支持多种认证方式(通过参数区分)
|
||||
- ✅ 扩展新标准只需实现策略接口并注册
|
||||
- ✅ 代码更清晰,职责分离
|
||||
|
||||
## 路由配置
|
||||
|
||||
由于不再使用中间件,路由配置更简洁:
|
||||
|
||||
```php
|
||||
// 易支付接口
|
||||
Route::any('/submit.php', [PayController::class, 'submit']);
|
||||
Route::post('/mapi.php', [PayController::class, 'mapi']);
|
||||
Route::get('/api.php', [PayController::class, 'queryOrder']);
|
||||
|
||||
// 所有接口都在控制器内部进行认证,无需中间件
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过策略模式重构认证逻辑,系统具备了:
|
||||
- **高扩展性**:轻松添加新的接口标准
|
||||
- **高灵活性**:控制器可以自由选择认证方式
|
||||
- **高可维护性**:代码结构清晰,易于理解和维护
|
||||
|
||||
216
doc/epay.md
Normal file
216
doc/epay.md
Normal file
@@ -0,0 +1,216 @@
|
||||
协议规则
|
||||
请求数据格式:application/x-www-form-urlencoded
|
||||
|
||||
返回数据格式:JSON
|
||||
|
||||
签名算法:MD5
|
||||
|
||||
字符编码:UTF-8
|
||||
|
||||
页面跳转支付
|
||||
此接口可用于用户前台直接发起支付,使用form表单跳转或拼接成url跳转。
|
||||
|
||||
URL地址:http://192.168.31.200:4000/submit.php
|
||||
|
||||
请求方式:POST 或 GET(推荐POST,不容易被劫持或屏蔽)
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
商户ID pid 是 Int 1001
|
||||
支付方式 type 否 String alipay 支付方式列表
|
||||
商户订单号 out_trade_no 是 String 20160806151343349
|
||||
异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址
|
||||
跳转通知地址 return_url 是 String http://www.pay.com/return_url.php 页面跳转通知地址
|
||||
商品名称 name 是 String VIP会员 如超过127个字节会自动截取
|
||||
商品金额 money 是 String 1.00 单位:元,最大2位小数
|
||||
业务扩展参数 param 否 String 没有请留空 支付后原样返回
|
||||
签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看
|
||||
签名类型 sign_type 是 String MD5 默认为MD5
|
||||
支付方式(type)不传会跳转到收银台支付
|
||||
|
||||
API接口支付
|
||||
此接口可用于服务器后端发起支付请求,会返回支付二维码链接或支付跳转url。
|
||||
|
||||
URL地址:http://192.168.31.200:4000/mapi.php
|
||||
|
||||
请求方式:POST
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
商户ID pid 是 Int 1001
|
||||
支付方式 type 是 String alipay 支付方式列表
|
||||
商户订单号 out_trade_no 是 String 20160806151343349
|
||||
异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址
|
||||
跳转通知地址 return_url 否 String http://www.pay.com/return_url.php 页面跳转通知地址
|
||||
商品名称 name 是 String VIP会员 如超过127个字节会自动截取
|
||||
商品金额 money 是 String 1.00 单位:元,最大2位小数
|
||||
用户IP地址 clientip 是 String 192.168.1.100 用户发起支付的IP地址
|
||||
设备类型 device 否 String pc 根据用户浏览器的UA判断,
|
||||
传入用户所使用的浏览器
|
||||
或设备类型,默认为pc
|
||||
设备类型列表
|
||||
业务扩展参数 param 否 String 没有请留空 支付后原样返回
|
||||
签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看
|
||||
签名类型 sign_type 是 String MD5 默认为MD5
|
||||
返回结果(json):
|
||||
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
返回信息 msg String 失败时返回原因
|
||||
订单号 trade_no String 20160806151343349 支付订单号
|
||||
支付跳转url payurl String http://192.168.31.200:4000/pay/wxpay/202010903/ 如果返回该字段,则直接跳转到该url支付
|
||||
二维码链接 qrcode String weixin://wxpay/bizpayurl?pr=04IPMKM 如果返回该字段,则根据该url生成二维码
|
||||
小程序跳转url urlscheme String weixin://dl/business/?ticket=xxx 如果返回该字段,则使用js跳转该url,可发起微信小程序支付
|
||||
注:payurl、qrcode、urlscheme 三个参数只会返回其中一个
|
||||
|
||||
支付结果通知
|
||||
通知类型:服务器异步通知(notify_url)、页面跳转通知(return_url)
|
||||
|
||||
请求方式:GET
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
商户ID pid 是 Int 1001
|
||||
易支付订单号 trade_no 是 String 20160806151343349021 聚合支付平台订单号
|
||||
商户订单号 out_trade_no 是 String 20160806151343349 商户系统内部的订单号
|
||||
支付方式 type 是 String alipay 支付方式列表
|
||||
商品名称 name 是 String VIP会员
|
||||
商品金额 money 是 String 1.00
|
||||
支付状态 trade_status 是 String TRADE_SUCCESS 只有TRADE_SUCCESS是成功
|
||||
业务扩展参数 param 否 String
|
||||
签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看
|
||||
签名类型 sign_type 是 String MD5 默认为MD5
|
||||
收到异步通知后,需返回success以表示服务器接收到了订单通知
|
||||
|
||||
MD5签名算法
|
||||
1、将发送或接收到的所有参数按照参数名ASCII码从小到大排序(a-z),sign、sign_type、和空值不参与签名!
|
||||
|
||||
2、将排序后的参数拼接成URL键值对的格式,例如 a=b&c=d&e=f,参数值不要进行url编码。
|
||||
|
||||
3、再将拼接好的字符串与商户密钥KEY进行MD5加密得出sign签名参数,sign = md5 ( a=b&c=d&e=f + KEY ) (注意:+ 为各语言的拼接符,不是字符!),md5结果为小写。
|
||||
|
||||
4、具体签名与发起支付的示例代码可下载SDK查看。
|
||||
|
||||
支付方式列表
|
||||
调用值 描述
|
||||
alipay 支付宝
|
||||
wxpay 微信支付
|
||||
qqpay QQ钱包
|
||||
设备类型列表
|
||||
调用值 描述
|
||||
pc 电脑浏览器
|
||||
mobile 手机浏览器
|
||||
qq 手机QQ内浏览器
|
||||
wechat 微信内浏览器
|
||||
alipay 支付宝客户端
|
||||
jump 仅返回支付跳转url
|
||||
[API]查询商户信息
|
||||
URL地址:http://192.168.31.200:4000/api.php?act=query&pid={商户ID}&key={商户密钥}
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
操作类型 act 是 String query 此API固定值
|
||||
商户ID pid 是 Int 1001
|
||||
商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||
返回结果:
|
||||
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
商户ID pid Int 1001 商户ID
|
||||
商户密钥 key String(32) 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i 商户密钥
|
||||
商户状态 active Int 1 1为正常,0为封禁
|
||||
商户余额 money String 0.00 商户所拥有的余额
|
||||
结算方式 type Int 1 1:支付宝,2:微信,3:QQ,4:银行卡
|
||||
结算账号 account String admin@pay.com 结算的支付宝账号
|
||||
结算姓名 username String 张三 结算的支付宝姓名
|
||||
订单总数 orders Int 30 订单总数统计
|
||||
今日订单 order_today Int 15 今日订单数量
|
||||
昨日订单 order_lastday Int 15 昨日订单数量
|
||||
[API]查询结算记录
|
||||
URL地址:http://192.168.31.200:4000/api.php?act=settle&pid={商户ID}&key={商户密钥}
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
操作类型 act 是 String settle 此API固定值
|
||||
商户ID pid 是 Int 1001
|
||||
商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||
返回结果:
|
||||
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
返回信息 msg String 查询结算记录成功!
|
||||
结算记录 data Array 结算记录列表
|
||||
[API]查询单个订单
|
||||
URL地址:http://192.168.31.200:4000/api.php?act=order&pid={商户ID}&key={商户密钥}&out_trade_no={商户订单号}
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
操作类型 act 是 String order 此API固定值
|
||||
商户ID pid 是 Int 1001
|
||||
商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||
系统订单号 trade_no 选择 String 20160806151343312
|
||||
商户订单号 out_trade_no 选择 String 20160806151343349
|
||||
提示:系统订单号 和 商户订单号 二选一传入即可,如果都传入以系统订单号为准!
|
||||
|
||||
返回结果:
|
||||
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
返回信息 msg String 查询订单号成功!
|
||||
易支付订单号 trade_no String 2016080622555342651 聚合支付平台订单号
|
||||
商户订单号 out_trade_no String 20160806151343349 商户系统内部的订单号
|
||||
第三方订单号 api_trade_no String 20160806151343349 支付宝微信等接口方订单号
|
||||
支付方式 type String alipay 支付方式列表
|
||||
商户ID pid Int 1001 发起支付的商户ID
|
||||
创建订单时间 addtime String 2016-08-06 22:55:52
|
||||
完成交易时间 endtime String 2016-08-06 22:55:52
|
||||
商品名称 name String VIP会员
|
||||
商品金额 money String 1.00
|
||||
支付状态 status Int 0 1为支付成功,0为未支付
|
||||
业务扩展参数 param String 默认留空
|
||||
支付者账号 buyer String 默认留空
|
||||
[API]批量查询订单
|
||||
URL地址:http://192.168.31.200:4000/api.php?act=orders&pid={商户ID}&key={商户密钥}
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
操作类型 act 是 String orders 此API固定值
|
||||
商户ID pid 是 Int 1001
|
||||
商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||
查询订单数量 limit 否 Int 20 返回的订单数量,最大50
|
||||
页码 page 否 Int 1 当前查询的页码
|
||||
返回结果:
|
||||
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
返回信息 msg String 查询结算记录成功!
|
||||
订单列表 data Array 订单列表
|
||||
[API]提交订单退款
|
||||
需要先在商户后台开启订单退款API接口开关,才能调用该接口发起订单退款
|
||||
|
||||
URL地址:http://192.168.31.200:4000/api.php?act=refund
|
||||
|
||||
请求方式:POST
|
||||
|
||||
请求参数说明:
|
||||
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
商户ID pid 是 Int 1001
|
||||
商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||
易支付订单号 trade_no 特殊可选 String 20160806151343349021 易支付订单号
|
||||
商户订单号 out_trade_no 特殊可选 String 20160806151343349 订单支付时传入的商户订单号,商家自定义且保证商家系统中唯一
|
||||
退款金额 money 是 String 1.50 少数通道需要与原订单金额一致
|
||||
注:trade_no、out_trade_no 不能同时为空,如果都传了以trade_no为准
|
||||
|
||||
返回结果:
|
||||
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 0 0为成功,其它值为失败
|
||||
返回信息 msg String 退款成功
|
||||
214
doc/order_table_design.md
Normal file
214
doc/order_table_design.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# 支付订单表设计说明
|
||||
|
||||
## 一、订单表设计原因
|
||||
|
||||
### 1.1 订单号设计(双重订单号)
|
||||
|
||||
**系统订单号 (`pay_order_id`)**
|
||||
- **作用**:系统内部唯一标识,用于查询、对账、退款等操作
|
||||
- **生成规则**:`P` + `YYYYMMDDHHmmss` + `6位随机数`(如:P20240101120000123456)
|
||||
- **唯一性**:通过 `uk_pay_order_id` 唯一索引保证
|
||||
- **优势**:
|
||||
- 全局唯一,不受商户影响
|
||||
- 便于系统内部查询和关联
|
||||
- 对账时作为主键
|
||||
|
||||
**商户订单号 (`mch_order_no`)**
|
||||
- **作用**:商户传入的订单号,用于幂等性校验
|
||||
- **唯一性**:通过 `uk_mch_order_no(merchant_id, mch_order_no)` 联合唯一索引保证
|
||||
- **优势**:
|
||||
- 同一商户下订单号唯一,防止重复提交
|
||||
- 商户侧可以自定义订单号规则
|
||||
- 支持商户订单号查询订单
|
||||
|
||||
**为什么需要两个订单号?**
|
||||
- 系统订单号:保证全局唯一,便于系统内部管理
|
||||
- 商户订单号:保证商户侧唯一,防止重复支付(幂等性)
|
||||
|
||||
### 1.2 关联关系设计
|
||||
|
||||
**商户与应用关联 (`merchant_id` + `app_id`)**
|
||||
- **作用**:标识订单所属商户和应用
|
||||
- **用途**:
|
||||
- 权限控制(商户只能查询自己的订单)
|
||||
- 对账统计(按商户/应用维度)
|
||||
- 通知路由(根据应用配置的通知地址)
|
||||
|
||||
**支付通道关联 (`channel_id`)**
|
||||
- **作用**:记录实际使用的支付通道
|
||||
- **用途**:
|
||||
- 退款时找到对应的插件和配置
|
||||
- 对账时关联通道信息
|
||||
- 统计通道使用情况
|
||||
|
||||
**支付方式与产品 (`method_code` + `product_code`)**
|
||||
- **method_code**:支付方式(alipay/wechat/unionpay)
|
||||
- 用于统计、筛选、报表
|
||||
- **product_code**:支付产品(alipay_h5/alipay_life/wechat_jsapi等)
|
||||
- 由插件根据用户环境自动选择
|
||||
- 用于记录实际使用的支付产品
|
||||
|
||||
### 1.3 金额字段设计
|
||||
|
||||
**订单金额 (`amount`)**
|
||||
- 商户实际收款金额(扣除手续费前)
|
||||
- 用于退款金额校验、对账
|
||||
|
||||
**手续费 (`fee`)**
|
||||
- 可选字段,记录通道手续费
|
||||
- 用于对账、结算、利润统计
|
||||
- 如果不需要详细记录手续费,可以留空或通过 `extra` 存储
|
||||
|
||||
**币种 (`currency`)**
|
||||
- 默认 CNY,支持国际化扩展
|
||||
- 预留字段,便于后续支持多币种
|
||||
|
||||
### 1.4 状态流转设计
|
||||
|
||||
```
|
||||
PENDING(待支付)
|
||||
├─> SUCCESS(支付成功)← 收到渠道回调并验签通过
|
||||
├─> FAIL(支付失败)← 用户取消、超时、渠道返回失败
|
||||
└─> CLOSED(已关闭)← 全额退款后
|
||||
```
|
||||
|
||||
**状态说明**:
|
||||
- **PENDING**:订单创建后,等待用户支付
|
||||
- **SUCCESS**:支付成功,已收到渠道回调并验签通过
|
||||
- **FAIL**:支付失败(用户取消、订单超时、渠道返回失败等)
|
||||
- **CLOSED**:已关闭(全额退款后)
|
||||
|
||||
### 1.5 渠道信息设计
|
||||
|
||||
**渠道订单号 (`channel_order_no`)**
|
||||
- 渠道返回的订单号
|
||||
- 用于查询订单状态、退款等操作
|
||||
|
||||
**渠道交易号 (`channel_trade_no`)**
|
||||
- 部分渠道有交易号概念(如支付宝的 trade_no)
|
||||
- 用于对账、查询等
|
||||
|
||||
### 1.6 通知机制设计
|
||||
|
||||
**通知状态 (`notify_status`)**
|
||||
- 0:未通知
|
||||
- 1:已通知成功
|
||||
|
||||
**通知次数 (`notify_count`)**
|
||||
- 记录通知次数,用于重试控制
|
||||
- 配合 `ma_notify_task` 表实现异步通知
|
||||
|
||||
### 1.7 扩展性设计
|
||||
|
||||
**扩展字段 (`extra`)**
|
||||
- JSON 格式,存储:
|
||||
- 支付参数(`pay_params`):前端支付所需的参数
|
||||
- 退款信息(`refund_info`):退款结果
|
||||
- 自定义字段:业务扩展字段
|
||||
|
||||
**订单过期时间 (`expire_time`)**
|
||||
- 用于自动关闭超时订单
|
||||
- 默认 30 分钟,可配置
|
||||
|
||||
## 二、索引设计说明
|
||||
|
||||
### 2.1 唯一索引
|
||||
|
||||
- **`uk_pay_order_id`**:保证系统订单号唯一
|
||||
- **`uk_mch_order_no(merchant_id, mch_order_no)`**:保证同一商户下商户订单号唯一(幂等性)
|
||||
|
||||
### 2.2 普通索引
|
||||
|
||||
- **`idx_merchant_app(merchant_id, app_id)`**:商户/应用维度查询
|
||||
- **`idx_channel_id`**:通道维度查询
|
||||
- **`idx_method_code`**:支付方式维度统计
|
||||
- **`idx_status`**:状态筛选
|
||||
- **`idx_pay_time`**:按支付时间查询(对账、统计)
|
||||
- **`idx_created_at`**:按创建时间查询(分页、统计)
|
||||
|
||||
## 三、可能遗漏的字段(后续扩展)
|
||||
|
||||
### 3.1 退款相关字段
|
||||
|
||||
如果后续需要支持**部分退款**或**多次退款**,可以考虑添加:
|
||||
|
||||
```sql
|
||||
`refund_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '已退款金额(累计)',
|
||||
`refund_status` varchar(20) NOT NULL DEFAULT '' COMMENT '退款状态:PENDING-退款中, SUCCESS-退款成功, FAIL-退款失败',
|
||||
`refund_time` datetime DEFAULT NULL COMMENT '最后退款时间',
|
||||
```
|
||||
|
||||
**当前设计**:
|
||||
- 退款信息存储在 `extra['refund_info']` 中
|
||||
- 全额退款后订单状态改为 `CLOSED`
|
||||
- 如果只需要全额退款,当前设计已足够
|
||||
|
||||
### 3.2 结算相关字段
|
||||
|
||||
如果后续需要**分账/结算**功能,可以考虑添加:
|
||||
|
||||
```sql
|
||||
`settlement_status` varchar(20) NOT NULL DEFAULT '' COMMENT '结算状态:PENDING-待结算, SUCCESS-已结算, FAIL-结算失败',
|
||||
`settlement_time` datetime DEFAULT NULL COMMENT '结算时间',
|
||||
`settlement_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '结算金额',
|
||||
```
|
||||
|
||||
**当前设计**:
|
||||
- 结算信息可以通过 `extra` 存储
|
||||
- 如果不需要复杂的结算流程,当前设计已足够
|
||||
|
||||
### 3.3 风控相关字段
|
||||
|
||||
如果需要**风控功能**,可以考虑添加:
|
||||
|
||||
```sql
|
||||
`risk_level` varchar(20) NOT NULL DEFAULT '' COMMENT '风险等级:LOW-低, MEDIUM-中, HIGH-高',
|
||||
`risk_score` int(11) NOT NULL DEFAULT 0 COMMENT '风险评分',
|
||||
`risk_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '风险原因',
|
||||
```
|
||||
|
||||
**当前设计**:
|
||||
- 风控信息可以通过 `extra` 存储
|
||||
- 如果不需要复杂的风控系统,当前设计已足够
|
||||
|
||||
### 3.4 其他扩展字段
|
||||
|
||||
- **`user_id`**:用户ID(如果需要关联用户)
|
||||
- **`device_info`**:设备信息(用于风控)
|
||||
- **`remark`**:备注(管理员备注)
|
||||
- **`close_reason`**:关闭原因(用户取消/超时/管理员关闭等)
|
||||
|
||||
## 四、设计原则总结
|
||||
|
||||
1. **幂等性**:通过 `uk_mch_order_no` 保证同一商户下订单号唯一
|
||||
2. **可追溯性**:记录完整的订单信息、渠道信息、时间信息
|
||||
3. **可扩展性**:通过 `extra` JSON 字段存储扩展信息
|
||||
4. **性能优化**:合理的索引设计,支持常见查询场景
|
||||
5. **业务完整性**:覆盖订单全生命周期(创建→支付→退款→关闭)
|
||||
|
||||
## 五、与代码的对应关系
|
||||
|
||||
| SQL 字段 | 代码字段 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `pay_order_id` | `pay_order_id` | 系统订单号 |
|
||||
| `merchant_id` | `merchant_id` | 商户ID |
|
||||
| `app_id` | `app_id` | 应用ID |
|
||||
| `mch_order_no` | `mch_order_no` | 商户订单号 |
|
||||
| `method_code` | `method_code` | 支付方式 |
|
||||
| `product_code` | `product_code` | 支付产品 |
|
||||
| `channel_id` | `channel_id` | 通道ID |
|
||||
| `amount` | `amount` | 订单金额 |
|
||||
| `currency` | `currency` | 币种 |
|
||||
| `status` | `status` | 订单状态 |
|
||||
| `channel_order_no` | `channel_order_no` | 渠道订单号 |
|
||||
| `channel_trade_no` | `channel_trade_no` | 渠道交易号 |
|
||||
| `extra` | `extra` | 扩展字段(JSON) |
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
1. **字段命名统一**:SQL 和代码中的字段名必须一致
|
||||
2. **索引维护**:定期检查索引使用情况,优化慢查询
|
||||
3. **数据归档**:历史订单数据量大时,考虑归档策略
|
||||
4. **JSON 字段**:`extra` 字段使用 JSON 类型,便于扩展但查询性能略低
|
||||
5. **时间字段**:`pay_time`、`expire_time` 等时间字段使用 `datetime` 类型,便于查询和统计
|
||||
|
||||
485
doc/payment_flow.md
Normal file
485
doc/payment_flow.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# 支付订单发起流程说明
|
||||
|
||||
## 一、业务系统调用统一下单接口
|
||||
|
||||
### 1. 接口地址
|
||||
```
|
||||
POST /api/pay/unifiedOrder
|
||||
```
|
||||
|
||||
### 2. 请求头(签名认证)
|
||||
```
|
||||
X-App-Id: app001 # 应用ID
|
||||
X-Timestamp: 1704067200 # 时间戳(Unix秒)
|
||||
X-Nonce: abc123xyz # 随机字符串
|
||||
X-Signature: calculated_signature # 签名(HMAC-SHA256)
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 3. 签名算法
|
||||
|
||||
**待签名字符串**:
|
||||
```
|
||||
app_id={app_id}×tamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={body_sha256}
|
||||
```
|
||||
|
||||
**计算签名**:
|
||||
```php
|
||||
$bodySha256 = hash('sha256', json_encode($requestBody));
|
||||
$signString = "app_id={app_id}×tamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={bodySha256}";
|
||||
$signature = hash_hmac('sha256', $signString, $appSecret);
|
||||
```
|
||||
|
||||
### 4. 请求体示例
|
||||
|
||||
```json
|
||||
{
|
||||
"mch_order_no": "ORDER202401011200001",
|
||||
"pay_method": "alipay",
|
||||
"amount": 100.00,
|
||||
"currency": "CNY",
|
||||
"subject": "测试商品",
|
||||
"body": "测试商品描述"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `mch_order_no`:商户订单号(必填,唯一,用于幂等)
|
||||
- `pay_method`:支付方式(必填,如:alipay、wechat、unionpay)
|
||||
- `amount`:订单金额(必填,单位:元)
|
||||
- `currency`:币种(可选,默认:CNY)
|
||||
- `subject`:订单标题(必填)
|
||||
- `body`:订单描述(可选)
|
||||
|
||||
### 5. 调用示例(cURL)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/pay/unifiedOrder \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-App-Id: app001" \
|
||||
-H "X-Timestamp: 1704067200" \
|
||||
-H "X-Nonce: abc123xyz" \
|
||||
-H "X-Signature: calculated_signature" \
|
||||
-d '{
|
||||
"mch_order_no": "ORDER202401011200001",
|
||||
"pay_method": "alipay",
|
||||
"amount": 100.00,
|
||||
"subject": "测试商品",
|
||||
"body": "测试商品描述"
|
||||
}'
|
||||
```
|
||||
|
||||
### 6. PHP调用示例
|
||||
|
||||
```php
|
||||
<?php
|
||||
$appId = 'app001';
|
||||
$appSecret = 'your_app_secret';
|
||||
$baseUrl = 'http://localhost:8787';
|
||||
|
||||
// 准备请求数据
|
||||
$requestBody = [
|
||||
'mch_order_no' => 'ORDER202401011200001',
|
||||
'pay_method' => 'alipay',
|
||||
'amount' => 100.00,
|
||||
'subject' => '测试商品',
|
||||
'body' => '测试商品描述'
|
||||
];
|
||||
|
||||
// 计算签名
|
||||
$timestamp = time();
|
||||
$nonce = uniqid();
|
||||
$bodyJson = json_encode($requestBody);
|
||||
$bodySha256 = hash('sha256', $bodyJson);
|
||||
$signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={$bodySha256}";
|
||||
$signature = hash_hmac('sha256', $signString, $appSecret);
|
||||
|
||||
// 发送请求
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $baseUrl . '/api/pay/unifiedOrder');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
"X-App-Id: {$appId}",
|
||||
"X-Timestamp: {$timestamp}",
|
||||
"X-Nonce: {$nonce}",
|
||||
"X-Signature: {$signature}",
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if ($httpCode === 200 && $result['code'] === 200) {
|
||||
echo "支付订单号:" . $result['data']['pay_order_id'] . "\n";
|
||||
echo "支付参数:" . json_encode($result['data']['pay_params'], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
} else {
|
||||
echo "错误:" . $result['msg'] . "\n";
|
||||
}
|
||||
```
|
||||
|
||||
## 二、服务端处理流程
|
||||
|
||||
### 流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant BizSystem as 业务系统
|
||||
participant OpenAPI as OpenAPI接口
|
||||
participant AuthMW as 签名中间件
|
||||
participant PayService as 订单服务
|
||||
participant ChannelRouter as 通道路由
|
||||
participant PluginFactory as 插件工厂
|
||||
participant Plugin as 支付插件
|
||||
participant Channel as 第三方渠道
|
||||
|
||||
BizSystem->>OpenAPI: POST /api/pay/unifiedOrder
|
||||
OpenAPI->>AuthMW: 验证签名
|
||||
AuthMW->>PayService: 调用统一下单
|
||||
PayService->>PayService: 1. 验证商户应用
|
||||
PayService->>PayService: 2. 幂等校验
|
||||
PayService->>PayService: 3. 创建支付订单
|
||||
PayService->>ChannelRouter: 4. 选择通道
|
||||
ChannelRouter-->>PayService: 返回通道信息
|
||||
PayService->>PluginFactory: 5. 实例化插件
|
||||
PluginFactory-->>PayService: 返回插件实例
|
||||
PayService->>Plugin: 6. 初始化插件(init)
|
||||
PayService->>Plugin: 7. 环境检测
|
||||
PayService->>Plugin: 8. 调用统一下单(unifiedOrder)
|
||||
Plugin->>Plugin: 8.1 根据环境选择产品
|
||||
Plugin->>Channel: 8.2 调用第三方接口
|
||||
Channel-->>Plugin: 返回支付参数
|
||||
Plugin-->>PayService: 返回支付结果
|
||||
PayService->>PayService: 9. 更新订单信息
|
||||
PayService-->>OpenAPI: 返回结果
|
||||
OpenAPI-->>BizSystem: 返回支付参数
|
||||
```
|
||||
|
||||
### 详细步骤说明
|
||||
|
||||
#### 步骤1:签名验证(中间件)
|
||||
- `OpenApiAuthMiddleware` 验证请求头中的签名
|
||||
- 验证时间戳(5分钟内有效)
|
||||
- 验证签名是否正确
|
||||
- 将应用信息注入到请求对象
|
||||
|
||||
#### 步骤2:验证商户应用
|
||||
- 根据 `app_id` 查询 `ma_merchant_app` 表
|
||||
- 检查应用状态是否启用
|
||||
|
||||
#### 步骤3:幂等校验
|
||||
- 根据 `merchant_id + mch_order_no` 查询是否已存在订单
|
||||
- 如果存在,直接返回已有订单信息(支持幂等)
|
||||
|
||||
#### 步骤4:创建支付订单
|
||||
- 生成支付订单号(格式:`P20240101120000123456`)
|
||||
- 创建 `ma_pay_order` 记录
|
||||
- 状态:`PENDING`(待支付)
|
||||
- 过期时间:30分钟后
|
||||
|
||||
#### 步骤5:通道路由选择
|
||||
- 根据 `merchant_id + app_id + method_code` 查找可用通道
|
||||
- 从 `ma_pay_channel` 表中查询
|
||||
- 选择第一个可用的通道(后续可扩展权重、容灾策略)
|
||||
|
||||
#### 步骤6:实例化插件
|
||||
- 在 `PayService` 中根据 `ma_pay_plugin` 注册表解析插件:优先使用表中的 `class_name`,否则按约定使用 `app\common\payment\{Code}Payment` 实例化插件
|
||||
- 例如:`plugin_code = 'lakala'` → 实例化 `LakalaPlugin`
|
||||
|
||||
#### 步骤7:初始化插件
|
||||
- 调用 `$plugin->init($methodCode, $channelConfig)`
|
||||
- 插件内部切换到指定支付方式的配置和逻辑
|
||||
- 例如:拉卡拉插件初始化到 `alipay` 模式
|
||||
|
||||
#### 步骤8:环境检测
|
||||
- 从请求头 `User-Agent` 判断用户环境
|
||||
- 环境类型:
|
||||
- `PC`:PC桌面浏览器
|
||||
- `H5`:H5手机浏览器
|
||||
- `WECHAT`:微信内浏览器
|
||||
- `ALIPAY_CLIENT`:支付宝客户端
|
||||
|
||||
#### 步骤9:调用插件统一下单
|
||||
- 调用 `$plugin->unifiedOrder($orderData, $channelConfig, $env)`
|
||||
- 插件内部处理:
|
||||
1. **产品选择**:从通道的 `enabled_products` 中,根据环境自动选择一个产品
|
||||
- 例如:H5环境 → 选择 `alipay_h5`
|
||||
- 例如:支付宝客户端 → 选择 `alipay_life`
|
||||
2. **调用第三方接口**:根据产品和支付方式,调用对应的第三方支付接口
|
||||
- 例如:拉卡拉插件的支付宝H5接口
|
||||
3. **返回支付参数**:返回给业务系统的支付参数
|
||||
|
||||
#### 步骤10:更新订单
|
||||
- 更新订单的 `product_code`(实际使用的产品)
|
||||
- 更新订单的 `channel_id`
|
||||
- 更新订单的 `channel_order_no`(渠道订单号)
|
||||
- 保存 `pay_params` 到 `extra` 字段
|
||||
|
||||
## 三、响应数据格式
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"pay_order_id": "P20240101120000123456",
|
||||
"status": "PENDING",
|
||||
"pay_params": {
|
||||
"type": "redirect",
|
||||
"url": "https://mapi.alipay.com/gateway.do?..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 支付参数类型
|
||||
|
||||
根据不同的支付产品和环境,`pay_params` 的格式不同:
|
||||
|
||||
#### 1. 跳转支付(H5/PC扫码)
|
||||
```json
|
||||
{
|
||||
"type": "redirect",
|
||||
"url": "https://mapi.alipay.com/gateway.do?xxx"
|
||||
}
|
||||
```
|
||||
业务系统需要:**跳转到该URL**
|
||||
|
||||
#### 2. 表单提交(H5)
|
||||
```json
|
||||
{
|
||||
"type": "form",
|
||||
"method": "POST",
|
||||
"action": "https://mapi.alipay.com/gateway.do",
|
||||
"fields": {
|
||||
"app_id": "xxx",
|
||||
"method": "alipay.trade.wap.pay",
|
||||
"biz_content": "{...}"
|
||||
}
|
||||
}
|
||||
```
|
||||
业务系统需要:**自动提交表单**
|
||||
|
||||
#### 3. JSAPI支付(微信内/支付宝生活号)
|
||||
```json
|
||||
{
|
||||
"type": "jsapi",
|
||||
"appId": "wx1234567890",
|
||||
"timeStamp": "1704067200",
|
||||
"nonceStr": "abc123",
|
||||
"package": "prepay_id=wx1234567890",
|
||||
"signType": "MD5",
|
||||
"paySign": "calculated_signature"
|
||||
}
|
||||
```
|
||||
业务系统需要:**调用微信/支付宝JSAPI**
|
||||
|
||||
#### 4. 二维码支付(PC扫码)
|
||||
```json
|
||||
{
|
||||
"type": "qrcode",
|
||||
"qrcode_url": "https://qr.alipay.com/xxx",
|
||||
"qrcode_data": "data:image/png;base64,..."
|
||||
}
|
||||
```
|
||||
业务系统需要:**展示二维码**
|
||||
|
||||
## 四、用户支付流程
|
||||
|
||||
### 1. 业务系统处理支付参数
|
||||
|
||||
根据 `pay_params.type` 进行不同处理:
|
||||
|
||||
```javascript
|
||||
// 前端处理示例
|
||||
const payParams = response.data.pay_params;
|
||||
|
||||
switch (payParams.type) {
|
||||
case 'redirect':
|
||||
// 跳转支付
|
||||
window.location.href = payParams.url;
|
||||
break;
|
||||
|
||||
case 'form':
|
||||
// 表单提交
|
||||
const form = document.createElement('form');
|
||||
form.method = payParams.method;
|
||||
form.action = payParams.action;
|
||||
Object.keys(payParams.fields).forEach(key => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = payParams.fields[key];
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
break;
|
||||
|
||||
case 'jsapi':
|
||||
// 微信JSAPI支付
|
||||
WeixinJSBridge.invoke('getBrandWCPayRequest', {
|
||||
appId: payParams.appId,
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonceStr,
|
||||
package: payParams.package,
|
||||
signType: payParams.signType,
|
||||
paySign: payParams.paySign
|
||||
}, function(res) {
|
||||
if (res.err_msg === "get_brand_wcpay_request:ok") {
|
||||
// 支付成功
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'qrcode':
|
||||
// 展示二维码
|
||||
document.getElementById('qrcode').src = payParams.qrcode_data;
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 用户完成支付
|
||||
|
||||
- 用户在第三方支付平台完成支付
|
||||
- 第三方平台异步回调到支付中心
|
||||
|
||||
### 3. 支付中心处理回调
|
||||
|
||||
- 接收回调:`POST /api/notify/alipay` 或 `/api/notify/wechat`
|
||||
- 验签:使用插件验证回调签名
|
||||
- 更新订单状态:`PENDING` → `SUCCESS` 或 `FAIL`
|
||||
- 创建通知任务:异步通知业务系统
|
||||
|
||||
### 4. 业务系统接收通知
|
||||
|
||||
- 支付中心异步通知业务系统的 `notify_url`
|
||||
- 业务系统验证签名并处理订单
|
||||
|
||||
## 五、查询订单接口
|
||||
|
||||
### 接口地址
|
||||
```
|
||||
GET /api/pay/query?pay_order_id=P20240101120000123456
|
||||
```
|
||||
|
||||
### 请求头(需要签名)
|
||||
```
|
||||
X-App-Id: app001
|
||||
X-Timestamp: 1704067200
|
||||
X-Nonce: abc123xyz
|
||||
X-Signature: calculated_signature
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"pay_order_id": "P20240101120000123456",
|
||||
"mch_order_no": "ORDER202401011200001",
|
||||
"status": "SUCCESS",
|
||||
"amount": 100.00,
|
||||
"pay_time": "2024-01-01 12:00:30"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 六、完整调用示例(Node.js)
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
|
||||
class PaymentClient {
|
||||
constructor(appId, appSecret, baseUrl) {
|
||||
this.appId = appId;
|
||||
this.appSecret = appSecret;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
// 计算签名
|
||||
calculateSignature(method, path, body, timestamp, nonce) {
|
||||
const bodySha256 = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
||||
const signString = `app_id=${this.appId}×tamp=${timestamp}&nonce=${nonce}&method=${method}&path=${path}&body_sha256=${bodySha256}`;
|
||||
return crypto.createHmac('sha256', this.appSecret).update(signString).digest('hex');
|
||||
}
|
||||
|
||||
// 统一下单
|
||||
async unifiedOrder(orderData) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonce = Math.random().toString(36).substring(7);
|
||||
const method = 'POST';
|
||||
const path = '/api/pay/unifiedOrder';
|
||||
|
||||
const signature = this.calculateSignature(method, path, orderData, timestamp, nonce);
|
||||
|
||||
const response = await axios.post(`${this.baseUrl}${path}`, orderData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App-Id': this.appId,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 查询订单
|
||||
async queryOrder(payOrderId) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonce = Math.random().toString(36).substring(7);
|
||||
const method = 'GET';
|
||||
const path = `/api/pay/query?pay_order_id=${payOrderId}`;
|
||||
|
||||
const signature = this.calculateSignature(method, path, {}, timestamp, nonce);
|
||||
|
||||
const response = await axios.get(`${this.baseUrl}${path}`, {
|
||||
headers: {
|
||||
'X-App-Id': this.appId,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const client = new PaymentClient('app001', 'your_app_secret', 'http://localhost:8787');
|
||||
|
||||
// 统一下单
|
||||
client.unifiedOrder({
|
||||
mch_order_no: 'ORDER202401011200001',
|
||||
pay_method: 'alipay',
|
||||
amount: 100.00,
|
||||
subject: '测试商品',
|
||||
body: '测试商品描述'
|
||||
}).then(result => {
|
||||
console.log('支付参数:', result.data.pay_params);
|
||||
// 根据 pay_params.type 处理支付
|
||||
}).catch(err => {
|
||||
console.error('下单失败:', err.message);
|
||||
});
|
||||
```
|
||||
|
||||
## 七、注意事项
|
||||
|
||||
1. **幂等性**:相同的 `mch_order_no` 多次调用,返回同一订单信息
|
||||
2. **签名有效期**:时间戳5分钟内有效
|
||||
3. **订单过期**:订单默认30分钟过期
|
||||
4. **环境检测**:系统自动根据UA判断环境,选择合适的产品
|
||||
5. **异步通知**:支付成功后,系统会异步通知业务系统的 `notify_url`
|
||||
6. **订单查询**:业务系统可通过查询接口主动查询订单状态
|
||||
|
||||
182
doc/payment_system_implementation.md
Normal file
182
doc/payment_system_implementation.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 支付系统核心实现说明
|
||||
|
||||
## 概述
|
||||
|
||||
已实现支付系统核心功能,包括:
|
||||
- 插件化支付通道系统(支持一个插件多个支付方式)
|
||||
- OpenAPI统一支付网关
|
||||
- 通道管理与配置
|
||||
- 订单管理与状态机
|
||||
- 异步通知机制
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
执行以下SQL脚本创建表结构:
|
||||
|
||||
```bash
|
||||
mysql -u用户名 -p 数据库名 < database/mvp_payment_tables.sql
|
||||
```
|
||||
|
||||
## 核心架构
|
||||
|
||||
### 1. 插件系统
|
||||
|
||||
- **插件接口**:`app/common/contracts/PayPluginInterface.php`
|
||||
- **抽象基类**:`app/common/contracts/AbstractPayPlugin.php`(提供环境检测、产品选择等通用功能)
|
||||
- **插件类示例**:`app/common/payment/LakalaPayment.php`(命名规范:`XxxPayment`)
|
||||
- **插件解析**:由 `PayService`、`PayOrderService`、`PluginService` 直接根据 `ma_pay_plugin` 注册表中配置的 `plugin_code` / `class_name` 解析并实例化插件(默认约定类名为 `app\common\payment\{Code}Payment`)
|
||||
|
||||
**插件特点**:
|
||||
- 一个插件可以支持多个支付方式(如拉卡拉插件支持 alipay/wechat/unionpay)
|
||||
- **支付产品由插件内部定义**,不需要数据库字典表
|
||||
- 插件根据用户环境(PC/H5/微信内/支付宝客户端)自动选择已开通的产品
|
||||
- 通道配置中,用户只需勾选确认开启了哪些产品(产品编码由插件定义)
|
||||
- 有些支付平台不区分产品,插件会根据通道配置自行处理
|
||||
- 通道配置表单由插件动态生成
|
||||
|
||||
### 2. 数据模型
|
||||
|
||||
- `Merchant`:商户
|
||||
- `MerchantApp`:商户应用(AppId/AppSecret)
|
||||
- `PayMethod`:支付方式(alipay/wechat等)
|
||||
- `PayChannel`:支付通道(绑定到"插件+支付方式",配置已开通的产品列表)
|
||||
- `PayOrder`:支付订单
|
||||
- `NotifyTask`:商户通知任务
|
||||
|
||||
**注意**:支付产品不由数据库管理,而是由插件通过 `getSupportedProducts()` 方法定义。通道配置中的 `enabled_products` 字段存储的是用户勾选的产品编码数组。
|
||||
|
||||
### 3. 服务层
|
||||
|
||||
- `PayOrderService`:订单业务编排(统一下单、查询)
|
||||
- `ChannelRouterService`:通道路由选择
|
||||
- `NotifyService`:商户通知服务
|
||||
|
||||
### 4. API接口
|
||||
|
||||
#### OpenAPI(对外支付网关)
|
||||
|
||||
- `POST /api/pay/unifiedOrder`:统一下单(需要签名认证)
|
||||
- `GET /api/pay/query`:查询订单(需要签名认证)
|
||||
- `POST /api/notify/alipay`:支付宝回调
|
||||
- `POST /api/notify/wechat`:微信回调
|
||||
|
||||
#### 管理后台API
|
||||
|
||||
- `GET /adminapi/channel/plugins`:获取所有可用插件
|
||||
- `GET /adminapi/channel/plugin/config-schema`:获取插件配置表单Schema
|
||||
- `GET /adminapi/channel/plugin/products`:获取插件支持的支付产品
|
||||
- `GET /adminapi/channel/list`:通道列表
|
||||
- `GET /adminapi/channel/detail`:通道详情
|
||||
- `POST /adminapi/channel/save`:保存通道
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 创建商户和应用
|
||||
|
||||
```sql
|
||||
INSERT INTO ma_merchant (merchant_no, merchant_name, funds_mode, status)
|
||||
VALUES ('M001', '测试商户', 'direct', 1);
|
||||
|
||||
INSERT INTO ma_merchant_app (merchant_id, app_id, app_secret, app_name, notify_url, status)
|
||||
VALUES (1, 'app001', 'secret_key_here', '测试应用', 'https://example.com/notify', 1);
|
||||
```
|
||||
|
||||
### 2. 配置支付通道
|
||||
|
||||
**配置流程**:
|
||||
1. 创建通道:选择支付方式、支付插件,配置通道基本信息(显示名称、分成比例、通道成本、通道模式、限额等)
|
||||
2. 配置插件参数:通道创建后,再配置该通道的插件参数信息(通过插件的配置表单动态生成)
|
||||
|
||||
通过管理后台或直接操作数据库:
|
||||
|
||||
```sql
|
||||
INSERT INTO ma_pay_channel (
|
||||
merchant_id, app_id, channel_code, channel_name,
|
||||
plugin_code, method_code, enabled_products, config_json,
|
||||
split_ratio, channel_cost, channel_mode,
|
||||
daily_limit, daily_count, min_amount, max_amount,
|
||||
status
|
||||
) VALUES (
|
||||
1, 1, 'CH001', '拉卡拉-支付宝通道',
|
||||
'lakala', 'alipay',
|
||||
'["alipay_h5", "alipay_life"]',
|
||||
'{"merchant_id": "lakala_merchant", "secret_key": "xxx", "api_url": "https://api.lakala.com"}',
|
||||
100.00, 0.00, 'wallet',
|
||||
0.00, 0, NULL, NULL,
|
||||
1
|
||||
);
|
||||
```
|
||||
|
||||
**通道字段说明**:
|
||||
- `split_ratio`: 分成比例(%),默认100.00
|
||||
- `channel_cost`: 通道成本(%),默认0.00
|
||||
- `channel_mode`: 通道模式,`wallet`-支付金额扣除手续费后加入商户余额,`direct`-直连到商户
|
||||
- `daily_limit`: 单日限额(元),0表示不限制
|
||||
- `daily_count`: 单日限笔,0表示不限制
|
||||
- `min_amount`: 单笔最小金额(元),NULL表示不限制
|
||||
- `max_amount`: 单笔最大金额(元),NULL表示不限制
|
||||
|
||||
### 3. 调用统一下单接口
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/pay/unifiedOrder \
|
||||
-H "X-App-Id: app001" \
|
||||
-H "X-Timestamp: 1234567890" \
|
||||
-H "X-Nonce: abc123" \
|
||||
-H "X-Signature: calculated_signature" \
|
||||
-d '{
|
||||
"mch_order_no": "ORDER001",
|
||||
"pay_method": "alipay",
|
||||
"amount": 100.00,
|
||||
"subject": "测试订单",
|
||||
"body": "测试订单描述"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. 签名算法
|
||||
|
||||
```
|
||||
signString = "app_id={app_id}×tamp={timestamp}&nonce={nonce}&method={method}&path={path}&body_sha256={body_sha256}"
|
||||
signature = HMAC-SHA256(signString, app_secret)
|
||||
```
|
||||
|
||||
## 扩展新插件
|
||||
|
||||
1. 创建插件类,继承 `AbstractPayPlugin`,并按照 `XxxPayment` 命名放在 `app/common/payment` 目录:
|
||||
|
||||
```php
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\contracts\AbstractPayPlugin;
|
||||
|
||||
class AlipayPayment extends AbstractPayPlugin
|
||||
{
|
||||
public static function getCode(): string { return 'alipay'; }
|
||||
public static function getName(): string { return '支付宝直连'; }
|
||||
public static function getSupportedMethods(): array { return ['alipay']; }
|
||||
// ... 实现其他方法
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `ma_pay_plugin` 表中注册插件信息(也可通过后台管理界面维护):
|
||||
|
||||
```sql
|
||||
INSERT INTO ma_pay_plugin (plugin_code, plugin_name, class_name, status)
|
||||
VALUES ('alipay', '支付宝直连', 'app\\common\\payment\\AlipayPayment', 1);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **支付产品定义**:支付产品由插件内部通过 `getSupportedProducts()` 方法定义,不需要数据库字典表。通道配置时,用户只需勾选已开通的产品编码。
|
||||
2. **环境检测**:插件基类提供 `detectEnvironment()` 方法,可根据UA判断环境
|
||||
3. **产品选择**:插件根据环境从通道已开通产品中自动选择。如果通道配置为空或不区分产品,插件会根据配置自行处理。
|
||||
4. **通知重试**:使用 `NotifyMerchantJob` 异步重试通知,支持指数退避
|
||||
5. **幂等性**:统一下单接口支持幂等,相同 `mch_order_no` 返回已有订单
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- 账务系统(账户、分录、余额)
|
||||
- 结算系统(可结算金额、结算批次、打款)
|
||||
- 对账系统(渠道账单导入、差异处理)
|
||||
- 风控系统(规则引擎、风险预警)
|
||||
|
||||
481
doc/skill.md
481
doc/skill.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理系统,提供完整的支付业务管理功能,包括用户认证、菜单管理、系统配置、财务管理、渠道管理和数据分析等核心模块。
|
||||
MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理系统,核心聚焦支付业务:商户管理、通道配置、统一支付、易支付兼容、商户通知等。管理后台提供管理员认证、菜单、系统配置、通道与插件管理;对外提供 OpenAPI 与易支付标准接口。
|
||||
|
||||
## 2. 技术架构
|
||||
|
||||
@@ -10,46 +10,38 @@ MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理
|
||||
|
||||
| 类别 | 技术/框架 | 版本 | 用途 | 来源 |
|
||||
|------|-----------|------|------|------|
|
||||
| 基础框架 | Webman | ^2.1 | 高性能HTTP服务框架 | composer.json:28 |
|
||||
| PHP版本 | PHP | >=8.1 | 开发语言 | composer.json:27 |
|
||||
| 数据库 | webman/database | ^2.1 | 数据库操作 | composer.json:31 |
|
||||
| 缓存 | Redis | ^2.1 | 缓存存储 | composer.json:32 |
|
||||
| 缓存 | webman/cache | ^2.1 | 缓存管理 | composer.json:34 |
|
||||
| 认证 | JWT | ^7.0 | 用户认证 | composer.json:42 |
|
||||
| 验证码 | webman/captcha | ^1.0 | 登录验证码 | composer.json:37 |
|
||||
| 事件系统 | webman/event | ^1.0 | 事件管理 | composer.json:38 |
|
||||
| 配置管理 | vlucas/phpdotenv | ^5.6 | 环境变量 | composer.json:39 |
|
||||
| 定时任务 | workerman/crontab | ^1.0 | 定时任务 | composer.json:40 |
|
||||
| 队列 | webman/redis-queue | ^2.1 | 消息队列 | composer.json:41 |
|
||||
| 验证 | topthink/think-validate | ^3.0 | 数据验证 | composer.json:36 |
|
||||
| 容器 | php-di/php-di | 7.0 | 依赖注入 | composer.json:30 |
|
||||
| 日志 | monolog/monolog | ^2.0 | 日志管理 | composer.json:29 |
|
||||
| 控制台 | webman/console | ^2.1 | 命令行工具 | composer.json:35 |
|
||||
| 基础框架 | Webman | ^2.1 | 高性能HTTP服务框架 | composer.json |
|
||||
| PHP版本 | PHP | >=8.1 | 开发语言 | composer.json |
|
||||
| 数据库 | webman/database | ^2.1 | 数据库操作 | composer.json |
|
||||
| 缓存 | Redis | ^2.1 | 缓存存储 | composer.json |
|
||||
| 缓存 | webman/cache | ^2.1 | 缓存管理 | composer.json |
|
||||
| 认证 | JWT | ^7.0 | 管理员认证 | composer.json |
|
||||
| 验证码 | webman/captcha | ^1.0 | 登录验证码 | composer.json |
|
||||
| 事件系统 | webman/event | ^1.0 | 事件管理 | composer.json |
|
||||
| 配置管理 | vlucas/phpdotenv | ^5.6 | 环境变量 | composer.json |
|
||||
| 定时任务 | workerman/crontab | ^1.0 | 定时任务 | composer.json |
|
||||
| 队列 | webman/redis-queue | ^2.1 | 消息队列 | composer.json |
|
||||
| 验证 | topthink/think-validate | ^3.0 | 数据验证 | composer.json |
|
||||
| 容器 | php-di/php-di | 7.0 | 依赖注入 | composer.json |
|
||||
| 日志 | monolog/monolog | ^2.0 | 日志管理 | composer.json |
|
||||
| 控制台 | webman/console | ^2.1 | 命令行工具 | composer.json |
|
||||
|
||||
### 2.2 前端技术栈
|
||||
|
||||
| 类别 | 技术/框架 | 版本 | 用途 | 来源 |
|
||||
|------|-----------|------|------|------|
|
||||
| 基础框架 | Vue | ^3.5.15 | 前端框架 | package.json:61 |
|
||||
| 语言 | TypeScript | ^5.2.2 | 开发语言 | package.json:103 |
|
||||
| 构建工具 | Vite | ^6.3.5 | 构建工具 | package.json:107 |
|
||||
| UI框架 | Arco Design | ^2.57.0 | 界面组件库 | package.json:72 |
|
||||
| 状态管理 | Pinia | ^2.3.0 | 状态管理 | package.json:53 |
|
||||
| 路由 | Vue Router | ^4.3.0 | 前端路由 | package.json:66 |
|
||||
| HTTP客户端 | Axios | ^1.6.8 | API调用 | package.json:47 |
|
||||
| 表单生成 | @form-create/arco-design | ^3.2.37 | 动态表单 | package.json:41 |
|
||||
| 图表 | @visactor/vchart | ^1.11.0 | 数据可视化 | package.json:42 |
|
||||
| 代码编辑器 | CodeMirror | ^6.0.1 | 代码编辑 | package.json:48 |
|
||||
| 富文本编辑器 | @wangeditor/editor | ^5.1.23 | 内容编辑 | package.json:45 |
|
||||
| 国际化 | vue-i18n | 10.0.0-alpha.3 | 多语言支持 | package.json:64 |
|
||||
| 工具库 | @vueuse/core | ^12.4.0 | 实用工具 | package.json:44 |
|
||||
| 指纹识别 | @fingerprintjs/fingerprintjs | ^4.6.2 | 设备识别 | package.json:40 |
|
||||
| 二维码 | qrcode | ^1.5.4 | 二维码生成 | package.json:57 |
|
||||
| 条码 | jsbarcode | ^3.11.6 | 条码生成 | package.json:51 |
|
||||
| 打印 | print-js | ^1.6.0 | 页面打印 | package.json:56 |
|
||||
| 进度条 | nprogress | ^0.2.0 | 加载进度 | package.json:52 |
|
||||
| 中文转拼音 | pinyin-pro | ^3.26.0 | 拼音转换 | package.json:55 |
|
||||
| 引导 | driver.js | ^1.3.1 | 功能引导 | package.json:49 |
|
||||
| 基础框架 | Vue | ^3.5.15 | 前端框架 | package.json |
|
||||
| 语言 | TypeScript | ^5.2.2 | 开发语言 | package.json |
|
||||
| 构建工具 | Vite | ^6.3.5 | 构建工具 | package.json |
|
||||
| UI框架 | Arco Design | ^2.57.0 | 界面组件库 | package.json |
|
||||
| 状态管理 | Pinia | ^2.3.0 | 状态管理 | package.json |
|
||||
| 路由 | Vue Router | ^4.3.0 | 前端路由 | package.json |
|
||||
| HTTP客户端 | Axios | ^1.6.8 | API调用 | package.json |
|
||||
| 表单生成 | @form-create/arco-design | ^3.2.37 | 动态表单 | package.json |
|
||||
| 图表 | @visactor/vchart | ^1.11.0 | 数据可视化 | package.json |
|
||||
| 国际化 | vue-i18n | 10.0.0-alpha.3 | 多语言支持 | package.json |
|
||||
| 工具库 | @vueuse/core | ^12.4.0 | 实用工具 | package.json |
|
||||
| 二维码 | qrcode | ^1.5.4 | 二维码生成 | package.json |
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
@@ -57,267 +49,264 @@ MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理
|
||||
|
||||
```
|
||||
d:\phpstudy_pro\WWW\mpay\mpay_v2_webman\
|
||||
├── app/ # 应用代码
|
||||
│ ├── common/ # 通用代码
|
||||
│ │ ├── base/ # 基础类
|
||||
├── app/ # 应用代码
|
||||
│ ├── common/ # 通用代码
|
||||
│ │ ├── base/ # 基础类
|
||||
│ │ │ ├── BaseController.php
|
||||
│ │ │ ├── BaseModel.php
|
||||
│ │ │ ├── BaseRepository.php
|
||||
│ │ │ └── BaseService.php
|
||||
│ │ ├── constants/ # 常量
|
||||
│ │ │ └── YesNo.php
|
||||
│ │ ├── enums/ # 枚举
|
||||
│ │ │ └── MenuType.php
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ │ ├── Cors.php
|
||||
│ │ │ └── StaticFile.php
|
||||
│ │ └── utils/ # 工具类
|
||||
│ │ └── JwtUtil.php
|
||||
│ ├── events/ # 事件
|
||||
│ │ └── SystemConfig.php
|
||||
│ ├── exceptions/ # 异常处理
|
||||
│ │ └── ValidationException.php
|
||||
│ ├── http/ # HTTP相关
|
||||
│ │ ├── admin/ # 后台管理
|
||||
│ │ │ ├── controller/ # 控制器
|
||||
│ │ ├── contracts/ # 契约/接口
|
||||
│ │ │ ├── PayPluginInterface.php
|
||||
│ │ │ └── AbstractPayPlugin.php
|
||||
│ │ ├── constants/ # 常量
|
||||
│ │ ├── enums/ # 枚举
|
||||
│ │ ├── middleware/ # 中间件(Cors, StaticFile)
|
||||
│ │ ├── payment/ # 支付插件实现
|
||||
│ │ │ └── LakalaPayment.php
|
||||
│ │ └── utils/ # 工具类(JwtUtil 等)
|
||||
│ ├── events/ # 事件
|
||||
│ ├── exceptions/ # 异常(BadRequest, NotFound, Validation 等)
|
||||
│ ├── http/
|
||||
│ │ ├── admin/ # 管理后台
|
||||
│ │ │ ├── controller/
|
||||
│ │ │ │ ├── AuthController.php
|
||||
│ │ │ │ ├── AdminController.php
|
||||
│ │ │ │ ├── MenuController.php
|
||||
│ │ │ │ ├── SystemController.php
|
||||
│ │ │ │ └── UserController.php
|
||||
│ │ │ └── middleware/ # 中间件
|
||||
│ │ │ │ ├── ChannelController.php
|
||||
│ │ │ │ └── PluginController.php
|
||||
│ │ │ └── middleware/
|
||||
│ │ │ └── AuthMiddleware.php
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── SystemConfig.php
|
||||
│ │ └── User.php
|
||||
│ ├── process/ # 进程管理
|
||||
│ │ ├── Http.php
|
||||
│ │ └── Monitor.php
|
||||
│ ├── repositories/ # 数据仓库
|
||||
│ │ ├── SystemConfigRepository.php
|
||||
│ │ └── UserRepository.php
|
||||
│ ├── routes/ # 路由配置
|
||||
│ │ └── api/ # 对外 API
|
||||
│ │ ├── controller/
|
||||
│ │ │ ├── PayController.php # OpenAPI 支付接口(骨架)
|
||||
│ │ │ └── EpayController.php # 易支付接口(submit.php/mapi.php/api.php)
|
||||
│ │ └── middleware/
|
||||
│ │ ├── EpayAuthMiddleware.php
|
||||
│ │ └── OpenApiAuthMiddleware.php
|
||||
│ ├── jobs/ # 异步任务
|
||||
│ │ └── NotifyMerchantJob.php
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── Admin.php
|
||||
│ │ ├── Merchant.php
|
||||
│ │ ├── MerchantApp.php
|
||||
│ │ ├── PaymentMethod.php
|
||||
│ │ ├── PaymentPlugin.php
|
||||
│ │ ├── PaymentChannel.php
|
||||
│ │ ├── PaymentOrder.php
|
||||
│ │ ├── PaymentCallbackLog.php
|
||||
│ │ ├── PaymentNotifyTask.php
|
||||
│ │ └── SystemConfig.php
|
||||
│ ├── repositories/ # 数据仓储
|
||||
│ │ ├── AdminRepository.php
|
||||
│ │ ├── MerchantRepository.php
|
||||
│ │ ├── MerchantAppRepository.php
|
||||
│ │ ├── PaymentMethodRepository.php
|
||||
│ │ ├── PaymentPluginRepository.php
|
||||
│ │ ├── PaymentChannelRepository.php
|
||||
│ │ ├── PaymentOrderRepository.php
|
||||
│ │ ├── PaymentNotifyTaskRepository.php
|
||||
│ │ ├── PaymentCallbackLogRepository.php
|
||||
│ │ └── SystemConfigRepository.php
|
||||
│ ├── routes/ # 路由
|
||||
│ │ ├── admin.php
|
||||
│ │ ├── api.php
|
||||
│ │ └── mer.php
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ │ ├── AuthService.php
|
||||
│ │ ├── AdminService.php
|
||||
│ │ ├── CaptchaService.php
|
||||
│ │ ├── MenuService.php
|
||||
│ │ ├── SystemConfigService.php
|
||||
│ │ ├── SystemSettingService.php
|
||||
│ │ └── UserService.php
|
||||
│ └── validation/ # 数据验证
|
||||
│ └── SystemConfigValidator.php
|
||||
├── config/ # 配置文件
|
||||
│ ├── base-config/ # 基础配置
|
||||
│ │ ├── basic.json
|
||||
│ │ ├── email.json
|
||||
│ │ ├── permission.json
|
||||
│ │ └── tabs.json
|
||||
│ ├── plugin/ # 插件配置
|
||||
│ │ ├── webman/
|
||||
│ │ │ ├── console/
|
||||
│ │ │ ├── event/
|
||||
│ │ │ ├── redis-queue/
|
||||
│ │ │ └── validation/
|
||||
│ ├── system-file/ # 系统文件
|
||||
│ │ ├── dict.json
|
||||
│ │ ├── menu.json
|
||||
│ │ └── menu.md
|
||||
│ ├── app.php
|
||||
│ ├── autoload.php
|
||||
│ ├── bootstrap.php
|
||||
│ ├── cache.php
|
||||
│ ├── container.php
|
||||
│ ├── database.php
|
||||
│ ├── dependence.php
|
||||
│ ├── event.php
|
||||
│ ├── exception.php
|
||||
│ ├── jwt.php
|
||||
│ ├── log.php
|
||||
│ ├── menu.php
|
||||
│ ├── middleware.php
|
||||
│ ├── process.php
|
||||
│ ├── redis.php
|
||||
│ ├── route.php
|
||||
│ ├── server.php
|
||||
│ ├── session.php
|
||||
│ ├── static.php
|
||||
│ ├── translation.php
|
||||
│ └── view.php
|
||||
├── database/ # 数据库文件
|
||||
│ └── ma_system_config.sql
|
||||
├── doc/ # 文档
|
||||
│ ├── event.md
|
||||
│ └── exception.md
|
||||
├── public/ # 静态资源
|
||||
│ └── favicon.ico
|
||||
├── resource/ # 资源文件
|
||||
│ └── mpay_v2_admin/ # 前端项目
|
||||
├── .env # 环境变量
|
||||
├── composer.json # PHP依赖
|
||||
└── composer.lock # 依赖锁定
|
||||
│ │ ├── PluginService.php # 插件注册与实例化
|
||||
│ │ ├── ChannelRouterService.php # 通道路由(按商户+应用+支付方式选通道)
|
||||
│ │ ├── PayOrderService.php # 订单创建、幂等、退款
|
||||
│ │ ├── PayService.php # 统一下单、调用插件
|
||||
│ │ ├── NotifyService.php # 商户通知、重试
|
||||
│ │ └── api/
|
||||
│ │ └── EpayService.php # 易支付业务封装
|
||||
│ ├── validation/ # 验证器
|
||||
│ │ ├── EpayValidator.php
|
||||
│ │ └── SystemConfigValidator.php
|
||||
│ └── process/ # 进程(Http, Monitor)
|
||||
├── config/ # 配置文件
|
||||
├── database/ # 数据库脚本
|
||||
│ └── mvp_payment_tables.sql # 支付系统核心表(ma_*)
|
||||
├── doc/ # 文档
|
||||
│ ├── skill.md
|
||||
│ ├── epay.md
|
||||
│ ├── payment_flow.md
|
||||
│ ├── validation.md
|
||||
│ └── payment_system_implementation.md
|
||||
├── public/
|
||||
├── resource/
|
||||
│ └── mpay_v2_admin/ # 前端项目
|
||||
├── .env
|
||||
└── composer.json
|
||||
```
|
||||
|
||||
### 3.2 前端目录结构
|
||||
### 3.2 数据库表结构(`database/mvp_payment_tables.sql`)
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| ma_merchant | 商户表 |
|
||||
| ma_merchant_app | 商户应用表(api_type 区分 openapi/epay/custom) |
|
||||
| ma_pay_method | 支付方式字典(alipay/wechat/unionpay) |
|
||||
| ma_pay_plugin | 支付插件注册表(plugin_code 为主键) |
|
||||
| ma_pay_channel | 支付通道表(merchant_id, merchant_app_id, method_id 关联) |
|
||||
| ma_pay_order | 支付订单表(status: 0-PENDING, 1-SUCCESS, 2-FAIL, 3-CLOSED) |
|
||||
| ma_pay_callback_log | 支付回调日志表 |
|
||||
| ma_notify_task | 商户通知任务表(order_id, retry_cnt, next_retry_at) |
|
||||
| ma_system_config | 系统配置表 |
|
||||
| ma_admin | 管理员表 |
|
||||
|
||||
### 3.3 前端目录结构
|
||||
|
||||
```
|
||||
d:\phpstudy_pro\WWW\mpay\mpay_v2_webman\resource\mpay_v2_admin\
|
||||
├── src/ # 源代码
|
||||
│ ├── api/ # API调用
|
||||
│ ├── assets/ # 静态资源
|
||||
│ ├── components/ # 组件
|
||||
│ ├── config/ # 配置
|
||||
│ ├── directives/ # 指令
|
||||
│ ├── hooks/ # 钩子
|
||||
│ ├── lang/ # 国际化
|
||||
│ ├── layout/ # 布局
|
||||
│ ├── mock/ # 模拟数据
|
||||
│ ├── router/ # 路由
|
||||
│ ├── store/ # 状态管理
|
||||
│ ├── style/ # 样式
|
||||
│ ├── typings/ # 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面
|
||||
│ ├── App.vue # 根组件
|
||||
│ ├── auto-import.d.ts # 自动导入
|
||||
│ ├── components.d.ts # 组件声明
|
||||
│ ├── main.ts # 入口文件
|
||||
│ └── style.css # 全局样式
|
||||
├── build/ # 构建配置
|
||||
│ ├── optimize.ts
|
||||
│ └── vite-plugin.ts
|
||||
├── .env # 环境变量
|
||||
├── .env.development # 开发环境变量
|
||||
├── .env.production # 生产环境变量
|
||||
├── .env.test # 测试环境变量
|
||||
├── eslint.config.js # ESLint配置
|
||||
├── index.html # HTML模板
|
||||
├── package.json # 前端依赖
|
||||
└── vite.config.ts # Vite配置
|
||||
resource/mpay_v2_admin/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ ├── components/
|
||||
│ ├── layout/
|
||||
│ ├── router/
|
||||
│ ├── store/
|
||||
│ ├── views/
|
||||
│ │ ├── login/
|
||||
│ │ ├── home/
|
||||
│ │ ├── finance/
|
||||
│ │ ├── channel/
|
||||
│ │ ├── analysis/
|
||||
│ │ └── system/
|
||||
│ ├── App.vue
|
||||
│ └── main.ts
|
||||
├── package.json
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
## 4. 核心功能模块
|
||||
|
||||
### 4.1 后端核心模块
|
||||
### 4.1 支付业务流程约定
|
||||
|
||||
| 模块 | 主要功能 | 文件位置 | 来源 |
|
||||
|------|----------|----------|------|
|
||||
| 认证模块 | 用户登录、验证码生成 | app/http/admin/controller/AuthController.php | app/routes/admin.php:20-21 |
|
||||
| 用户模块 | 获取用户信息 | app/http/admin/controller/UserController.php | app/routes/admin.php:26 |
|
||||
| 菜单模块 | 获取路由菜单 | app/http/admin/controller/MenuController.php | app/routes/admin.php:29 |
|
||||
| 系统模块 | 字典管理、配置管理 | app/http/admin/controller/SystemController.php | app/routes/admin.php:32-37 |
|
||||
1. **订单创建**:`PayOrderService::createOrder`,支持幂等(merchant_id + merchant_app_id + mch_order_no 唯一)
|
||||
2. **通道路由**:`ChannelRouterService::chooseChannel(merchantId, merchantAppId, methodId)` 按第一个可用通道
|
||||
3. **统一下单**:`PayService::unifiedPay` → 创建订单 → 选通道 → 实例化插件 → 调用 `unifiedOrder`
|
||||
4. **商户通知**:`NotifyService::createNotifyTask`,`notify_url` 从订单 `extra['notify_url']` 获取
|
||||
5. **通知重试**:`NotifyMerchantJob` 定时拉取待重试任务,指数退避
|
||||
|
||||
### 4.2 前端核心模块
|
||||
### 4.2 支付插件接口
|
||||
|
||||
| 模块 | 主要功能 | 文件位置 | 来源 |
|
||||
|------|----------|----------|------|
|
||||
| 布局模块 | 系统整体布局 | src/layout/ | resource/mpay_v2_admin/src/layout/ |
|
||||
| 认证模块 | 登录、权限控制 | src/views/login/ | resource/mpay_v2_admin/src/views/ |
|
||||
| 首页模块 | 数据概览 | src/views/home/ | resource/mpay_v2_admin/src/views/home/ |
|
||||
| 财务管理 | 结算、对账、发票 | src/views/finance/ | resource/mpay_v2_admin/src/views/finance/ |
|
||||
| 渠道管理 | 通道配置、支付方式 | src/views/channel/ | resource/mpay_v2_admin/src/views/channel/ |
|
||||
| 数据分析 | 交易分析、商户分析 | src/views/analysis/ | resource/mpay_v2_admin/src/views/analysis/ |
|
||||
| 系统设置 | 系统配置、字典管理 | src/views/system/ | resource/mpay_v2_admin/src/views/ |
|
||||
- `app/common/contracts/PayPluginInterface.php`
|
||||
- `app/common/contracts/AbstractPayPlugin.php`
|
||||
- 示例实现:`app/common/payment/LakalaPayment.php`
|
||||
|
||||
## 5. API接口设计
|
||||
插件需实现:`getName`、`getSupportedMethods`、`getConfigSchema`、`getSupportedProducts`、`init`、`unifiedOrder`、`refund`、`verifyNotify` 等。
|
||||
|
||||
### 5.1 认证接口
|
||||
### 4.3 后端核心模块
|
||||
|
||||
| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 |
|
||||
|------|------|-----------|------|------|------|
|
||||
| /adminapi/captcha | GET | AuthController | 获取验证码 | 无 | app/routes/admin.php:20 |
|
||||
| /adminapi/login | POST | AuthController | 用户登录 | 无 | app/routes/admin.php:21 |
|
||||
| 模块 | 主要功能 | 文件位置 |
|
||||
|------|----------|----------|
|
||||
| 认证 | 管理员登录、验证码 | AuthController, AuthService |
|
||||
| 管理员 | 获取管理员信息 | AdminController, AdminService, Admin 模型 |
|
||||
| 菜单 | 获取路由菜单 | MenuController, MenuService |
|
||||
| 系统 | 字典、配置管理 | SystemController, SystemConfigService |
|
||||
| 通道管理 | 通道列表、详情、保存 | ChannelController, PaymentChannelRepository |
|
||||
| 插件管理 | 插件列表、配置 Schema、产品列表 | PluginController, PluginService |
|
||||
| 易支付 | submit.php/mapi.php/api.php | EpayController, EpayService |
|
||||
|
||||
### 5.2 用户接口
|
||||
### 4.4 前端核心模块
|
||||
|
||||
| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 |
|
||||
|------|------|-----------|------|------|------|
|
||||
| /adminapi/user/getUserInfo | GET | UserController | 获取用户信息 | JWT | app/routes/admin.php:26 |
|
||||
| 模块 | 主要功能 | 位置 |
|
||||
|------|----------|------|
|
||||
| 布局 | 系统整体布局 | src/layout/ |
|
||||
| 认证 | 登录、权限控制 | src/views/login/ |
|
||||
| 首页 | 数据概览 | src/views/home/ |
|
||||
| 财务管理 | 结算、对账、发票 | src/views/finance/ |
|
||||
| 渠道管理 | 通道配置、支付方式 | src/views/channel/ |
|
||||
| 数据分析 | 交易分析、商户分析 | src/views/analysis/ |
|
||||
| 系统设置 | 系统配置、字典管理 | src/views/system/ |
|
||||
|
||||
### 5.3 菜单接口
|
||||
## 5. API 接口设计
|
||||
|
||||
| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 |
|
||||
|------|------|-----------|------|------|------|
|
||||
| /adminapi/menu/getRouters | GET | MenuController | 获取路由菜单 | JWT | app/routes/admin.php:29 |
|
||||
### 5.1 管理后台(/adminapi)
|
||||
|
||||
### 5.4 系统接口
|
||||
| 路径 | 方法 | 控制器 | 功能 | 权限 |
|
||||
|------|------|--------|------|------|
|
||||
| /adminapi/captcha | GET | AuthController | 获取验证码 | 无 |
|
||||
| /adminapi/login | POST | AuthController | 管理员登录 | 无 |
|
||||
| /adminapi/user/getUserInfo | GET | AdminController | 获取管理员信息 | JWT |
|
||||
| /adminapi/menu/getRouters | GET | MenuController | 获取路由菜单 | JWT |
|
||||
| /adminapi/system/getDict[/{code}] | GET | SystemController | 获取字典 | JWT |
|
||||
| /adminapi/system/base-config/tabs | GET | SystemController | 获取配置标签 | JWT |
|
||||
| /adminapi/system/base-config/form/{tabKey} | GET | SystemController | 获取表单配置 | JWT |
|
||||
| /adminapi/system/base-config/submit/{tabKey} | POST | SystemController | 提交配置 | JWT |
|
||||
| /adminapi/channel/list | GET | ChannelController | 通道列表 | JWT |
|
||||
| /adminapi/channel/detail | GET | ChannelController | 通道详情 | JWT |
|
||||
| /adminapi/channel/save | POST | ChannelController | 保存通道 | JWT |
|
||||
| /adminapi/channel/plugins | GET | PluginController | 插件列表 | JWT |
|
||||
| /adminapi/channel/plugin/config-schema | GET | PluginController | 插件配置 Schema | JWT |
|
||||
| /adminapi/channel/plugin/products | GET | PluginController | 插件产品列表 | JWT |
|
||||
|
||||
| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 |
|
||||
|------|------|-----------|------|------|------|
|
||||
| /adminapi/system/getDict[/{code}] | GET | SystemController | 获取字典数据 | JWT | app/routes/admin.php:32 |
|
||||
| /adminapi/system/base-config/tabs | GET | SystemController | 获取配置标签 | JWT | app/routes/admin.php:35 |
|
||||
| /adminapi/system/base-config/form/{tabKey} | GET | SystemController | 获取表单配置 | JWT | app/routes/admin.php:36 |
|
||||
| /adminapi/system/base-config/submit/{tabKey} | POST | SystemController | 提交配置 | JWT | app/routes/admin.php:37 |
|
||||
### 5.2 易支付接口(对外 API)
|
||||
|
||||
## 6. 技术特点
|
||||
| 路径 | 方法 | 控制器 | 功能 | 说明 |
|
||||
|------|------|--------|------|------|
|
||||
| /submit.php | ANY | EpayController | 页面跳转支付 | 参数:pid, key, out_trade_no, money, name, type, notify_url 等 |
|
||||
| /mapi.php | POST | EpayController | API 接口支付 | 返回 trade_no、payurl/qrcode/urlscheme |
|
||||
| /api.php | GET | EpayController | 订单查询/退款 | act=order 查询,act=refund 退款 |
|
||||
|
||||
### 6.1 后端特点
|
||||
易支付约定:`pid` 映射为 `app_id`(商户应用标识),`key` 为 `app_secret`。
|
||||
|
||||
1. **高性能架构**:基于 Webman 框架,使用 Workerman 作为底层,支持高并发处理
|
||||
2. **模块化设计**:采用分层架构,清晰分离控制器、服务、仓库和模型
|
||||
3. **JWT认证**:使用 JSON Web Token 实现无状态认证
|
||||
4. **中间件机制**:通过中间件实现请求拦截和权限控制
|
||||
5. **Redis集成**:使用 Redis 作为缓存和队列存储
|
||||
6. **事件系统**:支持事件驱动架构
|
||||
7. **定时任务**:内置定时任务管理功能
|
||||
8. **数据验证**:使用 think-validate 进行数据验证
|
||||
9. **依赖注入**:使用 PHP-DI 实现依赖注入
|
||||
10. **日志管理**:使用 Monolog 进行日志管理
|
||||
## 6. 命名与约定
|
||||
|
||||
### 6.2 前端特点
|
||||
### 6.1 模型与仓储命名
|
||||
|
||||
1. **Vue 3 + TypeScript**:使用最新的 Vue 3 组合式 API 和 TypeScript 提供类型安全
|
||||
2. **Arco Design**:采用字节跳动开源的 Arco Design UI 组件库,提供美观的界面
|
||||
3. **Pinia 状态管理**:使用 Pinia 替代 Vuex,提供更简洁的状态管理方案
|
||||
4. **Vite 构建工具**:使用 Vite 提供快速的开发体验和优化的构建输出
|
||||
5. **国际化支持**:内置多语言支持,可轻松切换语言
|
||||
6. **响应式设计**:适配不同屏幕尺寸的设备
|
||||
7. **丰富的功能组件**:集成多种实用组件,如二维码生成、条码生成、富文本编辑等
|
||||
8. **权限控制**:基于指令的权限控制机制
|
||||
9. **Mock 数据**:内置 Mock 数据,方便开发和测试
|
||||
- 业务语义命名:`PaymentMethod`、`PaymentOrder`、`PaymentChannel` 等,不使用 `ma` 前缀
|
||||
- 表名仍为 `ma_*`,通过模型 `$table` 映射
|
||||
|
||||
### 6.2 订单相关字段
|
||||
|
||||
- 系统订单号:`order_id`
|
||||
- 商户订单号:`mch_order_no`
|
||||
- 商户ID:`merchant_id`
|
||||
- 商户应用ID:`merchant_app_id`
|
||||
- 通道ID:`channel_id`
|
||||
- 支付方式ID:`method_id`(关联 ma_pay_method.id)
|
||||
|
||||
### 6.3 商户应用 api_type
|
||||
|
||||
用于区分不同 API 的验签与通知方式:`openapi`、`epay`、`custom` 等。
|
||||
|
||||
## 7. 开发流程
|
||||
|
||||
### 7.1 后端开发
|
||||
|
||||
1. **环境准备**:PHP 8.1+,Composer,MySQL,Redis
|
||||
2. **依赖安装**:`composer install`
|
||||
3. **配置环境**:复制 `.env.example` 为 `.env` 并配置相关参数
|
||||
4. **启动服务**:`php start.php start`
|
||||
5. **代码结构**:遵循 Webman 框架规范,按模块组织代码
|
||||
1. **环境**:PHP 8.1+,Composer,MySQL,Redis
|
||||
2. **依赖**:`composer install`
|
||||
3. **数据库**:执行 `database/mvp_payment_tables.sql`
|
||||
4. **配置**:复制 `.env.example` 为 `.env`
|
||||
5. **启动**:
|
||||
- Linux:`php start.php start`
|
||||
- Windows:`php windows.php start`
|
||||
|
||||
### 7.2 前端开发
|
||||
|
||||
1. **环境准备**:Node.js 18.12+,PNPM 8.7+
|
||||
2. **依赖安装**:`pnpm install`
|
||||
3. **开发模式**:`pnpm dev`
|
||||
4. **构建部署**:`pnpm build:prod`
|
||||
5. **代码结构**:遵循 Vue 3 项目规范,按功能模块组织代码
|
||||
1. **环境**:Node.js 18.12+,PNPM 8.7+
|
||||
2. **依赖**:`pnpm install`
|
||||
3. **开发**:`pnpm dev`
|
||||
4. **构建**:`pnpm build:prod`
|
||||
|
||||
## 8. 部署与配置
|
||||
## 8. 相关文档
|
||||
|
||||
### 8.1 后端部署
|
||||
|
||||
1. **服务器要求**:Linux/Unix 系统,PHP 8.1+,MySQL 5.7+,Redis 5.0+
|
||||
2. **Nginx 配置**:配置反向代理指向 Webman 服务
|
||||
3. **启动方式**:
|
||||
- 开发环境:`php start.php start`
|
||||
- 生产环境:`php start.php start -d`
|
||||
4. **监控管理**:可使用 Supervisor 管理进程
|
||||
|
||||
### 8.2 前端部署
|
||||
|
||||
1. **构建**:`pnpm build:prod`
|
||||
2. **部署**:将 `dist` 目录部署到 Web 服务器
|
||||
3. **Nginx 配置**:配置静态文件服务和路由重写
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| doc/epay.md | 易支付接口说明 |
|
||||
| doc/payment_flow.md | 支付流程说明 |
|
||||
| doc/payment_system_implementation.md | 支付系统实现说明 |
|
||||
| doc/validation.md | 验证规则说明 |
|
||||
| database/mvp_payment_tables.sql | 支付系统表结构 |
|
||||
|
||||
## 9. 总结
|
||||
|
||||
MPAY V2 项目采用现代化的技术栈和架构设计,后端使用 Webman 框架提供高性能的 API 服务,前端使用 Vue 3 + TypeScript + Arco Design 提供美观、响应式的用户界面。项目结构清晰,模块化程度高,便于维护和扩展。
|
||||
|
||||
核心功能覆盖了支付管理系统的主要业务场景,包括用户认证、菜单管理、系统配置、财务管理、渠道管理和数据分析等模块,为支付业务的运营和管理提供了完整的解决方案。
|
||||
|
||||
技术特点包括高性能架构、模块化设计、JWT认证、Redis集成、Vue 3组合式API、TypeScript类型安全、Arco Design UI组件库、Pinia状态管理、Vite构建工具等,确保了系统的稳定性、安全性和可扩展性。
|
||||
|
||||
该项目适合作为支付管理系统的基础框架,可根据具体业务需求进行定制和扩展。
|
||||
MPAY V2 以支付业务为核心,采用 Webman + Vue 3 技术栈,后端分层清晰(Controller → Service → Repository → Model),支持支付插件扩展与易支付兼容。管理后台基于 JWT 认证,提供通道、插件、系统配置等管理能力;对外提供易支付标准接口(submit/mapi/api),便于第三方商户接入。
|
||||
|
||||
395
doc/validation.md
Normal file
395
doc/validation.md
Normal file
@@ -0,0 +1,395 @@
|
||||
验证器 webman/validation
|
||||
基于 illuminate/validation,提供手动验证、注解验证、参数级验证,以及可复用的规则集。
|
||||
|
||||
安装
|
||||
composer require webman/validation
|
||||
基本概念
|
||||
规则集复用:通过继承 support\validation\Validator 定义可复用的 rules messages attributes scenes,可在手动与注解中复用。
|
||||
方法级注解(Attribute)验证:使用 PHP 8 属性注解 #[Validate] 绑定控制器方法。
|
||||
参数级注解(Attribute)验证:使用 PHP 8 属性注解 #[Param] 绑定控制器方法参数。
|
||||
异常处理:验证失败抛出 support\validation\ValidationException,异常类可通过配置自定义
|
||||
数据库验证:如果涉及数据库验证,需要安装 composer require webman/database
|
||||
手动验证
|
||||
基本用法
|
||||
use support\validation\Validator;
|
||||
|
||||
$data = ['email' => 'user@example.com'];
|
||||
|
||||
Validator::make($data, [
|
||||
'email' => 'required|email',
|
||||
])->validate();
|
||||
提示
|
||||
validate() 校验失败会抛出 support\validation\ValidationException。如果你不希望抛异常,请使用下方的 fails() 写法获取错误信息。
|
||||
|
||||
自定义 messages 与 attributes
|
||||
use support\validation\Validator;
|
||||
|
||||
$data = ['contact' => 'user@example.com'];
|
||||
|
||||
Validator::make(
|
||||
$data,
|
||||
['contact' => 'required|email'],
|
||||
['contact.email' => '邮箱格式不正确'],
|
||||
['contact' => '邮箱']
|
||||
)->validate();
|
||||
不抛异常并获取错误信息
|
||||
如果你不希望抛异常,可以使用 fails() 判断,并通过 errors()(返回 MessageBag)获取错误信息:
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
$data = ['email' => 'bad-email'];
|
||||
|
||||
$validator = Validator::make($data, [
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$firstError = $validator->errors()->first(); // string
|
||||
$allErrors = $validator->errors()->all(); // array
|
||||
$errorsByField = $validator->errors()->toArray(); // array
|
||||
// 处理错误...
|
||||
}
|
||||
规则集复用(自定义 Validator)
|
||||
namespace app\validation;
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
class UserValidator extends Validator
|
||||
{
|
||||
protected array $rules = [
|
||||
'id' => 'required|integer|min:1',
|
||||
'name' => 'required|string|min:2|max:20',
|
||||
'email' => 'required|email',
|
||||
];
|
||||
|
||||
protected array $messages = [
|
||||
'name.required' => '姓名必填',
|
||||
'email.required' => '邮箱必填',
|
||||
'email.email' => '邮箱格式不正确',
|
||||
];
|
||||
|
||||
protected array $attributes = [
|
||||
'name' => '姓名',
|
||||
'email' => '邮箱',
|
||||
];
|
||||
}
|
||||
手动验证复用
|
||||
use app\validation\UserValidator;
|
||||
|
||||
UserValidator::make($data)->validate();
|
||||
使用 scenes(可选)
|
||||
scenes 是可选能力,只有在你调用 withScene(...) 时,才会按场景只验证部分字段。
|
||||
|
||||
namespace app\validation;
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
class UserValidator extends Validator
|
||||
{
|
||||
protected array $rules = [
|
||||
'id' => 'required|integer|min:1',
|
||||
'name' => 'required|string|min:2|max:20',
|
||||
'email' => 'required|email',
|
||||
];
|
||||
|
||||
protected array $scenes = [
|
||||
'create' => ['name', 'email'],
|
||||
'update' => ['id', 'name', 'email'],
|
||||
];
|
||||
}
|
||||
use app\validation\UserValidator;
|
||||
|
||||
// 不指定场景 -> 验证全部规则
|
||||
UserValidator::make($data)->validate();
|
||||
|
||||
// 指定场景 -> 只验证该场景包含的字段
|
||||
UserValidator::make($data)->withScene('create')->validate();
|
||||
注解验证(方法级)
|
||||
直接规则
|
||||
use support\Request;
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
#[Validate(
|
||||
rules: [
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|string|min:6',
|
||||
],
|
||||
messages: [
|
||||
'email.required' => '邮箱必填',
|
||||
'password.required' => '密码必填',
|
||||
],
|
||||
attributes: [
|
||||
'email' => '邮箱',
|
||||
'password' => '密码',
|
||||
]
|
||||
)]
|
||||
public function login(Request $request)
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
复用规则集
|
||||
use app\validation\UserValidator;
|
||||
use support\Request;
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class UserController
|
||||
{
|
||||
#[Validate(validator: UserValidator::class, scene: 'create')]
|
||||
public function create(Request $request)
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
多重验证叠加
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class UserController
|
||||
{
|
||||
#[Validate(rules: ['email' => 'required|email'])]
|
||||
#[Validate(rules: ['token' => 'required|string'])]
|
||||
public function send()
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
验证数据来源
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class UserController
|
||||
{
|
||||
#[Validate(
|
||||
rules: ['email' => 'required|email'],
|
||||
in: ['query', 'body', 'path']
|
||||
)]
|
||||
public function send()
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
通过in参数来指定数据来源,其中:
|
||||
|
||||
query http请求的query参数,取自 $request->get()
|
||||
body http请求的包体,取自 $request->post()
|
||||
path http请求的路径参数,取自 $request->route->param()
|
||||
in可为字符串或数组;为数组时按顺序合并,后者覆盖前者。未传递in时默认等效于 ['query', 'body', 'path']。
|
||||
|
||||
参数级验证(Param)
|
||||
基本用法
|
||||
use support\validation\annotation\Param;
|
||||
|
||||
class MailController
|
||||
{
|
||||
public function send(
|
||||
#[Param(rules: 'required|email')] string $from,
|
||||
#[Param(rules: 'required|email')] string $to,
|
||||
#[Param(rules: 'required|string|min:1|max:500')] string $content
|
||||
) {
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
验证数据来源
|
||||
类似的,参数级也支持in参数指定来源
|
||||
|
||||
use support\validation\annotation\Param;
|
||||
|
||||
class MailController
|
||||
{
|
||||
public function send(
|
||||
#[Param(rules: 'required|email', in: ['body'])] string $from
|
||||
) {
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
rules 支持字符串或数组
|
||||
use support\validation\annotation\Param;
|
||||
|
||||
class MailController
|
||||
{
|
||||
public function send(
|
||||
#[Param(rules: ['required', 'email'])] string $from
|
||||
) {
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
自定义 messages / attribute
|
||||
use support\validation\annotation\Param;
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function updateEmail(
|
||||
#[Param(
|
||||
rules: 'required|email',
|
||||
messages: ['email.email' => '邮箱格式不正确'],
|
||||
attribute: '邮箱'
|
||||
)]
|
||||
string $email
|
||||
) {
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
规则常量复用
|
||||
final class ParamRules
|
||||
{
|
||||
public const EMAIL = ['required', 'email'];
|
||||
}
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function send(
|
||||
#[Param(rules: ParamRules::EMAIL)] string $email
|
||||
) {
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
方法级 + 参数级混合
|
||||
use support\Request;
|
||||
use support\validation\annotation\Param;
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class UserController
|
||||
{
|
||||
#[Validate(rules: ['token' => 'required|string'])]
|
||||
public function send(
|
||||
Request $request,
|
||||
#[Param(rules: 'required|email')] string $from,
|
||||
#[Param(rules: 'required|integer')] int $id
|
||||
) {
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
}
|
||||
自动规则推导(基于参数签名)
|
||||
当方法上使用 #[Validate],或该方法的任意参数使用了 #[Param] 时,本组件会根据方法参数签名自动推导并补全基础验证规则,再与已有规则合并后执行验证。
|
||||
|
||||
示例:#[Validate] 等价展开
|
||||
1) 只开启 #[Validate],不手写规则:
|
||||
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class DemoController
|
||||
{
|
||||
#[Validate]
|
||||
public function create(string $content, int $uid)
|
||||
{
|
||||
}
|
||||
}
|
||||
等价于:
|
||||
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class DemoController
|
||||
{
|
||||
#[Validate(rules: [
|
||||
'content' => 'required|string',
|
||||
'uid' => 'required|integer',
|
||||
])]
|
||||
public function create(string $content, int $uid)
|
||||
{
|
||||
}
|
||||
}
|
||||
2) 只写了部分规则,其余由参数签名补全:
|
||||
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class DemoController
|
||||
{
|
||||
#[Validate(rules: [
|
||||
'content' => 'min:2',
|
||||
])]
|
||||
public function create(string $content, int $uid)
|
||||
{
|
||||
}
|
||||
}
|
||||
等价于:
|
||||
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class DemoController
|
||||
{
|
||||
#[Validate(rules: [
|
||||
'content' => 'required|string|min:2',
|
||||
'uid' => 'required|integer',
|
||||
])]
|
||||
public function create(string $content, int $uid)
|
||||
{
|
||||
}
|
||||
}
|
||||
3) 默认值/可空类型:
|
||||
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class DemoController
|
||||
{
|
||||
#[Validate]
|
||||
public function create(string $content = '默认值', ?int $uid = null)
|
||||
{
|
||||
}
|
||||
}
|
||||
等价于:
|
||||
|
||||
use support\validation\annotation\Validate;
|
||||
|
||||
class DemoController
|
||||
{
|
||||
#[Validate(rules: [
|
||||
'content' => 'string',
|
||||
'uid' => 'integer|nullable',
|
||||
])]
|
||||
public function create(string $content = '默认值', ?int $uid = null)
|
||||
{
|
||||
}
|
||||
}
|
||||
异常处理
|
||||
默认异常
|
||||
验证失败默认抛出 support\validation\ValidationException,继承 Webman\Exception\BusinessException,不会记录错误日志。
|
||||
|
||||
默认响应行为由 BusinessException::render() 处理:
|
||||
|
||||
普通请求:返回字符串消息,例如 token 为必填项。
|
||||
JSON 请求:返回 JSON 响应,例如 {"code": 422, "msg": "token 为必填项。", "data":....}
|
||||
通过自定义异常修改处理方式
|
||||
全局配置:config/plugin/webman/validation/app.php 的 exception
|
||||
多语言支持
|
||||
组件内置中英文语言包,并支持项目覆盖。加载顺序:
|
||||
|
||||
项目语言包 resource/translations/{locale}/validation.php
|
||||
组件内置 vendor/webman/validation/resources/lang/{locale}/validation.php
|
||||
Illuminate 内置英文(兜底)
|
||||
提示
|
||||
webman默认语言由 config/translation.php 配置,也可以通过函数 locale('en'); 更改。
|
||||
|
||||
本地覆盖示例
|
||||
resource/translations/zh_CN/validation.php
|
||||
|
||||
return [
|
||||
'email' => ':attribute 不是有效的邮件格式。',
|
||||
];
|
||||
中间件自动加载
|
||||
组件安装后会通过 config/plugin/webman/validation/middleware.php 自动加载验证中间件,无需手动注册。
|
||||
|
||||
命令行生成注解
|
||||
使用命令 make:validator 生成验证器类(默认生成到 app/validation 目录)。
|
||||
|
||||
提示
|
||||
需要安装 composer require webman/console
|
||||
|
||||
基础用法
|
||||
生成空模板
|
||||
php webman make:validator UserValidator
|
||||
覆盖已存在文件
|
||||
php webman make:validator UserValidator --force
|
||||
php webman make:validator UserValidator -f
|
||||
从表结构生成规则
|
||||
指定表名生成基础规则(会根据字段类型/可空/长度等推导 $rules;默认排除字段与 ORM 相关:laravel 为 created_at/updated_at/deleted_at,thinkorm 为 create_time/update_time/delete_time)
|
||||
php webman make:validator UserValidator --table=wa_users
|
||||
php webman make:validator UserValidator -t wa_users
|
||||
指定数据库连接(多连接场景)
|
||||
php webman make:validator UserValidator --table=wa_users --database=mysql
|
||||
php webman make:validator UserValidator -t wa_users -d mysql
|
||||
场景(scenes)
|
||||
生成 CRUD 场景:create/update/delete/detail
|
||||
php webman make:validator UserValidator --table=wa_users --scenes=crud
|
||||
php webman make:validator UserValidator -t wa_users -s crud
|
||||
update 场景会包含主键字段(用于定位记录)以及其余字段;delete/detail 默认仅包含主键字段。
|
||||
Reference in New Issue
Block a user