diff --git a/app/Http/Controllers/Admin/VipPaymentConfigController.php b/app/Http/Controllers/Admin/VipPaymentConfigController.php new file mode 100644 index 0000000..c8424d5 --- /dev/null +++ b/app/Http/Controllers/Admin/VipPaymentConfigController.php @@ -0,0 +1,88 @@ +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 + */ + 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' => '调用支付中心超时时间(秒)', + ]; + } +} diff --git a/app/Http/Controllers/Admin/VipPaymentLogController.php b/app/Http/Controllers/Admin/VipPaymentLogController.php new file mode 100644 index 0000000..6b2743d --- /dev/null +++ b/app/Http/Controllers/Admin/VipPaymentLogController.php @@ -0,0 +1,70 @@ +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' => '失败', + ], + ]); + } +} diff --git a/app/Http/Controllers/VipCenterController.php b/app/Http/Controllers/VipCenterController.php new file mode 100644 index 0000000..407ab5e --- /dev/null +++ b/app/Http/Controllers/VipCenterController.php @@ -0,0 +1,72 @@ +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(); + } +} diff --git a/app/Http/Controllers/VipPaymentController.php b/app/Http/Controllers/VipPaymentController.php new file mode 100644 index 0000000..f078469 --- /dev/null +++ b/app/Http/Controllers/VipPaymentController.php @@ -0,0 +1,127 @@ +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); + } + } +} diff --git a/app/Http/Requests/Admin/UpdateVipPaymentConfigRequest.php b/app/Http/Requests/Admin/UpdateVipPaymentConfigRequest.php new file mode 100644 index 0000000..60f8393 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateVipPaymentConfigRequest.php @@ -0,0 +1,56 @@ +> + */ + 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 + */ + 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 秒', + ]; + } +} diff --git a/app/Http/Requests/CreateVipPaymentOrderRequest.php b/app/Http/Requests/CreateVipPaymentOrderRequest.php new file mode 100644 index 0000000..a43c23d --- /dev/null +++ b/app/Http/Requests/CreateVipPaymentOrderRequest.php @@ -0,0 +1,46 @@ +user() !== null; + } + + /** + * 获取字段校验规则 + * + * @return array> + */ + public function rules(): array + { + return [ + 'vip_level_id' => ['required', 'integer', 'exists:vip_levels,id'], + ]; + } + + /** + * 获取中文错误提示 + * + * @return array + */ + public function messages(): array + { + return [ + 'vip_level_id.required' => '请选择要购买的 VIP 等级', + 'vip_level_id.exists' => '所选 VIP 等级不存在或已被删除', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 662e719..31be5a3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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'); + } + // ── 职务相关关联 ────────────────────────────────────────────────────── /** diff --git a/app/Models/VipLevel.php b/app/Models/VipLevel.php index c9e9bf9..f4a9181 100644 --- a/app/Models/VipLevel.php +++ b/app/Models/VipLevel.php @@ -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'; diff --git a/app/Models/VipPaymentOrder.php b/app/Models/VipPaymentOrder.php new file mode 100644 index 0000000..93e859e --- /dev/null +++ b/app/Models/VipPaymentOrder.php @@ -0,0 +1,92 @@ + 可批量赋值字段 */ + 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 + */ + 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; + } +} diff --git a/app/Services/VipPaymentCenterClient.php b/app/Services/VipPaymentCenterClient.php new file mode 100644 index 0000000..2b811d8 --- /dev/null +++ b/app/Services/VipPaymentCenterClient.php @@ -0,0 +1,207 @@ +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 + */ + 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 + */ + 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 $payload 请求体 + * @return array + */ + 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 + */ + 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 + */ + 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), '/'); + } +} diff --git a/app/Services/VipPaymentService.php b/app/Services/VipPaymentService.php new file mode 100644 index 0000000..6c80497 --- /dev/null +++ b/app/Services/VipPaymentService.php @@ -0,0 +1,209 @@ +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 + */ + 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 $payload 同步回调参数 + */ + public function recordSyncReturn(VipPaymentOrder $vipPaymentOrder, array $payload): void + { + $vipPaymentOrder->update([ + 'sync_return_payload' => $payload, + ]); + } + + /** + * 根据异步通知将订单标记为已支付,并完成会员开通 + * + * @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单 + * @param array $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); + } +} diff --git a/database/factories/VipLevelFactory.php b/database/factories/VipLevelFactory.php new file mode 100644 index 0000000..39f8d85 --- /dev/null +++ b/database/factories/VipLevelFactory.php @@ -0,0 +1,37 @@ + + */ +class VipLevelFactory extends Factory +{ + /** + * 定义默认测试数据 + * + * @return array + */ + 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, + ]; + } +} diff --git a/database/factories/VipPaymentOrderFactory.php b/database/factories/VipPaymentOrderFactory.php new file mode 100644 index 0000000..7b88426 --- /dev/null +++ b/database/factories/VipPaymentOrderFactory.php @@ -0,0 +1,46 @@ + + */ +class VipPaymentOrderFactory extends Factory +{ + /** + * 定义默认测试数据 + * + * @return array + */ + 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()], + ]; + } +} diff --git a/database/migrations/2026_04_11_111658_create_vip_payment_orders_table.php b/database/migrations/2026_04_11_111658_create_vip_payment_orders_table.php new file mode 100644 index 0000000..41ead69 --- /dev/null +++ b/database/migrations/2026_04_11_111658_create_vip_payment_orders_table.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index 9985aca..bf9a3bd 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -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 会员等级' !!} + + {!! '🧾 会员购买日志' !!} + {!! '🛒 商店管理' !!} @@ -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 配置 + + 💳 VIP 支付配置 + 🤖 AI 厂商配置 diff --git a/resources/views/admin/vip-payment-logs/index.blade.php b/resources/views/admin/vip-payment-logs/index.blade.php new file mode 100644 index 0000000..a2bbe0e --- /dev/null +++ b/resources/views/admin/vip-payment-logs/index.blade.php @@ -0,0 +1,116 @@ +{{-- + 文件功能:后台会员购买日志页面 + 供后台管理员查询全站 VIP 购买订单,查看支付状态与会员开通情况 +--}} +@extends('admin.layouts.app') + +@section('title', '会员购买日志') + +@section('content') + + +
+
+ + + + + + + + + + + + + + + + + @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 + + + + + + + + + + + + + @empty + + + + @endforelse + +
订单ID用户会员等级本地订单号平台支付单号支付金额状态支付时间开通时间创建时间
#{{ $log->id }} +
{{ $log->user?->username ?? '未知用户' }}
+
+ {{ $log->vipLevel?->icon ?: '👑' }} + {{ $log->vip_name }} + {{ $log->order_no }}{{ $log->payment_order_no ?: '-' }}¥{{ number_format((float) $log->amount, 2) }}{{ $statusOptions[$log->status] ?? $log->status }}{{ $log->paid_at?->format('Y-m-d H:i:s') ?? '未支付' }}{{ $log->opened_vip_at?->format('Y-m-d H:i:s') ?? '未开通' }}{{ $log->created_at->format('Y-m-d H:i:s') }}
📭 暂无会员购买记录
+
+ + @if ($logs->hasPages()) +
{{ $logs->links() }}
+ @endif +
+@endsection diff --git a/resources/views/admin/vip-payment/config.blade.php b/resources/views/admin/vip-payment/config.blade.php new file mode 100644 index 0000000..71958b7 --- /dev/null +++ b/resources/views/admin/vip-payment/config.blade.php @@ -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 + +
+
+
+

