feat: multiple improvements and bug fixes

- Add gift card redemption feature
- Resolve custom range selection issue in overview
- Allow log page size to be modified
- Add subscription path change notification
- Improve dynamic node rate feature
- Support markdown documentation display for plugins
- Reduce power reset service logging
- Fix backend version number not updating after update
This commit is contained in:
xboard
2025-07-14 00:33:04 +08:00
parent a01b94f131
commit a838a43ae5
38 changed files with 3056 additions and 325 deletions
+334
View File
@@ -0,0 +1,334 @@
<?php
namespace App\Services;
use App\Exceptions\ApiException;
use App\Http\Resources\PlanResource;
use App\Models\GiftCardCode;
use App\Models\GiftCardTemplate;
use App\Models\GiftCardUsage;
use App\Models\Plan;
use App\Models\TrafficResetLog;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class GiftCardService
{
protected readonly GiftCardCode $code;
protected readonly GiftCardTemplate $template;
protected ?User $user = null;
public function __construct(string $code)
{
$this->code = GiftCardCode::where('code', $code)->first()
?? throw new ApiException('兑换码不存在');
$this->template = $this->code->template;
}
/**
* 设置使用用户
*/
public function setUser(User $user): self
{
$this->user = $user;
return $this;
}
/**
* 验证兑换码
*/
public function validate(): self
{
$this->validateIsActive();
$eligibility = $this->checkUserEligibility();
if (!$eligibility['can_redeem']) {
throw new ApiException($eligibility['reason']);
}
return $this;
}
/**
* 验证礼品卡本身是否可用 (不检查用户条件)
* @throws ApiException
*/
public function validateIsActive(): self
{
if (!$this->template->isAvailable()) {
throw new ApiException('该礼品卡类型已停用');
}
if (!$this->code->isAvailable()) {
throw new ApiException('兑换码不可用:' . $this->code->status_name);
}
return $this;
}
/**
* 检查用户是否满足兑换条件 (不抛出异常)
*/
public function checkUserEligibility(): array
{
if (!$this->user) {
return [
'can_redeem' => false,
'reason' => '用户信息未提供'
];
}
if (!$this->template->checkUserConditions($this->user)) {
return [
'can_redeem' => false,
'reason' => '您不满足此礼品卡的使用条件'
];
}
if (!$this->template->checkUsageLimit($this->user)) {
return [
'can_redeem' => false,
'reason' => '您已达到此礼品卡的使用限制'
];
}
return ['can_redeem' => true, 'reason' => null];
}
/**
* 使用礼品卡
*/
public function redeem(array $options = []): array
{
if (!$this->user) {
throw new ApiException('未设置使用用户');
}
return DB::transaction(function () use ($options) {
$actualRewards = $this->template->calculateActualRewards($this->user);
if ($this->template->type === GiftCardTemplate::TYPE_MYSTERY) {
$this->code->setActualRewards($actualRewards);
}
$this->giveRewards($actualRewards);
$inviteRewards = null;
if ($this->user->invite_user_id && isset($actualRewards['invite_reward_rate'])) {
$inviteRewards = $this->giveInviteRewards($actualRewards);
}
$this->code->markAsUsed($this->user);
GiftCardUsage::createRecord(
$this->code,
$this->user,
$actualRewards,
array_merge($options, [
'invite_rewards' => $inviteRewards,
'multiplier' => $this->calculateMultiplier(),
])
);
return [
'rewards' => $actualRewards,
'invite_rewards' => $inviteRewards,
'code' => $this->code->code,
'template_name' => $this->template->name,
];
});
}
/**
* 发放奖励
*/
protected function giveRewards(array $rewards): void
{
$userService = app(UserService::class);
if (isset($rewards['balance']) && $rewards['balance'] > 0) {
if (!$userService->addBalance($this->user->id, $rewards['balance'])) {
throw new ApiException('余额发放失败');
}
}
if (isset($rewards['transfer_enable']) && $rewards['transfer_enable'] > 0) {
$this->user->transfer_enable = ($this->user->transfer_enable ?? 0) + $rewards['transfer_enable'];
}
if (isset($rewards['device_limit']) && $rewards['device_limit'] > 0) {
$this->user->device_limit = ($this->user->device_limit ?? 0) + $rewards['device_limit'];
}
if (isset($rewards['reset_package']) && $rewards['reset_package']) {
if ($this->user->plan_id) {
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_GIFT_CARD);
}
}
if (isset($rewards['plan_id'])) {
$plan = Plan::find($rewards['plan_id']);
if ($plan) {
$userService->assignPlan(
$this->user,
$plan,
$rewards['plan_validity_days'] ?? null
);
}
} else {
// 只有在不是套餐卡的情况下,才处理独立的有效期奖励
if (isset($rewards['expire_days']) && $rewards['expire_days'] > 0) {
$userService->extendSubscription($this->user, $rewards['expire_days']);
}
}
// 保存用户更改
if (!$this->user->save()) {
throw new ApiException('用户信息更新失败');
}
}
/**
* 发放邀请人奖励
*/
protected function giveInviteRewards(array $rewards): ?array
{
if (!$this->user->invite_user_id) {
return null;
}
$inviteUser = User::find($this->user->invite_user_id);
if (!$inviteUser) {
return null;
}
$rate = $rewards['invite_reward_rate'] ?? 0.2;
$inviteRewards = [];
$userService = app(UserService::class);
// 邀请人余额奖励
if (isset($rewards['balance']) && $rewards['balance'] > 0) {
$inviteBalance = intval($rewards['balance'] * $rate);
if ($inviteBalance > 0) {
$userService->addBalance($inviteUser->id, $inviteBalance);
$inviteRewards['balance'] = $inviteBalance;
}
}
// 邀请人流量奖励
if (isset($rewards['transfer_enable']) && $rewards['transfer_enable'] > 0) {
$inviteTransfer = intval($rewards['transfer_enable'] * $rate);
if ($inviteTransfer > 0) {
$inviteUser->transfer_enable = ($inviteUser->transfer_enable ?? 0) + $inviteTransfer;
$inviteUser->save();
$inviteRewards['transfer_enable'] = $inviteTransfer;
}
}
return $inviteRewards;
}
/**
* 计算倍率
*/
protected function calculateMultiplier(): float
{
return $this->getFestivalBonus();
}
/**
* 获取节日加成倍率
*/
private function getFestivalBonus(): float
{
$festivalConfig = $this->template->special_config ?? [];
$now = time();
if (
isset($festivalConfig['start_time'], $festivalConfig['end_time']) &&
$now >= $festivalConfig['start_time'] &&
$now <= $festivalConfig['end_time']
) {
return $festivalConfig['festival_bonus'] ?? 1.0;
}
return 1.0;
}
/**
* 获取兑换码信息(不包含敏感信息)
*/
public function getCodeInfo(): array
{
$info = [
'code' => $this->code->code,
'template' => [
'name' => $this->template->name,
'description' => $this->template->description,
'type' => $this->template->type,
'type_name' => $this->template->type_name,
'icon' => $this->template->icon,
'background_image' => $this->template->background_image,
'theme_color' => $this->template->theme_color,
],
'status' => $this->code->status,
'status_name' => $this->code->status_name,
'expires_at' => $this->code->expires_at,
'usage_count' => $this->code->usage_count,
'max_usage' => $this->code->max_usage,
];
if ($this->template->type === GiftCardTemplate::TYPE_PLAN) {
$plan = Plan::find($this->code->template->rewards['plan_id']);
if ($plan) {
$info['plan_info'] = PlanResource::make($plan)->toArray(request());
}
}
return $info;
}
/**
* 预览奖励(不实际发放)
*/
public function previewRewards(): array
{
if (!$this->user) {
throw new ApiException('未设置使用用户');
}
return $this->template->calculateActualRewards($this->user);
}
/**
* 获取兑换码
*/
public function getCode(): GiftCardCode
{
return $this->code;
}
/**
* 获取模板
*/
public function getTemplate(): GiftCardTemplate
{
return $this->template;
}
/**
* 记录日志
*/
protected function logUsage(string $action, array $data = []): void
{
Log::info('礼品卡使用记录', [
'action' => $action,
'code' => $this->code->code,
'template_id' => $this->template->id,
'user_id' => $this->user?->id,
'data' => $data,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
}
+8 -12
View File
@@ -6,6 +6,7 @@ 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;
@@ -37,6 +38,7 @@ class OrderService
* @param Plan $plan
* @param string $period
* @param string|null $couponCode
* @param array|null $telegramMessageIds
* @return Order
* @throws ApiException
*/
@@ -106,7 +108,7 @@ class OrderService
$this->buyByOneTime($plan);
break;
case Plan::PERIOD_RESET_TRAFFIC:
$this->buyByResetTraffic();
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
break;
default:
$this->buyByPeriod($order, $plan);
@@ -321,7 +323,7 @@ class OrderService
return true;
} catch (\Exception $e) {
DB::rollBack();
\Log::error($e);
Log::error($e);
return false;
}
}
@@ -336,12 +338,6 @@ class OrderService
$this->user->device_limit = $deviceLimit;
}
private function buyByResetTraffic()
{
$this->user->u = 0;
$this->user->d = 0;
}
private function buyByPeriod(Order $order, Plan $plan)
{
// change plan process
@@ -351,10 +347,10 @@ class OrderService
$this->user->transfer_enable = $plan->transfer_enable * 1073741824;
// 从一次性转换到循环
if ($this->user->expired_at === NULL)
$this->buyByResetTraffic();
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
// 新购
if ($order->type === Order::TYPE_NEW_PURCHASE)
$this->buyByResetTraffic();
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);
@@ -362,7 +358,7 @@ class OrderService
private function buyByOneTime(Plan $plan)
{
$this->buyByResetTraffic();
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;
@@ -397,7 +393,7 @@ class OrderService
case 0:
break;
case 1:
$this->buyByResetTraffic();
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
break;
}
}
-35
View File
@@ -60,14 +60,6 @@ class TrafficResetService
]);
$this->clearUserCache($user);
Log::info(__('traffic_reset.reset_success'), [
'user_id' => $user->id,
'email' => $user->email,
'old_traffic' => $oldTotal,
'trigger_source' => $triggerSource,
]);
return true;
});
} catch (\Exception $e) {
@@ -283,11 +275,6 @@ class TrafficResetService
$errors = [];
$lastProcessedId = 0;
Log::info('Starting batch traffic reset task.', [
'batch_size' => $batchSize,
'start_time' => now()->toDateTimeString(),
]);
try {
do {
$users = User::where('next_reset_at', '<=', time())
@@ -307,9 +294,7 @@ class TrafficResetService
break;
}
$batchStartTime = microtime(true);
$batchResetCount = 0;
$batchErrors = [];
if ($progressCallback) {
$progressCallback([
@@ -319,13 +304,6 @@ class TrafficResetService
]);
}
Log::info("Processing batch #{$batchNumber}", [
'batch_number' => $batchNumber,
'batch_size' => $users->count(),
'total_processed' => $totalProcessedCount,
'id_range' => $users->first()->id . '-' . $users->last()->id,
]);
foreach ($users as $user) {
try {
if ($this->checkAndReset($user, TrafficResetLog::SOURCE_CRON)) {
@@ -352,17 +330,6 @@ class TrafficResetService
}
}
$batchDuration = round(microtime(true) - $batchStartTime, 2);
Log::info("Batch #{$batchNumber} processing complete", [
'batch_number' => $batchNumber,
'processed_count' => $users->count(),
'reset_count' => $batchResetCount,
'error_count' => count($batchErrors),
'duration' => $batchDuration,
'last_processed_id' => $lastProcessedId,
]);
$batchNumber++;
if ($batchNumber % 10 === 0) {
@@ -407,8 +374,6 @@ class TrafficResetService
'completed_at' => now()->toDateTimeString(),
];
Log::info('Batch traffic reset task completed', $result);
return $result;
}
+43 -1
View File
@@ -7,6 +7,7 @@ use App\Jobs\StatUserJob;
use App\Jobs\TrafficFetchJob;
use App\Models\Order;
use App\Models\Plan;
use App\Models\Server;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Services\TrafficResetService;
@@ -113,8 +114,11 @@ class UserService
return true;
}
public function trafficFetch(array $server, string $protocol, array $data)
public function trafficFetch(Server $server, string $protocol, array $data)
{
$server->rate = $server->getCurrentRate();
$server = $server->toArray();
list($server, $protocol, $data) = HookManager::filter('traffic.before_process', [
$server,
$protocol,
@@ -227,6 +231,44 @@ class UserService
}
}
/**
* 为用户分配一个新套餐或续费现有套餐
*
* @param User $user 用户模型
* @param Plan $plan 套餐模型
* @param int $validityDays 购买天数
* @return User 更新后的用户模型
*/
public function assignPlan(User $user, Plan $plan, int $validityDays): User
{
$user->plan_id = $plan->id;
$user->group_id = $plan->group_id;
$user->transfer_enable = $plan->transfer_enable * 1073741824;
$user->speed_limit = $plan->speed_limit;
if ($validityDays > 0) {
$user = $this->extendSubscription($user, $validityDays);
}
$user->save();
return $user;
}
/**
* 延长用户的订阅有效期
*
* @param User $user 用户模型
* @param int $days 延长天数
* @return User 更新后的用户模型
*/
public function extendSubscription(User $user, int $days): User
{
$currentExpired = $user->expired_at ?? time();
$user->expired_at = max($currentExpired, time()) + ($days * 86400);
return $user;
}
/**
* 设置试用计划
*/