Files
Xboard/app/Services/OrderService.php
xboard 010275b09e feat: introduce WebSocket sync for XBoard nodes
- Implement Workerman-based `xboard:ws-server` for real-time node synchronization.
- Support custom routes, outbounds, and certificate configurations via JSON.
- Optimize scheduled tasks with `lazyById` to minimize memory footprint.
- Enhance reactivity using Observers for `Plan`, `Server`, and `ServerRoute`.
- Expand protocol support for `httpupgrade`, `h2`, and `mieru`.
2026-03-15 09:49:11 +08:00

430 lines
15 KiB
PHP

<?php
namespace App\Services;
use App\Exceptions\ApiException;
use App\Jobs\OrderHandleJob;
use App\Models\Order;
use App\Models\Plan;
use App\Models\TrafficResetLog;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Utils\Helper;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
use App\Services\PlanService;
class OrderService
{
const STR_TO_TIME = [
Plan::PERIOD_MONTHLY => 1,
Plan::PERIOD_QUARTERLY => 3,
Plan::PERIOD_HALF_YEARLY => 6,
Plan::PERIOD_YEARLY => 12,
Plan::PERIOD_TWO_YEARLY => 24,
Plan::PERIOD_THREE_YEARLY => 36
];
public $order;
public $user;
public function __construct(Order $order)
{
$this->order = $order;
}
/**
* Create an order from a request.
*
* @param User $user
* @param Plan $plan
* @param string $period
* @param string|null $couponCode
* @return Order
* @throws ApiException
*/
public static function createFromRequest(
User $user,
Plan $plan,
string $period,
?string $couponCode = null,
): Order {
$userService = app(UserService::class);
$planService = new PlanService($plan);
$planService->validatePurchase($user, $period);
HookManager::call('order.create.before', [$user, $plan, $period, $couponCode]);
return DB::transaction(function () use ($user, $plan, $period, $couponCode, $userService) {
$newPeriod = PlanService::getPeriodKey($period);
$order = new Order([
'user_id' => $user->id,
'plan_id' => $plan->id,
'period' => $newPeriod,
'trade_no' => Helper::generateOrderNo(),
'total_amount' => (int) (optional($plan->prices)[$newPeriod] * 100),
]);
$orderService = new self($order);
if ($couponCode) {
$orderService->applyCoupon($couponCode);
}
$orderService->setVipDiscount($user);
$orderService->setOrderType($user);
$orderService->setInvite(user: $user);
if ($user->balance && $order->total_amount > 0) {
$orderService->handleUserBalance($user, $userService);
}
if (!$order->save()) {
throw new ApiException(__('Failed to create order'));
}
HookManager::call('order.create.after', $order);
// 兼容旧钩子
HookManager::call('order.after_create', $order);
return $order;
});
}
public function open(): void
{
$order = $this->order;
$plan = Plan::find($order->plan_id);
HookManager::call('order.open.before', $order);
DB::transaction(function () use ($order, $plan) {
$this->user = User::lockForUpdate()->find($order->user_id);
if ($order->refund_amount) {
$this->user->balance += $order->refund_amount;
}
if ($order->surplus_order_ids) {
Order::whereIn('id', $order->surplus_order_ids)
->update(['status' => Order::STATUS_DISCOUNTED]);
}
match ((string) $order->period) {
Plan::PERIOD_ONETIME => $this->buyByOneTime($plan),
Plan::PERIOD_RESET_TRAFFIC => app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER),
default => $this->buyByPeriod($order, $plan),
};
$this->setSpeedLimit($plan->speed_limit);
$this->setDeviceLimit($plan->device_limit);
if (!$this->user->save()) {
throw new \RuntimeException('用户信息保存失败');
}
$order->status = Order::STATUS_COMPLETED;
if (!$order->save()) {
throw new \RuntimeException('订单信息保存失败');
}
});
$eventId = match ((int) $order->type) {
Order::STATUS_PROCESSING => admin_setting('new_order_event_id', 0),
Order::TYPE_RENEWAL => admin_setting('renew_order_event_id', 0),
Order::TYPE_UPGRADE => admin_setting('change_order_event_id', 0),
default => 0,
};
if ($eventId) {
$this->openEvent($eventId);
}
HookManager::call('order.open.after', $order);
}
public function setOrderType(User $user)
{
$order = $this->order;
if ($order->period === Plan::PERIOD_RESET_TRAFFIC) {
$order->type = Order::TYPE_RESET_TRAFFIC;
} else if ($user->plan_id !== NULL && $order->plan_id !== $user->plan_id && ($user->expired_at > time() || $user->expired_at === NULL)) {
if (!(int) admin_setting('plan_change_enable', 1))
throw new ApiException('目前不允许更改订阅,请联系客服或提交工单操作');
$order->type = Order::TYPE_UPGRADE;
if ((int) admin_setting('surplus_enable', 1))
$this->getSurplusValue($user, $order);
if ($order->surplus_amount >= $order->total_amount) {
$order->refund_amount = (int) ($order->surplus_amount - $order->total_amount);
$order->total_amount = 0;
} else {
$order->total_amount = (int) ($order->total_amount - $order->surplus_amount);
}
} else if (($user->expired_at === null || $user->expired_at > time()) && $order->plan_id == $user->plan_id) { // 用户订阅未过期或按流量订阅 且购买订阅与当前订阅相同 === 续费
$order->type = Order::TYPE_RENEWAL;
} else { // 新购
$order->type = Order::TYPE_NEW_PURCHASE;
}
}
public function setVipDiscount(User $user)
{
$order = $this->order;
if ($user->discount) {
$order->discount_amount = $order->discount_amount + ($order->total_amount * ($user->discount / 100));
}
$order->total_amount = $order->total_amount - $order->discount_amount;
}
public function setInvite(User $user): void
{
$order = $this->order;
if ($user->invite_user_id && ($order->total_amount <= 0))
return;
$order->invite_user_id = $user->invite_user_id;
$inviter = User::find($user->invite_user_id);
if (!$inviter)
return;
$commissionType = (int) $inviter->commission_type;
if ($commissionType === User::COMMISSION_TYPE_SYSTEM) {
$commissionType = (bool) admin_setting('commission_first_time_enable', true) ? User::COMMISSION_TYPE_ONETIME : User::COMMISSION_TYPE_PERIOD;
}
$isCommission = false;
switch ($commissionType) {
case User::COMMISSION_TYPE_PERIOD:
$isCommission = true;
break;
case User::COMMISSION_TYPE_ONETIME:
$isCommission = !$this->haveValidOrder($user);
break;
}
if (!$isCommission)
return;
if ($inviter->commission_rate) {
$order->commission_balance = $order->total_amount * ($inviter->commission_rate / 100);
} else {
$order->commission_balance = $order->total_amount * (admin_setting('invite_commission', 10) / 100);
}
}
private function haveValidOrder(User $user): Order|null
{
return Order::where('user_id', $user->id)
->whereNotIn('status', [Order::STATUS_PENDING, Order::STATUS_CANCELLED])
->first();
}
private function getSurplusValue(User $user, Order $order)
{
if ($user->expired_at === NULL) {
$lastOneTimeOrder = Order::where('user_id', $user->id)
->where('period', Plan::PERIOD_ONETIME)
->where('status', Order::STATUS_COMPLETED)
->orderBy('id', 'DESC')
->first();
if (!$lastOneTimeOrder)
return;
$nowUserTraffic = Helper::transferToGB($user->transfer_enable);
if (!$nowUserTraffic)
return;
$paidTotalAmount = ($lastOneTimeOrder->total_amount + $lastOneTimeOrder->balance_amount);
if (!$paidTotalAmount)
return;
$trafficUnitPrice = $paidTotalAmount / $nowUserTraffic;
$notUsedTraffic = $nowUserTraffic - Helper::transferToGB($user->u + $user->d);
$result = $trafficUnitPrice * $notUsedTraffic;
$order->surplus_amount = (int) ($result > 0 ? $result : 0);
$order->surplus_order_ids = Order::where('user_id', $user->id)
->where('period', '!=', Plan::PERIOD_RESET_TRAFFIC)
->where('status', Order::STATUS_COMPLETED)
->pluck('id')
->all();
} else {
$orders = Order::query()
->where('user_id', $user->id)
->whereNotIn('period', [Plan::PERIOD_RESET_TRAFFIC, Plan::PERIOD_ONETIME])
->where('status', Order::STATUS_COMPLETED)
->get();
if ($orders->isEmpty()) {
$order->surplus_amount = 0;
$order->surplus_order_ids = [];
return;
}
$orderAmountSum = $orders->sum(fn($item) => $item->total_amount + $item->balance_amount + $item->surplus_amount - $item->refund_amount);
$orderMonthSum = $orders->sum(fn($item) => self::STR_TO_TIME[PlanService::getPeriodKey($item->period)] ?? 0);
$firstOrderAt = $orders->min('created_at');
$expiredAt = Carbon::createFromTimestamp($firstOrderAt)->addMonths($orderMonthSum);
$now = now();
$totalSeconds = $expiredAt->timestamp - $firstOrderAt;
$remainSeconds = max(0, $expiredAt->timestamp - $now->timestamp);
$cycleRatio = $totalSeconds > 0 ? $remainSeconds / $totalSeconds : 0;
$plan = Plan::find($user->plan_id);
$totalTraffic = $plan?->transfer_enable * $orderMonthSum;
$usedTraffic = Helper::transferToGB($user->u + $user->d);
$remainTraffic = max(0, $totalTraffic - $usedTraffic);
$trafficRatio = $totalTraffic > 0 ? $remainTraffic / $totalTraffic : 0;
$ratio = $cycleRatio;
if (admin_setting('change_order_event_id', 0) == 1) {
$ratio = min($cycleRatio, $trafficRatio);
}
$order->surplus_amount = (int) max(0, $orderAmountSum * $ratio);
$order->surplus_order_ids = $orders->pluck('id')->all();
}
}
public function paid(string $callbackNo)
{
$order = $this->order;
if ($order->status !== Order::STATUS_PENDING)
return true;
$order->status = Order::STATUS_PROCESSING;
$order->paid_at = time();
$order->callback_no = $callbackNo;
if (!$order->save())
return false;
try {
OrderHandleJob::dispatchSync($order->trade_no);
} catch (\Exception $e) {
Log::error($e);
return false;
}
return true;
}
public function cancel(): bool
{
$order = $this->order;
HookManager::call('order.cancel.before', $order);
try {
DB::beginTransaction();
$order->status = Order::STATUS_CANCELLED;
if (!$order->save()) {
throw new \Exception('Failed to save order status.');
}
if ($order->balance_amount) {
$userService = new UserService();
if (!$userService->addBalance($order->user_id, $order->balance_amount)) {
throw new \Exception('Failed to add balance.');
}
}
DB::commit();
HookManager::call('order.cancel.after', $order);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error($e);
return false;
}
}
private function setSpeedLimit($speedLimit)
{
$this->user->speed_limit = $speedLimit;
}
private function setDeviceLimit($deviceLimit)
{
$this->user->device_limit = $deviceLimit;
}
private function buyByPeriod(Order $order, Plan $plan)
{
// change plan process
if ((int) $order->type === Order::TYPE_UPGRADE) {
$this->user->expired_at = time();
}
$this->user->transfer_enable = $plan->transfer_enable * 1073741824;
// 从一次性转换到循环或者新购的时候,重置流量
if ($this->user->expired_at === NULL || $order->type === Order::TYPE_NEW_PURCHASE)
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
$this->user->plan_id = $plan->id;
$this->user->group_id = $plan->group_id;
$this->user->expired_at = $this->getTime($order->period, $this->user->expired_at);
}
private function buyByOneTime(Plan $plan)
{
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
$this->user->transfer_enable = $plan->transfer_enable * 1073741824;
$this->user->plan_id = $plan->id;
$this->user->group_id = $plan->group_id;
$this->user->expired_at = NULL;
}
/**
* 计算套餐到期时间
* @param string $periodKey
* @param int $timestamp
* @return int
* @throws ApiException
*/
private function getTime(string $periodKey, ?int $timestamp = null): int
{
$timestamp = $timestamp < time() ? time() : $timestamp;
$periodKey = PlanService::getPeriodKey($periodKey);
if (isset(self::STR_TO_TIME[$periodKey])) {
$months = self::STR_TO_TIME[$periodKey];
return Carbon::createFromTimestamp($timestamp)->addMonths($months)->timestamp;
}
throw new ApiException('无效的套餐周期');
}
private function openEvent($eventId)
{
switch ((int) $eventId) {
case 0:
break;
case 1:
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
break;
}
}
protected function applyCoupon(string $couponCode): void
{
$couponService = new CouponService($couponCode);
if (!$couponService->use($this->order)) {
throw new ApiException(__('Coupon failed'));
}
$this->order->coupon_id = $couponService->getId();
}
/**
* Summary of handleUserBalance
* @param User $user
* @param UserService $userService
* @return void
*/
protected function handleUserBalance(User $user, UserService $userService): void
{
$remainingBalance = $user->balance - $this->order->total_amount;
if ($remainingBalance >= 0) {
if (!$userService->addBalance($this->order->user_id, -$this->order->total_amount)) {
throw new ApiException(__('Insufficient balance'));
}
$this->order->balance_amount = $this->order->total_amount;
$this->order->total_amount = 0;
} else {
if (!$userService->addBalance($this->order->user_id, -$user->balance)) {
throw new ApiException(__('Insufficient balance'));
}
$this->order->balance_amount = $user->balance;
$this->order->total_amount = $this->order->total_amount - $user->balance;
}
}
}