VIP 在线支付配置

+

当前仅对接 NovaLink 支付中心,用于聊天室 VIP 会员购买。

+
+
+ +
+
+

接入说明

+

请先在 NovaLink 支付系统中创建“接入应用”,再把该应用的地址、App Key 和 App Secret 填入这里。

+
+ +
+ @csrf + @method('PUT') + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

该密钥仅用于服务器与支付中心之间的签名通信,请勿提供给前端。

+
+ +
+
+

系统回调说明

+

请把下面两个地址分别填写到 NovaLink 支付系统“编辑应用”的同步返回地址与异步通知地址中。

+
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+@endsection diff --git a/resources/views/chat/partials/layout/mobile-drawer.blade.php b/resources/views/chat/partials/layout/mobile-drawer.blade.php index 55c5b05..778ea74 100644 --- a/resources/views/chat/partials/layout/mobile-drawer.blade.php +++ b/resources/views/chat/partials/layout/mobile-drawer.blade.php @@ -32,6 +32,7 @@
🛒
商店
+
👑
会员
💾
存点
🎮
娱乐
🏦
银行
diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index 1afbd1f..c7f508c 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -16,6 +16,7 @@ {{-- ═══════════ 竖向工具条按钮 ═══════════ --}}
商店
+
会员
存点
娱乐
赚钱
diff --git a/resources/views/rooms/guide.blade.php b/resources/views/rooms/guide.blade.php index 6ce1810..bdb7c57 100644 --- a/resources/views/rooms/guide.blade.php +++ b/resources/views/rooms/guide.blade.php @@ -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 @@ 经验倍率 金币倍率 时长 - 价格 + 价格 + 操作 @@ -391,14 +395,39 @@ ×{{ $vip->exp_multiplier }} ×{{ $vip->jjb_multiplier }} - {{ $vip->duration_days }} 天 + {{ $vip->duration_days > 0 ? $vip->duration_days . ' 天' : '永久' }} {{ $vip->price > 0 ? $vip->price . ' 元' : '免费' }} + + @php + $isCurrentVipLevel = auth()->user()?->isVip() && (int) auth()->user()?->vip_level_id === (int) $vip->id; + @endphp + + @if ($vip->price > 0 && $vipPaymentEnabled) +
+ @csrf + + +
+ @elseif ($vip->price > 0) + 暂未开启 + @else + 联系管理员 + @endif + @endforeach
+ +
+ VIP 会员支付由平台支付中心提供,最终开通结果以异步回调为准。 +
@endif diff --git a/resources/views/vip/center.blade.php b/resources/views/vip/center.blade.php new file mode 100644 index 0000000..78093bf --- /dev/null +++ b/resources/views/vip/center.blade.php @@ -0,0 +1,201 @@ +{{-- + 文件功能:前台会员中心页面 + 展示当前用户会员状态、会员等级权益、购买入口以及用户自己的会员购买记录 +--}} +@extends('layouts.app') + +@section('title', '会员中心 - 飘落流星') +@section('nav-icon', '👑') +@section('nav-title', '会员中心') + +@section('content') +
+
+
+
+
+

