更新数据库结构

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