更新数据库结构

This commit is contained in:
技术老胡
2026-03-10 13:47:28 +08:00
parent 54ad21ac8f
commit 9de902231f
54 changed files with 5070 additions and 501 deletions

View 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);
}
}

View 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;
}

View 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'],
],
];
}
}

View 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);
}
}

View 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, '保存成功');
}
}

View 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);
}
}
}

View File

@@ -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);
}
}

View 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']);
}
}

View 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) {}
}

View 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}&timestamp={$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);
}
}

View 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}&timestamp={$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);
}
}

View 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
View 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
View 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',
];
}

View 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',
];
}

View 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',
];
}

View 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'] ?? [];
}
}

View 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',
];
}

View 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';
}

View 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;
}

View 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',
];
}

View File

@@ -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');
}
}

View 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;
}
}

View 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();
}
}

View 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();
}
}

View 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);
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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);
}
}

View 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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]);

View File

@@ -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]);

View 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' => ['*:*:*'],
];
}
}

View File

@@ -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();
}
}

View 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;
}
}

View 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);
}
}

View 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
View 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;
}
}

View 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;
}
}

View File

@@ -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' => ['*:*:*'],
];
}
}

View 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;
}
}

View 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 至少一个
],
];
}

View File

@@ -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

View File

@@ -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='系统配置表';

View 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 '登录密码hashNULL 或空表示使用默认开发密码)',
`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
View 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)
#### OpenApiAuthStrategyOpenAPI认证
- 使用 `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
View 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-zsign、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
View 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
View 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}&timestamp={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}&timestamp={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}&timestamp={$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}&timestamp=${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. **订单查询**:业务系统可通过查询接口主动查询订单状态

View 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}&timestamp={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` 返回已有订单
## 后续扩展
- 账务系统(账户、分录、余额)
- 结算系统(可结算金额、结算批次、打款)
- 对账系统(渠道账单导入、差异处理)
- 风控系统(规则引擎、风险预警)

View File

@@ -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+ComposerMySQLRedis
2. **依赖安装**`composer install`
3. **配置环境**复制 `.env.example``.env` 并配置相关参数
4. **启动服务**`php start.php start`
5. **代码结构**:遵循 Webman 框架规范,按模块组织代码
1. **环境**PHP 8.1+ComposerMySQLRedis
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
View 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_atthinkorm 为 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 默认仅包含主键字段。