feat: add vip payment and member center

This commit is contained in:
2026-04-11 12:01:52 +08:00
parent db26820544
commit 746116d325
23 changed files with 1781 additions and 2 deletions
@@ -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 等级不存在或已被删除',
];
}
}
+8
View File
@@ -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');
}
// ── 职务相关关联 ──────────────────────────────────────────────────────
/**
+3
View File
@@ -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';
+92
View File
@@ -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;
}
}
+207
View File
@@ -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), '/');
}
}
+209
View File
@@ -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);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* 文件功能:VIP 等级测试工厂
* 用于在测试中快速生成可购买的 VIP 等级数据
*/
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\VipLevel>
*/
class VipLevelFactory extends Factory
{
/**
* 定义默认测试数据
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => '测试会员'.fake()->unique()->numberBetween(1, 9999),
'icon' => '👑',
'color' => '#f59e0b',
'exp_multiplier' => 1.5,
'jjb_multiplier' => 1.2,
'join_templates' => null,
'leave_templates' => null,
'sort_order' => fake()->numberBetween(1, 20),
'price' => fake()->numberBetween(10, 99),
'duration_days' => 30,
];
}
}
@@ -0,0 +1,46 @@
<?php
/**
* 文件功能:VIP 支付订单测试工厂
* 用于在测试中快速生成本地 VIP 支付订单与远端支付映射数据
*/
namespace Database\Factories;
use App\Models\User;
use App\Models\VipLevel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\VipPaymentOrder>
*/
class VipPaymentOrderFactory extends Factory
{
/**
* 定义默认测试数据
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'order_no' => 'VPO'.fake()->unique()->numerify('##########'),
'merchant_order_no' => 'VPO'.fake()->unique()->numerify('##########'),
'user_id' => User::factory(),
'vip_level_id' => VipLevel::factory(),
'status' => 'pending',
'amount' => 30.00,
'subject' => '购买 VIP 会员 - 测试套餐',
'payment_order_no' => 'PO'.fake()->unique()->numerify('############'),
'provider' => 'alipay',
'provider_trade_no' => null,
'vip_name' => '测试会员',
'vip_duration_days' => 30,
'sync_return_payload' => null,
'async_notify_payload' => null,
'paid_at' => null,
'opened_vip_at' => null,
'meta' => ['username' => fake()->userName()],
];
}
}
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 文件功能:创建 VIP 支付订单表迁移
* 为聊天室 VIP 在线支付提供本地订单存储能力
*/
return new class extends Migration
{
/**
* 运行迁移
* 创建 VIP 支付订单表,记录本地订单与远端支付单的映射关系
*/
public function up(): void
{
Schema::create('vip_payment_orders', function (Blueprint $table) {
$table->id();
$table->string('order_no', 32)->unique()->comment('本地 VIP 支付订单号');
$table->string('merchant_order_no', 32)->unique()->comment('提交给支付中心的业务订单号');
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('购买用户');
$table->foreignId('vip_level_id')->constrained('vip_levels')->restrictOnDelete()->comment('目标 VIP 等级');
$table->string('status', 20)->default('created')->index()->comment('订单状态:created|pending|paid|closed|failed');
$table->decimal('amount', 10, 2)->comment('支付金额');
$table->string('subject', 120)->comment('支付标题');
$table->string('payment_order_no', 32)->nullable()->index()->comment('NovaLink 平台支付单号');
$table->string('provider', 30)->nullable()->comment('支付渠道,例如 alipay');
$table->string('provider_trade_no', 80)->nullable()->comment('第三方支付流水号');
$table->string('vip_name', 60)->comment('下单时快照的 VIP 名称');
$table->unsignedInteger('vip_duration_days')->default(0)->comment('下单时快照的会员时长');
$table->json('sync_return_payload')->nullable()->comment('同步回调原始数据');
$table->json('async_notify_payload')->nullable()->comment('异步回调原始数据');
$table->timestamp('paid_at')->nullable()->comment('支付成功时间');
$table->timestamp('opened_vip_at')->nullable()->comment('实际开通会员时间');
$table->json('meta')->nullable()->comment('扩展信息');
$table->timestamps();
});
}
/**
* 回滚迁移
* 删除 VIP 支付订单表
*/
public function down(): void
{
Schema::dropIfExists('vip_payment_orders');
}
};
@@ -74,6 +74,10 @@
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '👑 VIP 会员等级' !!}
</a>
<a href="{{ route('admin.vip-payment-logs.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip-payment-logs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🧾 会员购买日志' !!}
</a>
<a href="{{ route('admin.shop.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.shop.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🛒 商店管理' !!}
@@ -111,6 +115,10 @@
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.smtp.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📧 邮件 SMTP 配置
</a>
<a href="{{ route('admin.vip-payment.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip-payment.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
💳 VIP 支付配置
</a>
<a href="{{ route('admin.ai-providers.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.ai-providers.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🤖 AI 厂商配置
@@ -0,0 +1,116 @@
{{--
文件功能:后台会员购买日志页面
供后台管理员查询全站 VIP 购买订单,查看支付状态与会员开通情况
--}}
@extends('admin.layouts.app')
@section('title', '会员购买日志')
@section('content')
<div class="bg-white p-5 rounded-xl border border-gray-100 shadow-sm mb-6">
<form action="{{ route('admin.vip-payment-logs.index') }}" method="GET" class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[140px]">
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">用户名</label>
<input type="text" name="username" value="{{ request('username') }}"
class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2"
placeholder="支持模糊搜索">
</div>
<div class="flex-1 min-w-[140px]">
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">订单号/流水号</label>
<input type="text" name="order_no" value="{{ request('order_no') }}"
class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2"
placeholder="本地单号 / 平台单号 / 第三方单号">
</div>
<div class="flex-1 min-w-[130px]">
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">订单状态</label>
<select name="status"
class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2">
<option value="">全部状态</option>
@foreach ($statusOptions as $value => $label)
<option value="{{ $value }}" {{ request('status') === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="flex-none w-36">
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">开始日期</label>
<input type="date" name="date_start" value="{{ request('date_start') }}"
class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2">
</div>
<div class="flex-none w-36">
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">结束日期</label>
<input type="date" name="date_end" value="{{ request('date_end') }}"
class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2">
</div>
<div class="flex-none flex items-center space-x-2">
<button type="submit"
class="bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-5 rounded-lg shadow-sm transition text-sm">查询</button>
<a href="{{ route('admin.vip-payment-logs.index') }}"
class="bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium py-2 px-4 rounded-lg shadow-sm transition text-sm">重置</a>
</div>
</form>
</div>
<div class="bg-white rounded shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse min-w-[1200px]">
<thead>
<tr class="bg-gray-50 text-gray-600 text-sm uppercase tracking-wider border-b border-gray-200">
<th class="px-6 py-4 font-semibold whitespace-nowrap">订单ID</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">用户</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">会员等级</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">本地订单号</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">平台支付单号</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">支付金额</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">状态</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">支付时间</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap">开通时间</th>
<th class="px-6 py-4 font-semibold whitespace-nowrap text-right">创建时间</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($logs as $log)
@php
$statusClass = match ($log->status) {
'paid' => 'bg-emerald-100 text-emerald-700',
'pending' => 'bg-amber-100 text-amber-700',
'closed' => 'bg-gray-100 text-gray-500',
'failed' => 'bg-rose-100 text-rose-700',
default => 'bg-slate-100 text-slate-700',
};
@endphp
<tr class="hover:bg-gray-50 transition cursor-default">
<td class="px-6 py-4 text-sm text-gray-500">#{{ $log->id }}</td>
<td class="px-6 py-4">
<div class="font-bold text-gray-800">{{ $log->user?->username ?? '未知用户' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span style="color: {{ $log->vipLevel?->color ?: '#111827' }}">{{ $log->vipLevel?->icon ?: '👑' }}</span>
<span class="font-bold text-gray-800 ml-1">{{ $log->vip_name }}</span>
</td>
<td class="px-6 py-4 font-mono text-xs text-gray-600 whitespace-nowrap">{{ $log->order_no }}</td>
<td class="px-6 py-4 font-mono text-xs text-gray-600 whitespace-nowrap">{{ $log->payment_order_no ?: '-' }}</td>
<td class="px-6 py-4 font-bold text-rose-600 whitespace-nowrap">¥{{ number_format((float) $log->amount, 2) }}</td>
<td class="px-6 py-4 whitespace-nowrap"><span class="inline-flex px-2.5 py-1 rounded-full text-xs font-bold {{ $statusClass }}">{{ $statusOptions[$log->status] ?? $log->status }}</span></td>
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">{{ $log->paid_at?->format('Y-m-d H:i:s') ?? '未支付' }}</td>
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">{{ $log->opened_vip_at?->format('Y-m-d H:i:s') ?? '未开通' }}</td>
<td class="px-6 py-4 text-sm text-gray-500 text-right whitespace-nowrap">{{ $log->created_at->format('Y-m-d H:i:s') }}</td>
</tr>
@empty
<tr>
<td colspan="10" class="px-6 py-8 text-center text-gray-500">📭 暂无会员购买记录</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($logs->hasPages())
<div class="px-6 py-4 border-t border-gray-200">{{ $logs->links() }}</div>
@endif
</div>
@endsection
@@ -0,0 +1,108 @@
{{--
文件功能:后台 VIP 支付配置页面
用于维护聊天室对接 NovaLink 支付中心所需的开关、地址与签名密钥
--}}
@extends('admin.layouts.app')
@section('title', 'VIP 支付配置')
@section('content')
@php
$enabled = $params['vip_payment_enabled'] ?? '0';
$syncReturnUrl = route('vip.payment.return');
$asyncNotifyUrl = route('vip.payment.notify');
@endphp
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<div>
<h2 class="text-lg font-bold text-gray-800">VIP 在线支付配置</h2>
<p class="text-xs text-gray-500 mt-1">当前仅对接 NovaLink 支付中心,用于聊天室 VIP 会员购买。</p>
</div>
</div>
<div class="p-6">
<div class="mb-6 bg-indigo-50 border border-indigo-200 rounded-xl p-4 text-sm text-indigo-800">
<p class="font-bold">接入说明</p>
<p class="mt-1">请先在 NovaLink 支付系统中创建“接入应用”,再把该应用的地址、App Key App Secret 填入这里。</p>
</div>
<form action="{{ route('admin.vip-payment.update') }}" method="POST" class="space-y-6 max-w-3xl">
@csrf
@method('PUT')
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">{{ $descriptions['vip_payment_enabled'] }}</label>
<div class="flex items-center gap-6 text-sm text-gray-700">
<label class="inline-flex items-center gap-2">
<input type="radio" name="vip_payment_enabled" value="1" {{ old('vip_payment_enabled', $enabled) === '1' ? 'checked' : '' }}>
<span>开启</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="radio" name="vip_payment_enabled" value="0" {{ old('vip_payment_enabled', $enabled) !== '1' ? 'checked' : '' }}>
<span>关闭</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">{{ $descriptions['vip_payment_base_url'] }}</label>
<input type="text" name="vip_payment_base_url"
value="{{ old('vip_payment_base_url', $params['vip_payment_base_url'] ?? '') }}"
placeholder="https://novalink.test"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">{{ $descriptions['vip_payment_app_key'] }}</label>
<input type="text" name="vip_payment_app_key"
value="{{ old('vip_payment_app_key', $params['vip_payment_app_key'] ?? '') }}"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">{{ $descriptions['vip_payment_timeout'] }}</label>
<input type="number" name="vip_payment_timeout"
value="{{ old('vip_payment_timeout', $params['vip_payment_timeout'] ?? 10) }}"
min="3" max="30"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
</div>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">{{ $descriptions['vip_payment_app_secret'] }}</label>
<input type="password" name="vip_payment_app_secret"
value="{{ old('vip_payment_app_secret', $params['vip_payment_app_secret'] ?? '') }}"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
<p class="mt-2 text-xs text-gray-500">该密钥仅用于服务器与支付中心之间的签名通信,请勿提供给前端。</p>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-900 space-y-4">
<div>
<p class="font-bold">系统回调说明</p>
<p class="mt-1">请把下面两个地址分别填写到 NovaLink 支付系统“编辑应用”的同步返回地址与异步通知地址中。</p>
</div>
<div>
<label class="block text-sm font-bold text-amber-900 mb-2">同步返回地址</label>
<input type="text" value="{{ $syncReturnUrl }}" readonly
onclick="this.select();"
class="w-full border-amber-200 rounded-md shadow-sm p-2.5 bg-white border text-gray-700 font-mono text-sm cursor-pointer">
</div>
<div>
<label class="block text-sm font-bold text-amber-900 mb-2">异步通知地址</label>
<input type="text" value="{{ $asyncNotifyUrl }}" readonly
onclick="this.select();"
class="w-full border-amber-200 rounded-md shadow-sm p-2.5 bg-white border text-gray-700 font-mono text-sm cursor-pointer">
</div>
</div>
<div class="pt-6 border-t flex gap-3">
<button type="submit"
class="px-6 py-2 bg-indigo-600 text-white rounded-md font-bold hover:bg-indigo-700 shadow-sm transition">保存配置</button>
</div>
</form>
</div>
</div>
@endsection
@@ -32,6 +32,7 @@
</div>
<div class="mobile-drawer-body">
<div class="mobile-tool-btn" onclick="closeMobileDrawer();openShopModal();">🛒<br>商店</div>
<div class="mobile-tool-btn" onclick="closeMobileDrawer();window.open('{{ route('vip.center') }}','_blank');">👑<br>会员</div>
<div class="mobile-tool-btn" onclick="closeMobileDrawer();saveExp();">💾<br>存点</div>
<div class="mobile-tool-btn" onclick="closeMobileDrawer();openGameHall();">🎮<br>娱乐</div>
<div class="mobile-tool-btn" onclick="closeMobileDrawer();openBankModal();">🏦<br>银行</div>
@@ -16,6 +16,7 @@
{{-- ═══════════ 竖向工具条按钮 ═══════════ --}}
<div class="chat-toolbar" id="toolbar-strip">
<div class="tool-btn" onclick="openShopModal()" title="购买道具">商店</div>
<div class="tool-btn" onclick="window.open('{{ route('vip.center') }}', '_blank')" title="会员中心">会员</div>
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
<div class="tool-btn" onclick="openGameHall()" title="娱乐游戏大厅">娱乐</div>
<div class="tool-btn" onclick="window.dispatchEvent(new CustomEvent('open-earn-panel'))" title="看视频赚金币">赚钱</div>
+31 -2
View File
@@ -74,6 +74,9 @@
// VIP 等级
$vipLevels = VipLevel::orderBy('sort_order')->get();
// VIP 在线支付开关
$vipPaymentEnabled = Sysparam::getValue('vip_payment_enabled', '0') === '1';
// 礼物列表
$gifts = Gift::activeList();
@@ -378,7 +381,8 @@
<th class="px-4 py-2 text-center">经验倍率</th>
<th class="px-4 py-2 text-center">金币倍率</th>
<th class="px-4 py-2 text-center">时长</th>
<th class="px-4 py-2 text-center rounded-tr-md">价格</th>
<th class="px-4 py-2 text-center">价格</th>
<th class="px-4 py-2 text-center rounded-tr-md">操作</th>
</tr>
</thead>
<tbody>
@@ -391,14 +395,39 @@
×{{ $vip->exp_multiplier }}</td>
<td class="px-4 py-2.5 text-center text-amber-600 font-bold">
×{{ $vip->jjb_multiplier }}</td>
<td class="px-4 py-2.5 text-center text-gray-600">{{ $vip->duration_days }} </td>
<td class="px-4 py-2.5 text-center text-gray-600">{{ $vip->duration_days > 0 ? $vip->duration_days . ' 天' : '永久' }}</td>
<td class="px-4 py-2.5 text-center text-rose-600 font-bold">
{{ $vip->price > 0 ? $vip->price . ' 元' : '免费' }}</td>
<td class="px-4 py-2.5 text-center">
@php
$isCurrentVipLevel = auth()->user()?->isVip() && (int) auth()->user()?->vip_level_id === (int) $vip->id;
@endphp
@if ($vip->price > 0 && $vipPaymentEnabled)
<form action="{{ route('vip.payment.store') }}" method="POST"
onsubmit="return confirm('确认支付 {{ $vip->price }} 元购买【{{ $vip->name }}】吗?');">
@csrf
<input type="hidden" name="vip_level_id" value="{{ $vip->id }}">
<button type="submit"
class="px-3 py-1.5 rounded-lg bg-indigo-600 text-white text-xs font-bold hover:bg-indigo-700 transition">
{{ $isCurrentVipLevel ? '立即续费' : '立即购买' }}
</button>
</form>
@elseif ($vip->price > 0)
<span class="inline-flex px-3 py-1.5 rounded-lg bg-gray-100 text-gray-500 text-xs font-bold">暂未开启</span>
@else
<span class="inline-flex px-3 py-1.5 rounded-lg bg-emerald-100 text-emerald-700 text-xs font-bold">联系管理员</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4 rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-xs text-indigo-700">
VIP 会员支付由平台支付中心提供,最终开通结果以异步回调为准。
</div>
</section>
@endif
+201
View File
@@ -0,0 +1,201 @@
{{--
文件功能:前台会员中心页面
展示当前用户会员状态、会员等级权益、购买入口以及用户自己的会员购买记录
--}}
@extends('layouts.app')
@section('title', '会员中心 - 飘落流星')
@section('nav-icon', '👑')
@section('nav-title', '会员中心')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<section class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-white rounded-3xl border border-amber-100 shadow-sm p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-bold text-amber-600">我的会员状态</p>
<h2 class="mt-2 text-2xl font-extrabold text-gray-900">
@if ($user->isVip())
{{ $user->vipName() ?: '尊贵会员' }}
@else
普通用户
@endif
</h2>
<p class="mt-3 text-sm text-gray-500">
@if ($user->isVip())
当前已开通会员权益,
@if ($user->hy_time)
到期时间:{{ $user->hy_time->format('Y-m-d H:i') }}
@else
当前为永久会员
@endif
@else
你当前还未开通会员,开通后可享受经验加成、金币加成和专属身份展示。
@endif
</p>
</div>
<div class="shrink-0 rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 text-white px-5 py-4 text-center min-w-[120px]">
<div class="text-sm opacity-90">当前徽章</div>
<div class="mt-2 text-3xl">{{ $user->vipIcon() ?: '⭐' }}</div>
</div>
</div>
<div class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="rounded-2xl bg-indigo-50 border border-indigo-100 p-4">
<div class="text-xs font-bold text-indigo-500 uppercase tracking-wider">经验倍率</div>
<div class="mt-2 text-2xl font-extrabold text-indigo-900">×{{ $user->vipLevel?->exp_multiplier ?? 1 }}</div>
</div>
<div class="rounded-2xl bg-amber-50 border border-amber-100 p-4">
<div class="text-xs font-bold text-amber-500 uppercase tracking-wider">金币倍率</div>
<div class="mt-2 text-2xl font-extrabold text-amber-900">×{{ $user->vipLevel?->jjb_multiplier ?? 1 }}</div>
</div>
<div class="rounded-2xl bg-emerald-50 border border-emerald-100 p-4">
<div class="text-xs font-bold text-emerald-500 uppercase tracking-wider">累计已支付</div>
<div class="mt-2 text-2xl font-extrabold text-emerald-900">¥{{ number_format($totalAmount, 2) }}</div>
<div class="mt-1 text-xs text-emerald-700">成功订单 {{ $paidOrders }} </div>
</div>
</div>
</div>
<div class="bg-slate-900 text-white rounded-3xl shadow-sm p-6">
<p class="text-sm font-bold text-amber-300">会员权益总览</p>
<ul class="mt-4 space-y-3 text-sm text-slate-200">
<li class="flex items-start gap-2"><span></span><span>挂机经验按会员倍率加成,升级更快。</span></li>
<li class="flex items-start gap-2"><span>💰</span><span>金币收益按会员倍率加成,参与玩法更轻松。</span></li>
<li class="flex items-start gap-2"><span>👑</span><span>昵称旁展示专属会员图标与会员名称。</span></li>
<li class="flex items-start gap-2"><span></span><span>可使用会员专属进场欢迎语与离场提示语。</span></li>
</ul>
<div class="mt-6 rounded-2xl bg-white/10 border border-white/10 px-4 py-3 text-xs text-slate-200">
会员支付由平台支付中心统一处理,最终是否开通以异步回调结果为准。
</div>
</div>
</section>
<section class="bg-white rounded-3xl border border-gray-100 shadow-sm p-6">
<div class="flex items-center justify-between gap-4 mb-5">
<div>
<h3 class="text-xl font-extrabold text-gray-900">会员等级列表</h3>
<p class="mt-1 text-sm text-gray-500">不同档位提供不同倍率与时长,你可以按需要购买或续费。</p>
</div>
@if (! $vipPaymentEnabled)
<span class="inline-flex px-3 py-2 rounded-xl bg-gray-100 text-gray-500 text-sm font-bold">支付暂未开启</span>
@endif
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
@foreach ($vipLevels as $vip)
@php
$isCurrentVipLevel = $user->isVip() && (int) $user->vip_level_id === (int) $vip->id;
@endphp
<div class="rounded-3xl border {{ $isCurrentVipLevel ? 'border-amber-300 bg-amber-50/60' : 'border-gray-100 bg-white' }} shadow-sm p-5 flex flex-col">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-white border border-gray-100 flex items-center justify-center text-2xl shadow-sm">{{ $vip->icon }}</div>
<div>
<h4 class="text-lg font-extrabold" style="color: {{ $vip->color ?: '#111827' }}">{{ $vip->name }}</h4>
<p class="text-xs text-gray-500">当前已有 {{ $vip->users_count }} 位用户使用</p>
</div>
</div>
@if ($isCurrentVipLevel)
<span class="inline-flex px-2.5 py-1 rounded-full bg-amber-500 text-white text-xs font-bold">当前等级</span>
@endif
</div>
<div class="mt-5 grid grid-cols-2 gap-3 text-sm">
<div class="rounded-2xl bg-indigo-50 border border-indigo-100 p-3">
<div class="text-xs text-indigo-500 font-bold">经验倍率</div>
<div class="mt-1 text-xl font-extrabold text-indigo-900">×{{ $vip->exp_multiplier }}</div>
</div>
<div class="rounded-2xl bg-amber-50 border border-amber-100 p-3">
<div class="text-xs text-amber-500 font-bold">金币倍率</div>
<div class="mt-1 text-xl font-extrabold text-amber-900">×{{ $vip->jjb_multiplier }}</div>
</div>
</div>
<div class="mt-4 space-y-2 text-sm text-gray-600 flex-1">
<div class="flex items-center justify-between"><span>有效时长</span><span class="font-bold text-gray-900">{{ $vip->duration_days > 0 ? $vip->duration_days . ' 天' : '永久' }}</span></div>
<div class="flex items-center justify-between"><span>支付金额</span><span class="font-bold text-rose-600">{{ $vip->price > 0 ? '¥' . $vip->price : '联系管理员' }}</span></div>
</div>
<div class="mt-5">
@if ($vip->price > 0 && $vipPaymentEnabled)
<form action="{{ route('vip.payment.store') }}" method="POST"
onsubmit="return confirm('确认支付 {{ $vip->price }} 元购买【{{ $vip->name }}】吗?');">
@csrf
<input type="hidden" name="vip_level_id" value="{{ $vip->id }}">
<button type="submit" class="w-full py-3 rounded-2xl bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition shadow-sm">
{{ $isCurrentVipLevel ? '立即续费' : '立即购买' }}
</button>
</form>
@elseif ($vip->price > 0)
<div class="w-full py-3 rounded-2xl bg-gray-100 text-gray-500 text-center font-bold">支付暂未开启</div>
@else
<div class="w-full py-3 rounded-2xl bg-emerald-100 text-emerald-700 text-center font-bold">请联系管理员开通</div>
@endif
</div>
</div>
@endforeach
</div>
</section>
<section class="bg-white rounded-3xl border border-gray-100 shadow-sm p-6">
<div class="flex items-center justify-between gap-4 mb-5">
<div>
<h3 class="text-xl font-extrabold text-gray-900">我的购买记录</h3>
<p class="mt-1 text-sm text-gray-500">这里只显示你自己的会员订单记录,方便查看支付和开通状态。</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[860px]">
<thead>
<tr class="bg-slate-50 text-slate-600">
<th class="px-4 py-3 text-left font-bold rounded-tl-2xl">本地订单号</th>
<th class="px-4 py-3 text-left font-bold">会员等级</th>
<th class="px-4 py-3 text-center font-bold">金额</th>
<th class="px-4 py-3 text-center font-bold">状态</th>
<th class="px-4 py-3 text-center font-bold">支付时间</th>
<th class="px-4 py-3 text-center font-bold rounded-tr-2xl">开通时间</th>
</tr>
</thead>
<tbody>
@forelse ($paymentLogs as $log)
@php
$statusMap = [
'created' => ['text' => '待创建', 'class' => 'bg-slate-100 text-slate-700'],
'pending' => ['text' => '待支付', 'class' => 'bg-amber-100 text-amber-700'],
'paid' => ['text' => '已支付', 'class' => 'bg-emerald-100 text-emerald-700'],
'closed' => ['text' => '已关闭', 'class' => 'bg-gray-100 text-gray-500'],
'failed' => ['text' => '失败', 'class' => 'bg-rose-100 text-rose-700'],
];
$status = $statusMap[$log->status] ?? ['text' => $log->status, 'class' => 'bg-slate-100 text-slate-700'];
@endphp
<tr class="border-t border-gray-100 hover:bg-slate-50/80">
<td class="px-4 py-3 font-mono text-xs text-gray-600">{{ $log->order_no }}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span style="color: {{ $log->vipLevel?->color ?: '#111827' }}">{{ $log->vipLevel?->icon ?: '👑' }}</span>
<span class="font-bold text-gray-900">{{ $log->vip_name }}</span>
</div>
</td>
<td class="px-4 py-3 text-center font-bold text-rose-600">¥{{ number_format((float) $log->amount, 2) }}</td>
<td class="px-4 py-3 text-center"><span class="inline-flex px-2.5 py-1 rounded-full text-xs font-bold {{ $status['class'] }}">{{ $status['text'] }}</span></td>
<td class="px-4 py-3 text-center text-gray-500">{{ $log->paid_at?->format('Y-m-d H:i') ?? '未支付' }}</td>
<td class="px-4 py-3 text-center text-gray-500">{{ $log->opened_vip_at?->format('Y-m-d H:i') ?? '未开通' }}</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-12 text-center text-gray-400">暂无会员购买记录,开通后会显示在这里。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($paymentLogs->hasPages())
<div class="mt-5">{{ $paymentLogs->links() }}</div>
@endif
</section>
</div>
@endsection
+15
View File
@@ -34,6 +34,10 @@ Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
Route::middleware(['chat.auth'])->group(function () {
// ---- 第六阶段:大厅与房间管理 ----
Route::get('/guide', fn () => view('rooms.guide'))->name('guide');
Route::get('/vip-center', [\App\Http\Controllers\VipCenterController::class, 'index'])->name('vip.center');
// ---- VIP 在线支付 ----
Route::post('/vip/payment', [\App\Http\Controllers\VipPaymentController::class, 'store'])->name('vip.payment.store');
Route::get('/rooms', [RoomController::class, 'index'])->name('rooms.index');
Route::post('/rooms', [RoomController::class, 'store'])->name('rooms.store');
Route::put('/rooms/{id}', [RoomController::class, 'update'])->name('rooms.update');
@@ -416,6 +420,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
Route::post('/vip', [\App\Http\Controllers\Admin\VipController::class, 'store'])->name('vip.store');
Route::put('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'update'])->name('vip.update');
Route::delete('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'destroy'])->name('vip.destroy');
Route::get('/vip-payment-logs', [\App\Http\Controllers\Admin\VipPaymentLogController::class, 'index'])->name('vip-payment-logs.index');
// 全局用户金币/积分流水
Route::get('/currency-logs', [\App\Http\Controllers\Admin\CurrencyLogController::class, 'index'])->name('currency-logs.index');
@@ -525,6 +530,10 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
Route::put('/smtp', [\App\Http\Controllers\Admin\SmtpController::class, 'update'])->name('smtp.update');
Route::post('/smtp/test', [\App\Http\Controllers\Admin\SmtpController::class, 'test'])->name('smtp.test');
// VIP 支付配置管理
Route::get('/vip-payment', [\App\Http\Controllers\Admin\VipPaymentConfigController::class, 'edit'])->name('vip-payment.edit');
Route::put('/vip-payment', [\App\Http\Controllers\Admin\VipPaymentConfigController::class, 'update'])->name('vip-payment.update');
// 部门新增/删除(编辑已在 superlevel 层)
Route::post('/departments', [\App\Http\Controllers\Admin\DepartmentController::class, 'store'])->name('departments.store');
Route::delete('/departments/{department}', [\App\Http\Controllers\Admin\DepartmentController::class, 'destroy'])->name('departments.destroy');
@@ -560,6 +569,12 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
});
});
// ---- VIP 支付回调(公开入口) ----
Route::get('/vip/payment/return', [\App\Http\Controllers\VipPaymentController::class, 'handleReturn'])->name('vip.payment.return');
Route::post('/vip/payment/notify', [\App\Http\Controllers\VipPaymentController::class, 'notify'])
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class])
->name('vip.payment.notify');
// ═══════════════════════════════════════════════════════════════════
// 邀请链接路由 (严格纯数字)
// 必须放在最后以避免与其他如 /admin 路由冲突
@@ -0,0 +1,189 @@
<?php
/**
* 文件功能:VIP 支付集成功能测试
* 用于验证聊天室发起 VIP 支付下单与接收异步回调开通会员的关键链路
*/
namespace Tests\Feature\Feature;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\VipLevel;
use App\Models\VipPaymentOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class VipPaymentIntegrationTest extends TestCase
{
use RefreshDatabase;
/**
* 测试用户可以发起 VIP 支付并跳转到支付中心页面
*/
public function test_user_can_create_vip_payment_order_and_redirect_to_payment_center(): void
{
$this->seedVipPaymentConfig();
$user = User::factory()->create();
$vipLevel = VipLevel::factory()->create([
'name' => '黄金会员',
'price' => 68,
'duration_days' => 30,
]);
Http::fake([
'https://novalink.test/api/open/v1/pay/orders' => Http::response([
'success' => true,
'message' => '支付单创建成功',
'data' => [
'payment_order_no' => 'PO202604111200001234',
'pay_url' => 'https://novalink.test/payment/checkout/alipay/PO202604111200001234',
],
], 201),
]);
$response = $this->actingAs($user)->post(route('vip.payment.store'), [
'vip_level_id' => $vipLevel->id,
]);
$response->assertRedirect('https://novalink.test/payment/checkout/alipay/PO202604111200001234');
$this->assertDatabaseHas('vip_payment_orders', [
'user_id' => $user->id,
'vip_level_id' => $vipLevel->id,
'payment_order_no' => 'PO202604111200001234',
'status' => 'pending',
]);
}
/**
* 测试异步回调只会为同一笔订单开通一次会员,避免重复叠加时长
*/
public function test_async_notify_opens_vip_only_once(): void
{
$this->seedVipPaymentConfig();
$user = User::factory()->create();
$vipLevel = VipLevel::factory()->create([
'name' => '钻石会员',
'price' => 88,
'duration_days' => 30,
]);
$order = VipPaymentOrder::factory()->create([
'user_id' => $user->id,
'vip_level_id' => $vipLevel->id,
'amount' => 88.00,
'vip_name' => $vipLevel->name,
'vip_duration_days' => $vipLevel->duration_days,
'payment_order_no' => 'PO202604111530001234',
'merchant_order_no' => 'VPO202604111530001234',
]);
$payload = [
'payment_order_no' => $order->payment_order_no,
'merchant_order_no' => $order->merchant_order_no,
'status' => 'paid',
'amount' => '88.00',
'provider' => 'alipay',
'provider_trade_no' => '2026041122001499999999999999',
'paid_at' => '2026-04-11 15:35:12',
];
$rawBody = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$signature = hash_hmac('sha256', $rawBody, 'chatroom-secret');
$firstResponse = $this->call(
'POST',
route('vip.payment.notify'),
[],
[],
[],
[
'CONTENT_TYPE' => 'application/json',
'HTTP_X_PAYMENT_SIGNATURE' => $signature,
],
$rawBody
);
$firstResponse->assertOk();
$user->refresh();
$this->assertSame($vipLevel->id, $user->vip_level_id);
$this->assertNotNull($user->hy_time);
$firstExpireAt = $user->hy_time?->toDateTimeString();
$secondResponse = $this->call(
'POST',
route('vip.payment.notify'),
[],
[],
[],
[
'CONTENT_TYPE' => 'application/json',
'HTTP_X_PAYMENT_SIGNATURE' => $signature,
],
$rawBody
);
$secondResponse->assertOk();
$user->refresh();
$this->assertSame($firstExpireAt, $user->hy_time?->toDateTimeString());
$this->assertDatabaseHas('vip_payment_orders', [
'id' => $order->id,
'status' => 'paid',
]);
}
/**
* 测试会员中心页面只展示当前用户自己的购买记录
*/
public function test_vip_center_only_shows_current_user_records(): void
{
$currentUser = User::factory()->create(['username' => 'current-user']);
$otherUser = User::factory()->create(['username' => 'other-user']);
$vipLevel = VipLevel::factory()->create([
'name' => '星耀会员',
'price' => 66,
'duration_days' => 30,
]);
VipPaymentOrder::factory()->create([
'user_id' => $currentUser->id,
'vip_level_id' => $vipLevel->id,
'vip_name' => $vipLevel->name,
'status' => 'paid',
'order_no' => 'VPO_CURRENT_001',
'merchant_order_no' => 'MER_CURRENT_001',
]);
VipPaymentOrder::factory()->create([
'user_id' => $otherUser->id,
'vip_level_id' => $vipLevel->id,
'vip_name' => $vipLevel->name,
'status' => 'paid',
'order_no' => 'VPO_OTHER_001',
'merchant_order_no' => 'MER_OTHER_001',
]);
$response = $this->actingAs($currentUser)->get(route('vip.center'));
$response->assertOk();
$response->assertSee('VPO_CURRENT_001');
$response->assertDontSee('VPO_OTHER_001');
$response->assertSee('我的购买记录');
}
/**
* 写入测试所需的支付中心配置
*/
private function seedVipPaymentConfig(): void
{
// 这些配置与聊天室后台保存的数据结构保持一致,方便直接复用真实业务代码。
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_enabled'], ['body' => '1']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_base_url'], ['body' => 'https://novalink.test']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_app_key'], ['body' => 'chatroom-app']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_app_secret'], ['body' => 'chatroom-secret']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_timeout'], ['body' => '10']);
}
}