feat: add vip payment and member center
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台 VIP 支付配置控制器
|
||||
* 用于管理聊天室对接 NovaLink 支付中心所需的开关、地址、App Key 与 App Secret
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateVipPaymentConfigRequest;
|
||||
use App\Models\SysParam;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipPaymentConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数注入聊天室状态服务
|
||||
*
|
||||
* @param ChatStateService $chatState 系统参数缓存同步服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示 VIP 支付配置页
|
||||
*/
|
||||
public function edit(): View
|
||||
{
|
||||
$aliases = array_keys($this->fieldDescriptions());
|
||||
|
||||
// 仅读取 VIP 支付专属配置,避免与系统参数页重复展示。
|
||||
$params = SysParam::query()
|
||||
->whereIn('alias', $aliases)
|
||||
->pluck('body', 'alias')
|
||||
->toArray();
|
||||
|
||||
return view('admin.vip-payment.config', [
|
||||
'params' => $params,
|
||||
'descriptions' => $this->fieldDescriptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 VIP 支付配置并刷新缓存
|
||||
*
|
||||
* @param UpdateVipPaymentConfigRequest $request 已校验的后台配置请求
|
||||
*/
|
||||
public function update(UpdateVipPaymentConfigRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$descriptions = $this->fieldDescriptions();
|
||||
|
||||
foreach ($descriptions as $alias => $guidetxt) {
|
||||
$body = (string) ($data[$alias] ?? '');
|
||||
|
||||
// 写入数据库并同步描述文案,确保后续后台与缓存读取一致。
|
||||
SysParam::updateOrCreate(
|
||||
['alias' => $alias],
|
||||
['body' => $body, 'guidetxt' => $guidetxt]
|
||||
);
|
||||
|
||||
$this->chatState->setSysParam($alias, $body);
|
||||
SysParam::clearCache($alias);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.vip-payment.edit')->with('success', 'VIP 支付配置已成功保存。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 VIP 支付字段说明文案
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function fieldDescriptions(): array
|
||||
{
|
||||
return [
|
||||
'vip_payment_enabled' => 'VIP 在线支付开关(1=开启,0=关闭)',
|
||||
'vip_payment_base_url' => 'NovaLink 支付中心地址(例如 https://novalink.test)',
|
||||
'vip_payment_app_key' => 'NovaLink 支付中心 App Key',
|
||||
'vip_payment_app_secret' => 'NovaLink 支付中心 App Secret',
|
||||
'vip_payment_timeout' => '调用支付中心超时时间(秒)',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台会员购买日志控制器
|
||||
* 负责展示聊天室 VIP 在线支付订单列表,并支持按用户、状态、订单号和日期筛选
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VipPaymentOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipPaymentLogController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示会员购买日志列表
|
||||
*
|
||||
* @param Request $request 当前查询请求
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = VipPaymentOrder::query()->with(['user:id,username', 'vipLevel:id,name,color,icon']);
|
||||
|
||||
if ($request->filled('username')) {
|
||||
$username = (string) $request->input('username');
|
||||
|
||||
// 通过用户关联模糊匹配用户名,便于后台快速定位某个会员订单。
|
||||
$query->whereHas('user', function ($builder) use ($username): void {
|
||||
$builder->where('username', 'like', '%'.$username.'%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', (string) $request->input('status'));
|
||||
}
|
||||
|
||||
if ($request->filled('order_no')) {
|
||||
$keyword = (string) $request->input('order_no');
|
||||
$query->where(function ($builder) use ($keyword): void {
|
||||
$builder->where('order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('merchant_order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('payment_order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('provider_trade_no', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('created_at', '>=', (string) $request->input('date_start'));
|
||||
}
|
||||
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('created_at', '<=', (string) $request->input('date_end'));
|
||||
}
|
||||
|
||||
$logs = $query->latest('id')->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.vip-payment-logs.index', [
|
||||
'logs' => $logs,
|
||||
'statusOptions' => [
|
||||
'created' => '待创建',
|
||||
'pending' => '待支付',
|
||||
'paid' => '已支付',
|
||||
'closed' => '已关闭',
|
||||
'failed' => '失败',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台会员中心控制器
|
||||
* 负责展示会员等级、权益说明、当前会员状态以及用户自己的购买记录
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\VipLevel;
|
||||
use App\Models\VipPaymentOrder;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipCenterController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示会员中心页面
|
||||
*
|
||||
* @param Request $request 当前请求对象
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 会员等级按后台排序字段展示,方便用户对比不同档位权益。
|
||||
$vipLevels = VipLevel::query()
|
||||
->withCount('users')
|
||||
->orderByDesc('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// 仅展示当前用户自己的购买记录,避免泄露其他会员订单信息。
|
||||
$paymentLogs = $this->buildPaymentLogs($user->id);
|
||||
|
||||
$paidOrders = VipPaymentOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'paid')
|
||||
->count();
|
||||
|
||||
$totalAmount = (float) VipPaymentOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'paid')
|
||||
->sum('amount');
|
||||
|
||||
return view('vip.center', [
|
||||
'user' => $user,
|
||||
'vipLevels' => $vipLevels,
|
||||
'paymentLogs' => $paymentLogs,
|
||||
'vipPaymentEnabled' => Sysparam::getValue('vip_payment_enabled', '0') === '1',
|
||||
'paidOrders' => $paidOrders,
|
||||
'totalAmount' => $totalAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建当前用户的购买记录分页数据
|
||||
*
|
||||
* @param int $userId 当前登录用户 ID
|
||||
*/
|
||||
private function buildPaymentLogs(int $userId): LengthAwarePaginator
|
||||
{
|
||||
return VipPaymentOrder::query()
|
||||
->with('vipLevel:id,name,color,icon')
|
||||
->where('user_id', $userId)
|
||||
->latest('id')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台 VIP 支付控制器
|
||||
* 负责用户发起 VIP 支付、接收同步回调、接收异步通知并驱动本地会员开通
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\CreateVipPaymentOrderRequest;
|
||||
use App\Models\VipLevel;
|
||||
use App\Services\VipPaymentCenterClient;
|
||||
use App\Services\VipPaymentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use RuntimeException;
|
||||
|
||||
class VipPaymentController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param VipPaymentService $vipPaymentService 本地 VIP 支付服务
|
||||
* @param VipPaymentCenterClient $paymentCenterClient 远端支付中心客户端
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly VipPaymentService $vipPaymentService,
|
||||
private readonly VipPaymentCenterClient $paymentCenterClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建 VIP 支付订单并跳转到 NovaLink 支付页
|
||||
*
|
||||
* @param CreateVipPaymentOrderRequest $request 已校验的购买请求
|
||||
*/
|
||||
public function store(CreateVipPaymentOrderRequest $request): RedirectResponse
|
||||
{
|
||||
if (! $this->paymentCenterClient->isEnabled() || ! $this->paymentCenterClient->hasValidConfig()) {
|
||||
return redirect()->route('guide')->with('error', 'VIP 支付暂未开放,请联系管理员完成后台配置。');
|
||||
}
|
||||
|
||||
$vipLevel = VipLevel::query()->findOrFail((int) $request->validated('vip_level_id'));
|
||||
|
||||
try {
|
||||
// 先创建本地订单,再向支付中心发起下单,确保回调时有本地单据可追踪。
|
||||
$vipPaymentOrder = $this->vipPaymentService->createLocalOrder($request->user(), $vipLevel);
|
||||
$remoteOrder = $this->vipPaymentService->createRemoteOrder($vipPaymentOrder);
|
||||
$payUrl = (string) ($remoteOrder['pay_url'] ?? '');
|
||||
|
||||
if ($payUrl === '') {
|
||||
throw new RuntimeException('支付中心未返回可用的支付地址。');
|
||||
}
|
||||
|
||||
return redirect()->away($payUrl);
|
||||
} catch (\Throwable $exception) {
|
||||
return redirect()->route('guide')->with('error', '创建 VIP 支付订单失败:'.$exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付完成后的同步回调
|
||||
*
|
||||
* @param Request $request 支付中心跳转返回参数
|
||||
*/
|
||||
public function handleReturn(Request $request): RedirectResponse
|
||||
{
|
||||
$payload = $request->all();
|
||||
$vipPaymentOrder = $this->vipPaymentService->findByPaymentOrderNo($request->string('payment_order_no')->toString())
|
||||
?? $this->vipPaymentService->findByMerchantOrderNo($request->string('merchant_order_no')->toString());
|
||||
|
||||
if (! $vipPaymentOrder) {
|
||||
return redirect()->route('guide')->with('error', '未找到对应的 VIP 支付订单,请稍后在后台核对。');
|
||||
}
|
||||
|
||||
$this->vipPaymentService->recordSyncReturn($vipPaymentOrder, $payload);
|
||||
|
||||
try {
|
||||
// 同步回调只做页面回跳,但这里补查一次可让用户尽快看到最终结果。
|
||||
$vipPaymentOrder = $this->vipPaymentService->syncRemoteStatus($vipPaymentOrder);
|
||||
} catch (\Throwable $exception) {
|
||||
return redirect()->route('guide')->with('error', '支付结果正在确认中,请稍后刷新查看。');
|
||||
}
|
||||
|
||||
if ($vipPaymentOrder->isVipOpened()) {
|
||||
return redirect()->route('guide')->with('success', 'VIP 支付成功,会员已开通。');
|
||||
}
|
||||
|
||||
return redirect()->route('guide')->with('success', '支付页面已返回,系统正在确认支付结果,请稍后刷新查看。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收 NovaLink 支付中心的异步通知
|
||||
*
|
||||
* @param Request $request 支付中心回调请求
|
||||
*/
|
||||
public function notify(Request $request): Response
|
||||
{
|
||||
$rawBody = $request->getContent();
|
||||
$signature = (string) $request->header('X-Payment-Signature', '');
|
||||
|
||||
if (! $this->paymentCenterClient->isValidWebhookSignature($signature, $rawBody)) {
|
||||
return response('invalid signature', 401);
|
||||
}
|
||||
|
||||
$payload = $request->json()->all();
|
||||
$vipPaymentOrder = $this->vipPaymentService->findByPaymentOrderNo($payload['payment_order_no'] ?? null)
|
||||
?? $this->vipPaymentService->findByMerchantOrderNo($payload['merchant_order_no'] ?? null);
|
||||
|
||||
if (! $vipPaymentOrder) {
|
||||
return response('order not found', 404);
|
||||
}
|
||||
|
||||
if (($payload['status'] ?? '') !== 'paid') {
|
||||
return response('ignored', 200);
|
||||
}
|
||||
|
||||
try {
|
||||
// 异步回调才是最终支付成功依据,这里完成幂等开通 VIP 的核心逻辑。
|
||||
$this->vipPaymentService->markOrderAsPaid($vipPaymentOrder, $payload, 'async');
|
||||
|
||||
return response('success', 200);
|
||||
} catch (\Throwable $exception) {
|
||||
return response('error: '.$exception->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台 VIP 支付配置保存请求
|
||||
* 负责校验聊天室接入 NovaLink 支付中心所需的后台配置项
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateVipPaymentConfigRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前请求是否允许执行
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表单校验规则
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'vip_payment_enabled' => ['required', 'in:0,1'],
|
||||
'vip_payment_base_url' => ['nullable', 'url', 'max:255', 'required_if:vip_payment_enabled,1'],
|
||||
'vip_payment_app_key' => ['nullable', 'string', 'max:100', 'required_if:vip_payment_enabled,1'],
|
||||
'vip_payment_app_secret' => ['nullable', 'string', 'max:255', 'required_if:vip_payment_enabled,1'],
|
||||
'vip_payment_timeout' => ['nullable', 'integer', 'min:3', 'max:30'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中文错误提示
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'vip_payment_enabled.required' => '请先选择是否启用 VIP 支付',
|
||||
'vip_payment_base_url.required_if' => '启用 VIP 支付时,支付中心地址不能为空',
|
||||
'vip_payment_base_url.url' => '支付中心地址格式不正确',
|
||||
'vip_payment_app_key.required_if' => '启用 VIP 支付时,App Key 不能为空',
|
||||
'vip_payment_app_secret.required_if' => '启用 VIP 支付时,App Secret 不能为空',
|
||||
'vip_payment_timeout.integer' => '请求超时时间必须是整数',
|
||||
'vip_payment_timeout.min' => '请求超时时间不能小于 3 秒',
|
||||
'vip_payment_timeout.max' => '请求超时时间不能大于 30 秒',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建 VIP 支付订单请求
|
||||
* 负责校验前台用户发起 VIP 购买时提交的会员等级参数
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreateVipPaymentOrderRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许发起购买
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段校验规则
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'vip_level_id' => ['required', 'integer', 'exists:vip_levels,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中文错误提示
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'vip_level_id.required' => '请选择要购买的 VIP 等级',
|
||||
'vip_level_id.exists' => '所选 VIP 等级不存在或已被删除',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,14 @@ class User extends Authenticatable
|
||||
return $this->vipLevel?->icon ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:当前用户的 VIP 购买订单记录
|
||||
*/
|
||||
public function vipPaymentOrders(): HasMany
|
||||
{
|
||||
return $this->hasMany(VipPaymentOrder::class, 'user_id')->latest('id');
|
||||
}
|
||||
|
||||
// ── 职务相关关联 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class VipLevel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** @var string 表名 */
|
||||
protected $table = 'vip_levels';
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:VIP 会员支付订单模型
|
||||
* 负责记录聊天室用户购买 VIP 时的本地订单、远端支付单号与开通结果
|
||||
* 通过本地订单可串联下单、回调、会员开通与后台查询链路
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VipPaymentOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** @var string 表名 */
|
||||
protected $table = 'vip_payment_orders';
|
||||
|
||||
/** @var array<int, string> 可批量赋值字段 */
|
||||
protected $fillable = [
|
||||
'order_no',
|
||||
'merchant_order_no',
|
||||
'user_id',
|
||||
'vip_level_id',
|
||||
'status',
|
||||
'amount',
|
||||
'subject',
|
||||
'payment_order_no',
|
||||
'provider',
|
||||
'provider_trade_no',
|
||||
'vip_name',
|
||||
'vip_duration_days',
|
||||
'sync_return_payload',
|
||||
'async_notify_payload',
|
||||
'paid_at',
|
||||
'opened_vip_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'decimal:2',
|
||||
'vip_duration_days' => 'integer',
|
||||
'sync_return_payload' => 'array',
|
||||
'async_notify_payload' => 'array',
|
||||
'meta' => 'array',
|
||||
'paid_at' => 'datetime',
|
||||
'opened_vip_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:支付订单所属用户
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:支付订单对应的 VIP 等级
|
||||
*/
|
||||
public function vipLevel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(VipLevel::class, 'vip_level_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断订单是否已经完成支付
|
||||
*/
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === 'paid';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断订单是否已经完成会员开通
|
||||
*/
|
||||
public function isVipOpened(): bool
|
||||
{
|
||||
return $this->opened_vip_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:NovaLink 支付中心客户端
|
||||
* 负责读取聊天室后台配置,按支付中心要求生成签名并发起下单、查单等请求
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\VipPaymentOrder;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class VipPaymentCenterClient
|
||||
{
|
||||
/**
|
||||
* 判断 VIP 在线支付是否已启用
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return Sysparam::getValue('vip_payment_enabled', '0') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断支付中心接入配置是否完整
|
||||
*/
|
||||
public function hasValidConfig(): bool
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
|
||||
return $config['base_url'] !== ''
|
||||
&& $config['app_key'] !== ''
|
||||
&& $config['app_secret'] !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前支付中心配置
|
||||
*
|
||||
* @return array{base_url:string,app_key:string,app_secret:string,timeout:int}
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return [
|
||||
'base_url' => $this->normalizeBaseUrl(Sysparam::getValue('vip_payment_base_url', '')),
|
||||
'app_key' => Sysparam::getValue('vip_payment_app_key', ''),
|
||||
'app_secret' => Sysparam::getValue('vip_payment_app_secret', ''),
|
||||
'timeout' => (int) Sysparam::getValue('vip_payment_timeout', '10'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为本地 VIP 订单在支付中心创建远端支付单
|
||||
*
|
||||
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
|
||||
* @param string $syncReturnUrl 同步回调地址
|
||||
* @param string $asyncNotifyUrl 异步回调地址
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createOrder(VipPaymentOrder $vipPaymentOrder, string $syncReturnUrl, string $asyncNotifyUrl): array
|
||||
{
|
||||
$payload = [
|
||||
'merchant_order_no' => $vipPaymentOrder->merchant_order_no,
|
||||
'subject' => $vipPaymentOrder->subject,
|
||||
'body' => '聊天室 VIP 会员购买:'.$vipPaymentOrder->vip_name,
|
||||
'amount' => number_format((float) $vipPaymentOrder->amount, 2, '.', ''),
|
||||
'channel' => 'web',
|
||||
'business_type' => 'chatroom_vip',
|
||||
'sync_return_url' => $syncReturnUrl,
|
||||
'async_notify_url' => $asyncNotifyUrl,
|
||||
'meta' => [
|
||||
'user_id' => $vipPaymentOrder->user_id,
|
||||
'vip_level_id' => $vipPaymentOrder->vip_level_id,
|
||||
'local_order_no' => $vipPaymentOrder->order_no,
|
||||
],
|
||||
];
|
||||
|
||||
return $this->request('POST', '/api/open/v1/pay/orders', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付中心订单状态
|
||||
*
|
||||
* @param string $paymentOrderNo 远端平台支付单号
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function queryOrder(string $paymentOrderNo): array
|
||||
{
|
||||
return $this->request('GET', '/api/open/v1/pay/orders/'.$paymentOrderNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验支付中心异步回调签名
|
||||
*
|
||||
* @param string $signature 请求头携带的签名
|
||||
* @param string $rawBody 原始请求体 JSON
|
||||
*/
|
||||
public function isValidWebhookSignature(string $signature, string $rawBody): bool
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
|
||||
if ($signature === '' || $config['app_secret'] === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 支付中心回调直接使用 JSON 原文做 HMAC,避免数组重组后签名不一致。
|
||||
$expectedSignature = hash_hmac('sha256', $rawBody, $config['app_secret']);
|
||||
|
||||
return hash_equals($expectedSignature, $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用支付中心开放接口
|
||||
*
|
||||
* @param string $method 请求方法
|
||||
* @param string $path 接口路径
|
||||
* @param array<string, mixed> $payload 请求体
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function request(string $method, string $path, array $payload = []): array
|
||||
{
|
||||
if (! $this->isEnabled() || ! $this->hasValidConfig()) {
|
||||
throw new RuntimeException('VIP 支付中心尚未完成配置。');
|
||||
}
|
||||
|
||||
$config = $this->getConfig();
|
||||
$body = strtoupper($method) === 'GET'
|
||||
? ''
|
||||
: json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// 先按 NovaLink 约定计算签名头,再发起请求。
|
||||
$headers = $this->buildHeaders($method, $path, $body, $config['app_key'], $config['app_secret']);
|
||||
$request = Http::timeout(max($config['timeout'], 3))
|
||||
->acceptJson()
|
||||
->withHeaders($headers);
|
||||
|
||||
$response = strtoupper($method) === 'GET'
|
||||
? $request->get($config['base_url'].$path)
|
||||
: $request->withBody($body, 'application/json')->send($method, $config['base_url'].$path);
|
||||
|
||||
return $this->parseResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建开放 API 认证请求头
|
||||
*
|
||||
* @param string $method 请求方法
|
||||
* @param string $path 接口路径
|
||||
* @param string $body JSON 请求体
|
||||
* @param string $appKey 接入应用 Key
|
||||
* @param string $appSecret 接入应用 Secret
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildHeaders(string $method, string $path, string $body, string $appKey, string $appSecret): array
|
||||
{
|
||||
$timestamp = (string) now()->timestamp;
|
||||
$nonce = (string) str()->uuid();
|
||||
|
||||
// 保持与 NovaLink 中间件完全一致的签名原文拼接顺序。
|
||||
$signString = implode("\n", [
|
||||
strtoupper($method),
|
||||
$path,
|
||||
$timestamp,
|
||||
$nonce,
|
||||
hash('sha256', $body),
|
||||
]);
|
||||
|
||||
return [
|
||||
'X-App-Key' => $appKey,
|
||||
'X-Timestamp' => $timestamp,
|
||||
'X-Nonce' => $nonce,
|
||||
'X-Signature' => hash_hmac('sha256', $signString, $appSecret),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付中心接口响应
|
||||
*
|
||||
* @param Response $response HTTP 响应对象
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function parseResponse(Response $response): array
|
||||
{
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('支付中心请求失败:'.$response->body());
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
if (! is_array($json) || ! ($json['success'] ?? false)) {
|
||||
throw new RuntimeException((string) ($json['message'] ?? '支付中心返回了无效响应。'));
|
||||
}
|
||||
|
||||
return (array) ($json['data'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化支付中心地址
|
||||
*
|
||||
* @param string $baseUrl 后台录入的地址
|
||||
*/
|
||||
private function normalizeBaseUrl(string $baseUrl): string
|
||||
{
|
||||
return rtrim(trim($baseUrl), '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:VIP 支付业务服务
|
||||
* 负责本地 VIP 订单创建、同步远端支付单、处理回调并在支付成功后开通会员
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\VipLevel;
|
||||
use App\Models\VipPaymentOrder;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class VipPaymentService
|
||||
{
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param VipPaymentCenterClient $paymentCenterClient 支付中心客户端
|
||||
* @param VipService $vipService VIP 开通服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly VipPaymentCenterClient $paymentCenterClient,
|
||||
private readonly VipService $vipService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建本地 VIP 支付订单
|
||||
*
|
||||
* @param User $user 购买用户
|
||||
* @param VipLevel $vipLevel 目标 VIP 等级
|
||||
*/
|
||||
public function createLocalOrder(User $user, VipLevel $vipLevel): VipPaymentOrder
|
||||
{
|
||||
if ((float) $vipLevel->price <= 0) {
|
||||
throw new RuntimeException('当前 VIP 等级未设置在线支付价格,暂不支持直接购买。');
|
||||
}
|
||||
|
||||
return VipPaymentOrder::create([
|
||||
'order_no' => $this->generateOrderNo(),
|
||||
'merchant_order_no' => $this->generateOrderNo(),
|
||||
'user_id' => $user->id,
|
||||
'vip_level_id' => $vipLevel->id,
|
||||
'status' => 'created',
|
||||
'amount' => $vipLevel->price,
|
||||
'subject' => '购买 VIP 会员 - '.$vipLevel->name,
|
||||
'provider' => 'alipay',
|
||||
'vip_name' => $vipLevel->name,
|
||||
'vip_duration_days' => (int) $vipLevel->duration_days,
|
||||
'meta' => [
|
||||
'username' => $user->username,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用支付中心创建远端支付单
|
||||
*
|
||||
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createRemoteOrder(VipPaymentOrder $vipPaymentOrder): array
|
||||
{
|
||||
$remoteOrder = $this->paymentCenterClient->createOrder(
|
||||
$vipPaymentOrder,
|
||||
route('vip.payment.return'),
|
||||
route('vip.payment.notify')
|
||||
);
|
||||
|
||||
// 将远端平台支付单号回填到本地订单,后续回调和补单都依赖它。
|
||||
$vipPaymentOrder->update([
|
||||
'payment_order_no' => (string) ($remoteOrder['payment_order_no'] ?? ''),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return $remoteOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据远端支付单号查找本地订单
|
||||
*
|
||||
* @param string|null $paymentOrderNo 远端平台支付单号
|
||||
*/
|
||||
public function findByPaymentOrderNo(?string $paymentOrderNo): ?VipPaymentOrder
|
||||
{
|
||||
if (! $paymentOrderNo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return VipPaymentOrder::query()->where('payment_order_no', $paymentOrderNo)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据本地业务订单号查找本地订单
|
||||
*
|
||||
* @param string|null $merchantOrderNo 商户业务订单号
|
||||
*/
|
||||
public function findByMerchantOrderNo(?string $merchantOrderNo): ?VipPaymentOrder
|
||||
{
|
||||
if (! $merchantOrderNo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return VipPaymentOrder::query()->where('merchant_order_no', $merchantOrderNo)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 在同步回调时主动补查远端状态
|
||||
*
|
||||
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
|
||||
*/
|
||||
public function syncRemoteStatus(VipPaymentOrder $vipPaymentOrder): VipPaymentOrder
|
||||
{
|
||||
if (! $vipPaymentOrder->payment_order_no || $vipPaymentOrder->isVipOpened()) {
|
||||
return $vipPaymentOrder;
|
||||
}
|
||||
|
||||
$remoteOrder = $this->paymentCenterClient->queryOrder($vipPaymentOrder->payment_order_no);
|
||||
|
||||
if (($remoteOrder['status'] ?? null) === 'paid') {
|
||||
return $this->markOrderAsPaid($vipPaymentOrder, $remoteOrder, 'sync');
|
||||
}
|
||||
|
||||
if (($remoteOrder['status'] ?? null) === 'closed') {
|
||||
$vipPaymentOrder->update(['status' => 'closed']);
|
||||
}
|
||||
|
||||
return $vipPaymentOrder->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录同步回调原始参数
|
||||
*
|
||||
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
|
||||
* @param array<string, mixed> $payload 同步回调参数
|
||||
*/
|
||||
public function recordSyncReturn(VipPaymentOrder $vipPaymentOrder, array $payload): void
|
||||
{
|
||||
$vipPaymentOrder->update([
|
||||
'sync_return_payload' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据异步通知将订单标记为已支付,并完成会员开通
|
||||
*
|
||||
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
|
||||
* @param array<string, mixed> $payload 支付中心回调数据
|
||||
* @param string $source 触发来源:sync|async
|
||||
*/
|
||||
public function markOrderAsPaid(VipPaymentOrder $vipPaymentOrder, array $payload, string $source = 'async'): VipPaymentOrder
|
||||
{
|
||||
return DB::transaction(function () use ($vipPaymentOrder, $payload, $source) {
|
||||
$lockedOrder = VipPaymentOrder::query()
|
||||
->with(['user', 'vipLevel'])
|
||||
->lockForUpdate()
|
||||
->findOrFail($vipPaymentOrder->id);
|
||||
|
||||
$amount = number_format((float) $lockedOrder->amount, 2, '.', '');
|
||||
$callbackAmount = number_format((float) ($payload['amount'] ?? 0), 2, '.', '');
|
||||
|
||||
if ($amount !== $callbackAmount) {
|
||||
throw new RuntimeException('支付金额校验失败,已拒绝开通会员。');
|
||||
}
|
||||
|
||||
// 无论是同步还是异步,都保留原始回调数据,方便后台排查问题。
|
||||
$lockedOrder->fill([
|
||||
'payment_order_no' => (string) ($payload['payment_order_no'] ?? $lockedOrder->payment_order_no),
|
||||
'provider' => (string) ($payload['provider'] ?? $lockedOrder->provider ?? 'alipay'),
|
||||
'provider_trade_no' => (string) ($payload['provider_trade_no'] ?? $lockedOrder->provider_trade_no),
|
||||
'status' => 'paid',
|
||||
'paid_at' => isset($payload['paid_at']) ? Carbon::parse((string) $payload['paid_at']) : ($lockedOrder->paid_at ?? now()),
|
||||
]);
|
||||
|
||||
if ($source === 'async') {
|
||||
$lockedOrder->async_notify_payload = $payload;
|
||||
}
|
||||
|
||||
if ($source === 'sync') {
|
||||
$lockedOrder->sync_return_payload = array_merge($lockedOrder->sync_return_payload ?? [], $payload);
|
||||
}
|
||||
|
||||
$lockedOrder->save();
|
||||
|
||||
if (! $lockedOrder->isVipOpened()) {
|
||||
// 只在首次成功支付时开通会员,防止重复回调导致会员时长重复叠加。
|
||||
$user = User::query()->lockForUpdate()->findOrFail($lockedOrder->user_id);
|
||||
$this->vipService->grantVip($user, $lockedOrder->vip_level_id, (int) $lockedOrder->vip_duration_days);
|
||||
|
||||
$lockedOrder->update([
|
||||
'opened_vip_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $lockedOrder->fresh(['user', 'vipLevel']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本地 VIP 订单号
|
||||
*/
|
||||
private function generateOrderNo(): string
|
||||
{
|
||||
return 'VPO'.date('YmdHis').random_int(1000, 9999);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user