更新数据库结构

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