我的会员状态

+

+ @if ($user->isVip()) + {{ $user->vipName() ?: '尊贵会员' }} + @else + 普通用户 + @endif +

+

+ @if ($user->isVip()) + 当前已开通会员权益, + @if ($user->hy_time) + 到期时间:{{ $user->hy_time->format('Y-m-d H:i') }} + @else + 当前为永久会员 + @endif + @else + 你当前还未开通会员,开通后可享受经验加成、金币加成和专属身份展示。 + @endif +

+
+
+
当前徽章
+
{{ $user->vipIcon() ?: '⭐' }}
+
+
+ +
+
+
经验倍率
+
×{{ $user->vipLevel?->exp_multiplier ?? 1 }}
+
+
+
金币倍率
+
×{{ $user->vipLevel?->jjb_multiplier ?? 1 }}
+
+
+
累计已支付
+
¥{{ number_format($totalAmount, 2) }}
+
成功订单 {{ $paidOrders }} 笔
+
+
+
+ +
+

会员权益总览

+
    +
  • 挂机经验按会员倍率加成,升级更快。
  • +
  • 💰金币收益按会员倍率加成,参与玩法更轻松。
  • +
  • 👑昵称旁展示专属会员图标与会员名称。
  • +
  • 可使用会员专属进场欢迎语与离场提示语。
  • +
+
+ 会员支付由平台支付中心统一处理,最终是否开通以异步回调结果为准。 +
+
+
+ +
+
+
+

会员等级列表

+

不同档位提供不同倍率与时长,你可以按需要购买或续费。

+
+ @if (! $vipPaymentEnabled) + 支付暂未开启 + @endif +
+ +
+ @foreach ($vipLevels as $vip) + @php + $isCurrentVipLevel = $user->isVip() && (int) $user->vip_level_id === (int) $vip->id; + @endphp +
+
+
+
{{ $vip->icon }}
+
+

{{ $vip->name }}

+

当前已有 {{ $vip->users_count }} 位用户使用

+
+
+ @if ($isCurrentVipLevel) + 当前等级 + @endif +
+ +
+
+
经验倍率
+
×{{ $vip->exp_multiplier }}
+
+
+
金币倍率
+
×{{ $vip->jjb_multiplier }}
+
+
+ +
+
有效时长{{ $vip->duration_days > 0 ? $vip->duration_days . ' 天' : '永久' }}
+
支付金额{{ $vip->price > 0 ? '¥' . $vip->price : '联系管理员' }}
+
+ +
+ @if ($vip->price > 0 && $vipPaymentEnabled) +
+ @csrf + + +
+ @elseif ($vip->price > 0) +
支付暂未开启
+ @else +
请联系管理员开通
+ @endif +
+
+ @endforeach +
+
+ +
+
+
+

我的购买记录

+

这里只显示你自己的会员订单记录,方便查看支付和开通状态。

+
+
+ +
+ + + + + + + + + + + + + @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 + + + + + + + + + @empty + + + + @endforelse + +
本地订单号会员等级金额状态支付时间开通时间
{{ $log->order_no }} +
+ {{ $log->vipLevel?->icon ?: '👑' }} + {{ $log->vip_name }} +
+
¥{{ number_format((float) $log->amount, 2) }}{{ $status['text'] }}{{ $log->paid_at?->format('Y-m-d H:i') ?? '未支付' }}{{ $log->opened_vip_at?->format('Y-m-d H:i') ?? '未开通' }}
暂无会员购买记录,开通后会显示在这里。
+
+ + @if ($paymentLogs->hasPages()) +
{{ $paymentLogs->links() }}
+ @endif +
+
+@endsection diff --git a/routes/web.php b/routes/web.php index b0a7fbd..6dce85a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 路由冲突 diff --git a/tests/Feature/Feature/VipPaymentIntegrationTest.php b/tests/Feature/Feature/VipPaymentIntegrationTest.php new file mode 100644 index 0000000..102c922 --- /dev/null +++ b/tests/Feature/Feature/VipPaymentIntegrationTest.php @@ -0,0 +1,189 @@ +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']); + } +}