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

View File

@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Models\User;
use App\Services\TrafficResetService;
use App\Utils\Helper;
use Carbon\Carbon;
@@ -54,8 +55,6 @@ class ResetTraffic extends Command
// 设置最大执行时间
set_time_limit($maxTime);
$startTime = microtime(true);
try {
if ($dryRun) {
$result = $this->performDryRun($batchSize);
@@ -129,7 +128,7 @@ class ResetTraffic extends Command
{
$this->info("🔍 扫描需要重置的用户...");
$totalUsers = \App\Models\User::where('next_reset_at', '<=', time())
$totalUsers = User::where('next_reset_at', '<=', time())
->whereNotNull('next_reset_at')
->where(function ($query) {
$query->where('expired_at', '>', time())
@@ -160,7 +159,7 @@ class ResetTraffic extends Command
// 显示前几个用户的详情作为示例
if ($this->option('verbose') || $totalUsers <= 20) {
$sampleUsers = \App\Models\User::where('next_reset_at', '<=', time())
$sampleUsers = User::where('next_reset_at', '<=', time())
->whereNotNull('next_reset_at')
->where(function ($query) {
$query->where('expired_at', '>', time())

View File

@@ -2,7 +2,9 @@
namespace App\Console\Commands;
use App\Services\UpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
class XboardUpdate extends Command
@@ -39,9 +41,11 @@ class XboardUpdate extends Command
public function handle()
{
$this->info('正在导入数据库请稍等...');
\Artisan::call("migrate");
$this->info(\Artisan::output());
\Artisan::call('horizon:terminate');
Artisan::call("migrate");
$this->info(Artisan::output());
Artisan::call('horizon:terminate');
$updateService = new UpdateService();
$updateService->updateVersionCache();
$this->info('更新完毕,队列服务已重启,你无需进行任何操作。');
}
}

View File

@@ -16,7 +16,7 @@ trait ApiResponse
* @param array $codeResponse
* @return JsonResponse
*/
public function success($data = null, $codeResponse=ResponseEnum::HTTP_OK): JsonResponse
public function success($data = null, $codeResponse = ResponseEnum::HTTP_OK): JsonResponse
{
return $this->jsonResponse('success', $codeResponse, $data, null);
}
@@ -28,7 +28,7 @@ trait ApiResponse
* @param mixed $error
* @return JsonResponse
*/
public function fail($codeResponse=ResponseEnum::HTTP_ERROR, $data = null, $error=null): JsonResponse
public function fail($codeResponse = ResponseEnum::HTTP_ERROR, $data = null, $error = null): JsonResponse
{
return $this->jsonResponse('fail', $codeResponse, $data, $error);
}
@@ -46,49 +46,24 @@ trait ApiResponse
list($code, $message) = $codeResponse;
return response()
->json([
'status' => $status,
// 'code' => $code,
'message' => $message,
'data' => $data ?? null,
'error' => $error,
],(int)substr(((string) $code),0,3));
'status' => $status,
// 'code' => $code,
'message' => $message,
'data' => $data ?? null,
'error' => $error,
], (int) substr(((string) $code), 0, 3));
}
/**
* 成功分页返回
* @param $page
* @return JsonResponse
*/
protected function successPaginate($page): JsonResponse
{
return $this->success($this->paginate($page));
}
private function paginate($page)
public function paginate(LengthAwarePaginator $page)
{
if ($page instanceof LengthAwarePaginator){
return [
'total' => $page->total(),
'page' => $page->currentPage(),
'limit' => $page->perPage(),
'pages' => $page->lastPage(),
'list' => $page->items()
];
}
if ($page instanceof Collection){
$page = $page->toArray();
}
if (!is_array($page) && !is_object($page)){
return $page;
}
$total = count($page);
return [
'total' => $total, //数据总数
'page' => 1, // 当前页码
'limit' => $total, // 每页的数据条数
'pages' => 1, // 最后一页的页码
'list' => $page // 数据
];
return response()->json([
'total' => $page->total(),
'current_page' => $page->currentPage(),
'per_page' => $page->perPage(),
'last_page' => $page->lastPage(),
'data' => $page->items()
]);
}
/**
@@ -97,7 +72,7 @@ trait ApiResponse
* @param string $info
* @throws BusinessException
*/
public function throwBusinessException(array $codeResponse=ResponseEnum::HTTP_ERROR, string $info = '')
public function throwBusinessException(array $codeResponse = ResponseEnum::HTTP_ERROR, string $info = '')
{
throw new BusinessException($codeResponse, $info);
}

View File

@@ -55,7 +55,7 @@ class ShadowsocksTidalabController extends Controller
foreach ($data as $item) {
$formatData[$item['user_id']] = [$item['u'], $item['d']];
}
$userService->trafficFetch($server->toArray(), 'shadowsocks', $formatData);
$userService->trafficFetch($server, 'shadowsocks', $formatData);
return response([
'ret' => 1,

View File

@@ -62,7 +62,7 @@ class TrojanTidalabController extends Controller
foreach ($data as $item) {
$formatData[$item['user_id']] = [$item['u'], $item['d']];
}
$userService->trafficFetch($server->toArray(), 'trojan', $formatData);
$userService->trafficFetch($server, 'trojan', $formatData);
return response([
'ret' => 1,

View File

@@ -79,7 +79,7 @@ class UniProxyController extends Controller
);
$userService = new UserService();
$userService->trafficFetch($node->toArray(), $nodeType, $data);
$userService->trafficFetch($node, $nodeType, $data);
return $this->success(true);
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\GiftCardCheckRequest;
use App\Http\Requests\User\GiftCardRedeemRequest;
use App\Models\GiftCardUsage;
use App\Services\GiftCardService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class GiftCardController extends Controller
{
/**
* 查询兑换码信息
*/
public function check(GiftCardCheckRequest $request)
{
try {
$giftCardService = new GiftCardService($request->input('code'));
$giftCardService->setUser($request->user());
// 1. 验证礼品卡本身是否有效 (如不存在、已过期、已禁用)
$giftCardService->validateIsActive();
// 2. 检查用户是否满足使用条件,但不在此处抛出异常
$eligibility = $giftCardService->checkUserEligibility();
// 3. 获取卡片信息和奖励预览
$codeInfo = $giftCardService->getCodeInfo();
$rewardPreview = $giftCardService->previewRewards();
return $this->success([
'code_info' => $codeInfo, // 这里面已经包含 plan_info
'reward_preview' => $rewardPreview,
'can_redeem' => $eligibility['can_redeem'],
'reason' => $eligibility['reason'],
]);
} catch (ApiException $e) {
// 这里只捕获 validateIsActive 抛出的异常
return $this->fail([400, $e->getMessage()]);
} catch (\Exception $e) {
Log::error('礼品卡查询失败', [
'code' => $request->input('code'),
'user_id' => $request->user()->id,
'error' => $e->getMessage(),
]);
return $this->fail([500, '查询失败,请稍后重试']);
}
}
/**
* 使用兑换码
*/
public function redeem(GiftCardRedeemRequest $request)
{
try {
$giftCardService = new GiftCardService($request->input('code'));
$giftCardService->setUser($request->user());
$giftCardService->validate();
// 使用礼品卡
$result = $giftCardService->redeem([
// 'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
Log::info('礼品卡使用成功', [
'code' => $request->input('code'),
'user_id' => $request->user()->id,
'rewards' => $result['rewards'],
]);
return $this->success([
'message' => '兑换成功!',
'rewards' => $result['rewards'],
'invite_rewards' => $result['invite_rewards'],
'template_name' => $result['template_name'],
]);
} catch (ApiException $e) {
return $this->fail([400, $e->getMessage()]);
} catch (\Exception $e) {
Log::error('礼品卡使用失败', [
'code' => $request->input('code'),
'user_id' => $request->user()->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $this->fail([500, '兑换失败,请稍后重试']);
}
}
/**
* 获取用户兑换记录
*/
public function history(Request $request)
{
$request->validate([
'page' => 'integer|min:1',
'per_page' => 'integer|min:1|max:100',
]);
$perPage = $request->input('per_page', 15);
$usages = GiftCardUsage::with(['template', 'code'])
->where('user_id', $request->user()->id)
->orderBy('created_at', 'desc')
->paginate($perPage);
$data = $usages->getCollection()->map(function ($usage) {
return [
'id' => $usage->id,
'code' => substr($usage->code->code, 0, 8) . '****', // 脱敏处理
'template_name' => $usage->template->name,
'template_type' => $usage->template->type,
'template_type_name' => $usage->template->type_name,
'rewards_given' => $usage->rewards_given,
'invite_rewards' => $usage->invite_rewards,
'multiplier_applied' => $usage->multiplier_applied,
'created_at' => $usage->created_at,
];
});
$usages->setCollection($data);
return $this->paginate($usages);
}
/**
* 获取兑换记录详情
*/
public function detail(Request $request)
{
$request->validate([
'id' => 'required|integer|exists:v2_gift_card_usage,id',
]);
$usage = GiftCardUsage::with(['template', 'code', 'inviteUser'])
->where('user_id', $request->user()->id)
->where('id', $request->input('id'))
->first();
if (!$usage) {
return $this->fail([404, '记录不存在']);
}
return $this->success([
'id' => $usage->id,
'code' => $usage->code->code,
'template' => [
'name' => $usage->template->name,
'description' => $usage->template->description,
'type' => $usage->template->type,
'type_name' => $usage->template->type_name,
'icon' => $usage->template->icon,
'theme_color' => $usage->template->theme_color,
],
'rewards_given' => $usage->rewards_given,
'invite_rewards' => $usage->invite_rewards,
'invite_user' => $usage->inviteUser ? [
'id' => $usage->inviteUser->id,
'email' => substr($usage->inviteUser->email, 0, 3) . '***@***',
] : null,
'user_level_at_use' => $usage->user_level_at_use,
'plan_id_at_use' => $usage->plan_id_at_use,
'multiplier_applied' => $usage->multiplier_applied,
// 'ip_address' => $usage->ip_address,
'notes' => $usage->notes,
'created_at' => $usage->created_at,
]);
}
/**
* 获取可用的礼品卡类型
*/
public function types(Request $request)
{
return $this->success([
'types' => \App\Models\GiftCardTemplate::getTypeMap(),
]);
}
}

View File

@@ -14,7 +14,9 @@ use App\Services\MailService;
use App\Services\TelegramService;
use App\Services\ThemeService;
use App\Utils\Dict;
use Illuminate\Console\Command;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
class ConfigController extends Controller

View File

@@ -46,10 +46,7 @@ class CouponController extends Controller
$coupons = $builder
->orderBy('created_at', 'desc')
->paginate($pageSize, ["*"], 'page', $current);
return response([
'data' => $coupons->items(),
'total' => $coupons->total()
]);
return $this->paginate($coupons);
}
public function update(Request $request)

View File

@@ -0,0 +1,641 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\GiftCardCode;
use App\Models\GiftCardTemplate;
use App\Models\GiftCardUsage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
class GiftCardController extends Controller
{
/**
* 获取礼品卡模板列表
*/
public function templates(Request $request)
{
$request->validate([
'type' => 'integer|min:1|max:10',
'status' => 'integer|in:0,1',
'page' => 'integer|min:1',
'per_page' => 'integer|min:1|max:1000',
]);
$query = GiftCardTemplate::query();
if ($request->has('type')) {
$query->where('type', $request->input('type'));
}
if ($request->has('status')) {
$query->where('status', $request->input('status'));
}
$perPage = $request->input('per_page', 15);
$templates = $query->orderBy('sort', 'asc')
->orderBy('created_at', 'desc')
->paginate($perPage);
$data = $templates->getCollection()->map(function ($template) {
return [
'id' => $template->id,
'name' => $template->name,
'description' => $template->description,
'type' => $template->type,
'type_name' => $template->type_name,
'status' => $template->status,
'conditions' => $template->conditions,
'rewards' => $template->rewards,
'limits' => $template->limits,
'special_config' => $template->special_config,
'icon' => $template->icon,
'background_image' => $template->background_image,
'theme_color' => $template->theme_color,
'sort' => $template->sort,
'admin_id' => $template->admin_id,
'created_at' => $template->created_at,
'updated_at' => $template->updated_at,
// 统计信息
'codes_count' => $template->codes()->count(),
'used_count' => $template->usages()->count(),
];
});
$templates->setCollection($data);
return $this->paginate($templates);
}
/**
* 创建礼品卡模板
*/
public function createTemplate(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => [
'required',
'integer',
Rule::in(array_keys(GiftCardTemplate::getTypeMap()))
],
'status' => 'boolean',
'conditions' => 'nullable|array',
'rewards' => 'required|array',
'limits' => 'nullable|array',
'special_config' => 'nullable|array',
'icon' => 'nullable|string|max:255',
'background_image' => 'nullable|string|url|max:255',
'theme_color' => 'nullable|string|regex:/^#[0-9A-Fa-f]{6}$/',
'sort' => 'integer|min:0',
], [
'name.required' => '礼品卡名称不能为空',
'type.required' => '礼品卡类型不能为空',
'type.in' => '无效的礼品卡类型',
'rewards.required' => '奖励配置不能为空',
'theme_color.regex' => '主题色格式不正确',
'background_image.url' => '背景图片必须是有效的URL',
]);
try {
$template = GiftCardTemplate::create([
'name' => $request->input('name'),
'description' => $request->input('description'),
'type' => $request->input('type'),
'status' => $request->input('status', true),
'conditions' => $request->input('conditions'),
'rewards' => $request->input('rewards'),
'limits' => $request->input('limits'),
'special_config' => $request->input('special_config'),
'icon' => $request->input('icon'),
'background_image' => $request->input('background_image'),
'theme_color' => $request->input('theme_color', '#1890ff'),
'sort' => $request->input('sort', 0),
'admin_id' => $request->user()->id,
'created_at' => time(),
'updated_at' => time(),
]);
return $this->success($template);
} catch (\Exception $e) {
Log::error('创建礼品卡模板失败', [
'admin_id' => $request->user()->id,
'data' => $request->all(),
'error' => $e->getMessage(),
]);
return $this->fail([500, '创建失败']);
}
}
/**
* 更新礼品卡模板
*/
public function updateTemplate(Request $request)
{
$validatedData = $request->validate([
'id' => 'required|integer|exists:v2_gift_card_template,id',
'name' => 'sometimes|required|string|max:255',
'description' => 'sometimes|nullable|string',
'type' => [
'sometimes',
'required',
'integer',
Rule::in(array_keys(GiftCardTemplate::getTypeMap()))
],
'status' => 'sometimes|boolean',
'conditions' => 'sometimes|nullable|array',
'rewards' => 'sometimes|required|array',
'limits' => 'sometimes|nullable|array',
'special_config' => 'sometimes|nullable|array',
'icon' => 'sometimes|nullable|string|max:255',
'background_image' => 'sometimes|nullable|string|url|max:255',
'theme_color' => 'sometimes|nullable|string|regex:/^#[0-9A-Fa-f]{6}$/',
'sort' => 'sometimes|integer|min:0',
]);
$template = GiftCardTemplate::find($validatedData['id']);
if (!$template) {
return $this->fail([404, '模板不存在']);
}
try {
$updateData = collect($validatedData)->except('id')->all();
if (empty($updateData)) {
return $this->success($template);
}
$updateData['updated_at'] = time();
$template->update($updateData);
return $this->success($template->fresh());
} catch (\Exception $e) {
Log::error('更新礼品卡模板失败', [
'admin_id' => $request->user()->id,
'template_id' => $template->id,
'error' => $e->getMessage(),
]);
return $this->fail([500, '更新失败']);
}
}
/**
* 删除礼品卡模板
*/
public function deleteTemplate(Request $request)
{
$request->validate([
'id' => 'required|integer|exists:v2_gift_card_template,id',
]);
$template = GiftCardTemplate::find($request->input('id'));
if (!$template) {
return $this->fail([404, '模板不存在']);
}
// 检查是否有关联的兑换码
if ($template->codes()->exists()) {
return $this->fail([400, '该模板下存在兑换码,无法删除']);
}
try {
$template->delete();
return $this->success(true);
} catch (\Exception $e) {
Log::error('删除礼品卡模板失败', [
'admin_id' => $request->user()->id,
'template_id' => $template->id,
'error' => $e->getMessage(),
]);
return $this->fail([500, '删除失败']);
}
}
/**
* 生成兑换码
*/
public function generateCodes(Request $request)
{
$request->validate([
'template_id' => 'required|integer|exists:v2_gift_card_template,id',
'count' => 'required|integer|min:1|max:10000',
'prefix' => 'nullable|string|max:10|regex:/^[A-Z0-9]*$/',
'expires_hours' => 'nullable|integer|min:1',
'max_usage' => 'integer|min:1|max:1000',
], [
'template_id.required' => '请选择礼品卡模板',
'count.required' => '请指定生成数量',
'count.max' => '单次最多生成10000个兑换码',
'prefix.regex' => '前缀只能包含大写字母和数字',
]);
$template = GiftCardTemplate::find($request->input('template_id'));
if (!$template->isAvailable()) {
return $this->fail([400, '模板已被禁用']);
}
try {
$options = [
'prefix' => $request->input('prefix', 'GC'),
'max_usage' => $request->input('max_usage', 1),
];
if ($request->has('expires_hours')) {
$options['expires_at'] = time() + ($request->input('expires_hours') * 3600);
}
$batchId = GiftCardCode::batchGenerate(
$request->input('template_id'),
$request->input('count'),
$options
);
// 查询本次生成的所有兑换码
$codes = GiftCardCode::where('batch_id', $batchId)->get();
// 判断是否导出 CSV
if ($request->input('download_csv')) {
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="gift_codes.csv"',
];
$callback = function () use ($codes, $template) {
$handle = fopen('php://output', 'w');
// 表头
fputcsv($handle, [
'兑换码',
'前缀',
'有效期',
'最大使用次数',
'批次号',
'创建时间',
'模板名称',
'模板类型',
'模板奖励',
'状态',
'使用者',
'使用时间',
'备注'
]);
foreach ($codes as $code) {
$expireDate = $code->expires_at ? date('Y-m-d H:i:s', $code->expires_at) : '长期有效';
$createDate = date('Y-m-d H:i:s', $code->created_at);
$templateName = $template->name ?? '';
$templateType = $template->type ?? '';
$templateRewards = $template->rewards ? json_encode($template->rewards, JSON_UNESCAPED_UNICODE) : '';
// 状态判断
if ($code->disabled) {
$status = '已禁用';
} elseif ($code->used_at) {
$status = '已使用';
} elseif ($code->expires_at && $code->expires_at < time()) {
$status = '已过期';
} else {
$status = '未使用';
}
$usedBy = $code->user_id ?? '';
$usedAt = $code->used_at ? date('Y-m-d H:i:s', $code->used_at) : '';
$remark = $code->remark ?? '';
fputcsv($handle, [
$code->code,
$code->prefix ?? '',
$expireDate,
$code->max_usage,
$code->batch_id,
$createDate,
$templateName,
$templateType,
$templateRewards,
$status,
$usedBy,
$usedAt,
$remark,
]);
}
fclose($handle);
};
return response()->streamDownload($callback, 'gift_codes.csv', $headers);
}
Log::info('批量生成兑换码', [
'admin_id' => $request->user()->id,
'template_id' => $request->input('template_id'),
'count' => $request->input('count'),
'batch_id' => $batchId,
]);
return $this->success([
'batch_id' => $batchId,
'count' => $request->input('count'),
'message' => '生成成功',
]);
} catch (\Exception $e) {
Log::error('生成兑换码失败', [
'admin_id' => $request->user()->id,
'data' => $request->all(),
'error' => $e->getMessage(),
]);
return $this->fail([500, '生成失败']);
}
}
/**
* 获取兑换码列表
*/
public function codes(Request $request)
{
$request->validate([
'template_id' => 'integer|exists:v2_gift_card_template,id',
'batch_id' => 'string',
'status' => 'integer|in:0,1,2,3',
'page' => 'integer|min:1',
'per_page' => 'integer|min:1|max:100',
]);
$query = GiftCardCode::with(['template', 'user']);
if ($request->has('template_id')) {
$query->where('template_id', $request->input('template_id'));
}
if ($request->has('batch_id')) {
$query->where('batch_id', $request->input('batch_id'));
}
if ($request->has('status')) {
$query->where('status', $request->input('status'));
}
$perPage = $request->input('per_page', 15);
$codes = $query->orderBy('created_at', 'desc')->paginate($perPage);
$data = $codes->getCollection()->map(function ($code) {
return [
'id' => $code->id,
'template_id' => $code->template_id,
'template_name' => $code->template->name,
'code' => $code->code,
'batch_id' => $code->batch_id,
'status' => $code->status,
'status_name' => $code->status_name,
'user_id' => $code->user_id,
'user_email' => $code->user ? substr($code->user->email, 0, 3) . '***@***' : null,
'used_at' => $code->used_at,
'expires_at' => $code->expires_at,
'usage_count' => $code->usage_count,
'max_usage' => $code->max_usage,
'created_at' => $code->created_at,
];
});
$codes->setCollection($data);
return $this->paginate($codes);
}
/**
* 禁用/启用兑换码
*/
public function toggleCode(Request $request)
{
$request->validate([
'id' => 'required|integer|exists:v2_gift_card_code,id',
'action' => 'required|string|in:disable,enable',
]);
$code = GiftCardCode::find($request->input('id'));
if (!$code) {
return $this->fail([404, '兑换码不存在']);
}
try {
if ($request->input('action') === 'disable') {
$code->markAsDisabled();
} else {
if ($code->status === GiftCardCode::STATUS_DISABLED) {
$code->status = GiftCardCode::STATUS_UNUSED;
$code->save();
}
}
return $this->success([
'message' => $request->input('action') === 'disable' ? '已禁用' : '已启用',
]);
} catch (\Exception $e) {
return $this->fail([500, '操作失败']);
}
}
/**
* 导出兑换码
*/
public function exportCodes(Request $request)
{
$request->validate([
'batch_id' => 'required|string|exists:v2_gift_card_code,batch_id',
]);
$codes = GiftCardCode::where('batch_id', $request->input('batch_id'))
->orderBy('created_at', 'asc')
->get(['code']);
$content = $codes->pluck('code')->implode("\n");
return response($content)
->header('Content-Type', 'text/plain')
->header('Content-Disposition', 'attachment; filename="gift_cards_' . $request->input('batch_id') . '.txt"');
}
/**
* 获取使用记录
*/
public function usages(Request $request)
{
$request->validate([
'template_id' => 'integer|exists:v2_gift_card_template,id',
'user_id' => 'integer|exists:v2_user,id',
'page' => 'integer|min:1',
'per_page' => 'integer|min:1|max:100',
]);
$query = GiftCardUsage::with(['template', 'code', 'user', 'inviteUser']);
if ($request->has('template_id')) {
$query->where('template_id', $request->input('template_id'));
}
if ($request->has('user_id')) {
$query->where('user_id', $request->input('user_id'));
}
$perPage = $request->input('per_page', 15);
$usages = $query->orderBy('created_at', 'desc')->paginate($perPage);
$data = $usages->getCollection()->map(function ($usage) {
return [
'id' => $usage->id,
'code' => $usage->code->code,
'template_name' => $usage->template->name,
'user_email' => $usage->user->email,
'invite_user_email' => $usage->inviteUser ? substr($usage->inviteUser->email, 0, 3) . '***@***' : null,
'rewards_given' => $usage->rewards_given,
'invite_rewards' => $usage->invite_rewards,
'multiplier_applied' => $usage->multiplier_applied,
// 'ip_address' => $usage->ip_address,
'created_at' => $usage->created_at,
];
});
return response()->json([
'data' => $data,
'pagination' => [
'current_page' => $usages->currentPage(),
'last_page' => $usages->lastPage(),
'per_page' => $usages->perPage(),
'total' => $usages->total(),
],
]);
}
/**
* 获取统计数据
*/
public function statistics(Request $request)
{
$request->validate([
'start_date' => 'date_format:Y-m-d',
'end_date' => 'date_format:Y-m-d',
]);
$startDate = $request->input('start_date', date('Y-m-d', strtotime('-30 days')));
$endDate = $request->input('end_date', date('Y-m-d'));
// 总体统计
$totalStats = [
'templates_count' => GiftCardTemplate::count(),
'active_templates_count' => GiftCardTemplate::where('status', 1)->count(),
'codes_count' => GiftCardCode::count(),
'used_codes_count' => GiftCardCode::where('status', GiftCardCode::STATUS_USED)->count(),
'usages_count' => GiftCardUsage::count(),
];
// 每日使用统计
$driver = GiftCardUsage::query()->getConnection()->getDriverName();
$dateExpression = "date(created_at, 'unixepoch')"; // Default for SQLite
if ($driver === 'mysql') {
$dateExpression = 'DATE(FROM_UNIXTIME(created_at))';
} elseif ($driver === 'pgsql') {
$dateExpression = 'date(to_timestamp(created_at))';
}
$dailyUsages = GiftCardUsage::selectRaw("{$dateExpression} as date, COUNT(*) as count")
->whereRaw("{$dateExpression} BETWEEN ? AND ?", [$startDate, $endDate])
->groupBy('date')
->orderBy('date')
->get();
// 类型统计
$typeStats = GiftCardUsage::with('template')
->selectRaw('template_id, COUNT(*) as count')
->groupBy('template_id')
->get()
->map(function ($item) {
return [
'template_name' => $item->template->name,
'type_name' => $item->template->type_name,
'count' => $item->count,
];
});
return $this->success([
'total_stats' => $totalStats,
'daily_usages' => $dailyUsages,
'type_stats' => $typeStats,
]);
}
/**
* 获取所有可用的礼品卡类型
*/
public function types()
{
return $this->success(GiftCardTemplate::getTypeMap());
}
/**
* 更新单个兑换码
*/
public function updateCode(Request $request)
{
$validatedData = $request->validate([
'id' => 'required|integer|exists:v2_gift_card_code,id',
'expires_at' => 'sometimes|nullable|integer',
'max_usage' => 'sometimes|integer|min:1|max:1000',
'status' => 'sometimes|integer|in:0,1,2,3',
]);
$code = GiftCardCode::find($validatedData['id']);
if (!$code) {
return $this->fail([404, '礼品卡不存在']);
}
try {
$updateData = collect($validatedData)->except('id')->all();
if (empty($updateData)) {
return $this->success($code);
}
$updateData['updated_at'] = time();
$code->update($updateData);
return $this->success($code->fresh());
} catch (\Exception $e) {
Log::error('更新礼品卡信息失败', [
'admin_id' => $request->user()->id,
'code_id' => $code->id,
'error' => $e->getMessage(),
]);
return $this->fail([500, '更新失败']);
}
}
/**
* 删除礼品卡
*/
public function deleteCode(Request $request)
{
$request->validate([
'id' => 'required|integer|exists:v2_gift_card_code,id',
]);
$code = GiftCardCode::find($request->input('id'));
if (!$code) {
return $this->fail([404, '礼品卡不存在']);
}
// 检查是否已被使用
if ($code->status === GiftCardCode::STATUS_USED) {
return $this->fail([400, '该礼品卡已被使用,无法删除']);
}
try {
// 检查是否有关联的使用记录
if ($code->usages()->exists()) {
return $this->fail([400, '该礼品卡存在使用记录,无法删除']);
}
$code->delete();
return $this->success(['message' => '删除成功']);
} catch (\Exception $e) {
Log::error('删除礼品卡失败', [
'admin_id' => $request->user()->id,
'code_id' => $code->id,
'error' => $e->getMessage(),
]);
return $this->fail([500, '删除失败']);
}
}
}

View File

@@ -54,13 +54,13 @@ class OrderController extends Controller
page: $current
);
$paginatedResults->getCollection()->transform(function($order) {
$paginatedResults->getCollection()->transform(function ($order) {
$orderArray = $order->toArray();
$orderArray['period'] = PlanService::getLegacyPeriod((string) $order->period);
return $orderArray;
});
return response()->json($paginatedResults);
return $this->paginate($paginatedResults);
}
private function applyFiltersAndSorts(Request $request, Builder $builder): void

View File

@@ -8,6 +8,7 @@ use App\Services\Plugin\PluginManager;
use App\Services\Plugin\PluginConfigService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class PluginController extends Controller
{
@@ -44,6 +45,11 @@ class PluginController extends Controller
$installed = isset($installedPlugins[$code]);
// 使用配置服务获取配置
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
$readmeFile = collect(['README.md', 'readme.md'])
->map(fn($f) => $directory . '/' . $f)
->first(fn($path) => File::exists($path));
$readmeContent = $readmeFile ? File::get($readmeFile) : '';
$plugins[] = [
'code' => $config['code'],
'name' => $config['name'],
@@ -53,6 +59,7 @@ class PluginController extends Controller
'is_installed' => $installed,
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
'config' => $pluginConfig,
'readme' => $readmeContent,
];
}
}

View File

@@ -169,10 +169,7 @@ class UserController extends Controller
return self::transformUserData($user);
});
return response([
'data' => $users->items(),
'total' => $users->total()
]);
return $this->paginate($users);
}
/**

View File

@@ -95,6 +95,11 @@ class ServerSave extends FormRequest
'excludes' => 'nullable|array',
'ips' => 'nullable|array',
'rate' => 'required|numeric',
'rate_time_enable' => 'nullable|boolean',
'rate_time_ranges' => 'nullable|array',
'rate_time_ranges.*.start' => 'required_with:rate_time_ranges|string|date_format:H:i',
'rate_time_ranges.*.end' => 'required_with:rate_time_ranges|string|date_format:H:i',
'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0',
'protocol_settings' => 'array',
];
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
class GiftCardCheckRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
class GiftCardRedeemRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'code' => 'required|string|min:8|max:32',
];
}
/**
* Get custom messages for validator errors.
*
* @return array
*/
public function messages()
{
return [
'code.required' => '请输入兑换码',
'code.min' => '兑换码长度不能少于8位',
'code.max' => '兑换码长度不能超过32位',
];
}
}

View File

@@ -29,6 +29,7 @@ class PlanResource extends JsonResource
'capacity_limit' => $this->getFormattedCapacityLimit(),
'transfer_enable' => $this->resource['transfer_enable'],
'speed_limit' => $this->resource['speed_limit'],
'device_limit' => $this->resource['device_limit'],
'show' => (bool) $this->resource['show'],
'sell' => (bool) $this->resource['sell'],
'renew' => (bool) $this->resource['renew'],

View File

@@ -3,6 +3,7 @@ namespace App\Http\Routes\V1;
use App\Http\Controllers\V1\User\CommController;
use App\Http\Controllers\V1\User\CouponController;
use App\Http\Controllers\V1\User\GiftCardController;
use App\Http\Controllers\V1\User\InviteController;
use App\Http\Controllers\V1\User\KnowledgeController;
use App\Http\Controllers\V1\User\NoticeController;
@@ -61,6 +62,12 @@ class UserRoute
$router->get('/server/fetch', [ServerController::class, 'fetch']);
// Coupon
$router->post('/coupon/check', [CouponController::class, 'check']);
// Gift Card
$router->post('/gift-card/check', [GiftCardController::class, 'check']);
$router->post('/gift-card/redeem', [GiftCardController::class, 'redeem']);
$router->get('/gift-card/history', [GiftCardController::class, 'history']);
$router->get('/gift-card/detail', [GiftCardController::class, 'detail']);
$router->get('/gift-card/types', [GiftCardController::class, 'types']);
// Telegram
$router->get('/telegram/getBotInfo', [TelegramController::class, 'getBotInfo']);
// Comm

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\V2\Admin\StatController;
use App\Http\Controllers\V2\Admin\NoticeController;
use App\Http\Controllers\V2\Admin\TicketController;
use App\Http\Controllers\V2\Admin\CouponController;
use App\Http\Controllers\V2\Admin\GiftCardController;
use App\Http\Controllers\V2\Admin\KnowledgeController;
use App\Http\Controllers\V2\Admin\PaymentController;
use App\Http\Controllers\V2\Admin\SystemController;
@@ -158,6 +159,32 @@ class AdminRoute
$router->post('/update', [CouponController::class, 'update']);
});
// Gift Card
$router->group([
'prefix' => 'gift-card'
], function ($router) {
// Template management
$router->any('/templates', [GiftCardController::class, 'templates']);
$router->post('/create-template', [GiftCardController::class, 'createTemplate']);
$router->post('/update-template', [GiftCardController::class, 'updateTemplate']);
$router->post('/delete-template', [GiftCardController::class, 'deleteTemplate']);
// Code management
$router->post('/generate-codes', [GiftCardController::class, 'generateCodes']);
$router->any('/codes', [GiftCardController::class, 'codes']);
$router->post('/toggle-code', [GiftCardController::class, 'toggleCode']);
$router->get('/export-codes', [GiftCardController::class, 'exportCodes']);
$router->post('/update-code', [GiftCardController::class, 'updateCode']);
$router->post('/delete-code', [GiftCardController::class, 'deleteCode']);
// Usage records
$router->any('/usages', [GiftCardController::class, 'usages']);
// Statistics
$router->any('/statistics', [GiftCardController::class, 'statistics']);
$router->get('/types', [GiftCardController::class, 'types']);
});
// Knowledge
$router->group([
'prefix' => 'knowledge'

259
app/Models/GiftCardCode.php Normal file
View File

@@ -0,0 +1,259 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* App\Models\GiftCardCode
*
* @property int $id
* @property int $template_id 模板ID
* @property GiftCardTemplate $template 关联模板
* @property string $code 兑换码
* @property string|null $batch_id 批次ID
* @property int $status 状态
* @property int|null $user_id 使用用户ID
* @property int|null $used_at 使用时间
* @property int|null $expires_at 过期时间
* @property array|null $actual_rewards 实际奖励
* @property int $usage_count 使用次数
* @property int $max_usage 最大使用次数
* @property array|null $metadata 额外数据
* @property int $created_at
* @property int $updated_at
*/
class GiftCardCode extends Model
{
protected $table = 'v2_gift_card_code';
protected $dateFormat = 'U';
// 状态常量
const STATUS_UNUSED = 0; // 未使用
const STATUS_USED = 1; // 已使用
const STATUS_EXPIRED = 2; // 已过期
const STATUS_DISABLED = 3; // 已禁用
protected $fillable = [
'template_id',
'code',
'batch_id',
'status',
'user_id',
'used_at',
'expires_at',
'actual_rewards',
'usage_count',
'max_usage',
'metadata'
];
protected $casts = [
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
'used_at' => 'timestamp',
'expires_at' => 'timestamp',
'actual_rewards' => 'array',
'metadata' => 'array'
];
/**
* 获取状态映射
*/
public static function getStatusMap(): array
{
return [
self::STATUS_UNUSED => '未使用',
self::STATUS_USED => '已使用',
self::STATUS_EXPIRED => '已过期',
self::STATUS_DISABLED => '已禁用',
];
}
/**
* 获取状态名称
*/
public function getStatusNameAttribute(): string
{
return self::getStatusMap()[$this->status] ?? '未知状态';
}
/**
* 关联礼品卡模板
*/
public function template(): BelongsTo
{
return $this->belongsTo(GiftCardTemplate::class, 'template_id');
}
/**
* 关联使用用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* 关联使用记录
*/
public function usages(): HasMany
{
return $this->hasMany(GiftCardUsage::class, 'code_id');
}
/**
* 检查是否可用
*/
public function isAvailable(): bool
{
// 检查状态
if ($this->status !== self::STATUS_UNUSED) {
return false;
}
// 检查是否过期
if ($this->expires_at && $this->expires_at < time()) {
return false;
}
// 检查使用次数
if ($this->usage_count >= $this->max_usage) {
return false;
}
return true;
}
/**
* 检查是否已过期
*/
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at < time();
}
/**
* 标记为已使用
*/
public function markAsUsed(User $user): bool
{
$this->status = self::STATUS_USED;
$this->user_id = $user->id;
$this->used_at = time();
$this->usage_count += 1;
return $this->save();
}
/**
* 标记为已过期
*/
public function markAsExpired(): bool
{
$this->status = self::STATUS_EXPIRED;
return $this->save();
}
/**
* 标记为已禁用
*/
public function markAsDisabled(): bool
{
$this->status = self::STATUS_DISABLED;
return $this->save();
}
/**
* 生成兑换码
*/
public static function generateCode(string $prefix = 'GC'): string
{
do {
$code = $prefix . strtoupper(substr(md5(uniqid(mt_rand(), true)), 0, 12));
} while (self::where('code', $code)->exists());
return $code;
}
/**
* 批量生成兑换码
*/
public static function batchGenerate(int $templateId, int $count, array $options = []): string
{
$batchId = uniqid('batch_');
$prefix = $options['prefix'] ?? 'GC';
$expiresAt = $options['expires_at'] ?? null;
$maxUsage = $options['max_usage'] ?? 1;
$codes = [];
for ($i = 0; $i < $count; $i++) {
$codes[] = [
'template_id' => $templateId,
'code' => self::generateCode($prefix),
'batch_id' => $batchId,
'status' => self::STATUS_UNUSED,
'expires_at' => $expiresAt,
'max_usage' => $maxUsage,
'created_at' => time(),
'updated_at' => time(),
];
}
self::insert($codes);
return $batchId;
}
/**
* 设置实际奖励(用于盲盒等)
*/
public function setActualRewards(array $rewards): bool
{
$this->actual_rewards = $rewards;
return $this->save();
}
/**
* 获取实际奖励
*/
public function getActualRewards(): array
{
return $this->actual_rewards ?? $this->template->rewards ?? [];
}
/**
* 检查兑换码格式
*/
public static function validateCodeFormat(string $code): bool
{
// 基本格式验证字母数字组合长度8-32
return preg_match('/^[A-Z0-9]{8,32}$/', $code);
}
/**
* 根据批次ID获取兑换码
*/
public static function getByBatchId(string $batchId)
{
return self::where('batch_id', $batchId)->get();
}
/**
* 清理过期兑换码
*/
public static function cleanupExpired(): int
{
$count = self::where('status', self::STATUS_UNUSED)
->where('expires_at', '<', time())
->count();
self::where('status', self::STATUS_UNUSED)
->where('expires_at', '<', time())
->update(['status' => self::STATUS_EXPIRED]);
return $count;
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* App\Models\GiftCardTemplate
*
* @property int $id
* @property string $name 礼品卡名称
* @property string|null $description 礼品卡描述
* @property int $type 卡片类型
* @property boolean $status 状态
* @property array|null $conditions 使用条件配置
* @property array $rewards 奖励配置
* @property array|null $limits 限制条件
* @property array|null $special_config 特殊配置
* @property string|null $icon 卡片图标
* @property string $theme_color 主题色
* @property int $sort 排序
* @property int $admin_id 创建管理员ID
* @property int $created_at
* @property int $updated_at
*/
class GiftCardTemplate extends Model
{
protected $table = 'v2_gift_card_template';
protected $dateFormat = 'U';
// 卡片类型常量
const TYPE_GENERAL = 1; // 通用礼品卡
const TYPE_PLAN = 2; // 套餐礼品卡
const TYPE_MYSTERY = 3; // 盲盒礼品卡
protected $fillable = [
'name',
'description',
'type',
'status',
'conditions',
'rewards',
'limits',
'special_config',
'icon',
'background_image',
'theme_color',
'sort',
'admin_id'
];
protected $casts = [
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
'conditions' => 'array',
'rewards' => 'array',
'limits' => 'array',
'special_config' => 'array',
'status' => 'boolean'
];
/**
* 获取卡片类型映射
*/
public static function getTypeMap(): array
{
return [
self::TYPE_GENERAL => '通用礼品卡',
self::TYPE_PLAN => '套餐礼品卡',
self::TYPE_MYSTERY => '盲盒礼品卡',
];
}
/**
* 获取类型名称
*/
public function getTypeNameAttribute(): string
{
return self::getTypeMap()[$this->type] ?? '未知类型';
}
/**
* 关联兑换码
*/
public function codes(): HasMany
{
return $this->hasMany(GiftCardCode::class, 'template_id');
}
/**
* 关联使用记录
*/
public function usages(): HasMany
{
return $this->hasMany(GiftCardUsage::class, 'template_id');
}
/**
* 关联统计数据
*/
public function stats(): HasMany
{
return $this->hasMany(GiftCardUsage::class, 'template_id');
}
/**
* 检查是否可用
*/
public function isAvailable(): bool
{
return $this->status;
}
/**
* 检查用户是否满足使用条件
*/
public function checkUserConditions(User $user): bool
{
switch ($this->type) {
case self::TYPE_GENERAL:
$rewards = $this->rewards ?? [];
if (isset($rewards['transfer_enable']) || isset($rewards['expire_days']) || isset($rewards['reset_package'])) {
if (!$user->plan_id) {
return false;
}
}
break;
case self::TYPE_PLAN:
if ($user->isActive()) {
return false;
}
break;
}
$conditions = $this->conditions ?? [];
// 检查新用户条件
if (isset($conditions['new_user_only']) && $conditions['new_user_only']) {
$maxDays = $conditions['new_user_max_days'] ?? 7;
if ($user->created_at < (time() - ($maxDays * 86400))) {
return false;
}
}
// 检查付费用户条件
if (isset($conditions['paid_user_only']) && $conditions['paid_user_only']) {
$paidOrderExists = $user->orders()->where('status', Order::STATUS_COMPLETED)->exists();
if (!$paidOrderExists) {
return false;
}
}
// 检查允许的套餐
if (isset($conditions['allowed_plans']) && $user->plan_id) {
if (!in_array($user->plan_id, $conditions['allowed_plans'])) {
return false;
}
}
// 检查是否需要邀请人
if (isset($conditions['require_invite']) && $conditions['require_invite']) {
if (!$user->invite_user_id) {
return false;
}
}
return true;
}
/**
* 计算实际奖励
*/
public function calculateActualRewards(User $user): array
{
$baseRewards = $this->rewards;
$actualRewards = $baseRewards;
// 处理盲盒随机奖励
if ($this->type === self::TYPE_MYSTERY && isset($this->rewards['random_rewards'])) {
$randomRewards = $this->rewards['random_rewards'];
$totalWeight = array_sum(array_column($randomRewards, 'weight'));
$random = mt_rand(1, $totalWeight);
$currentWeight = 0;
foreach ($randomRewards as $reward) {
$currentWeight += $reward['weight'];
if ($random <= $currentWeight) {
$actualRewards = array_merge($actualRewards, $reward);
unset($actualRewards['weight']);
break;
}
}
}
// 处理节日等特殊奖励(通用逻辑)
if (isset($this->special_config['festival_bonus'])) {
$now = time();
$festivalConfig = $this->special_config;
if (isset($festivalConfig['start_time']) && isset($festivalConfig['end_time'])) {
if ($now >= $festivalConfig['start_time'] && $now <= $festivalConfig['end_time']) {
$bonus = (float) ($festivalConfig['festival_bonus'] ?? 1.0);
if ($bonus > 1.0) {
foreach ($actualRewards as $key => &$value) {
if (is_numeric($value)) {
$value = intval($value * $bonus);
}
}
unset($value); // 解除引用
}
}
}
}
return $actualRewards;
}
/**
* 检查使用频率限制
*/
public function checkUsageLimit(User $user): bool
{
$conditions = $this->conditions ?? [];
// 检查每用户最大使用次数
if (isset($conditions['max_use_per_user'])) {
$usedCount = $this->usages()
->where('user_id', $user->id)
->count();
if ($usedCount >= $conditions['max_use_per_user']) {
return false;
}
}
// 检查冷却时间
if (isset($conditions['cooldown_hours'])) {
$lastUsage = $this->usages()
->where('user_id', $user->id)
->orderBy('created_at', 'desc')
->first();
if ($lastUsage) {
$cooldownTime = $lastUsage->created_at + ($conditions['cooldown_hours'] * 3600);
if (time() < $cooldownTime) {
return false;
}
}
}
return true;
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\GiftCardUsage
*
* @property int $id
* @property int $code_id 兑换码ID
* @property int $template_id 模板ID
* @property int $user_id 使用用户ID
* @property int|null $invite_user_id 邀请人ID
* @property array $rewards_given 实际发放的奖励
* @property array|null $invite_rewards 邀请人获得的奖励
* @property int|null $user_level_at_use 使用时用户等级
* @property int|null $plan_id_at_use 使用时用户套餐ID
* @property float $multiplier_applied 应用的倍率
* @property string|null $ip_address 使用IP地址
* @property string|null $user_agent 用户代理
* @property string|null $notes 备注
* @property int $created_at
*/
class GiftCardUsage extends Model
{
protected $table = 'v2_gift_card_usage';
protected $dateFormat = 'U';
public $timestamps = false;
protected $fillable = [
'code_id',
'template_id',
'user_id',
'invite_user_id',
'rewards_given',
'invite_rewards',
'user_level_at_use',
'plan_id_at_use',
'multiplier_applied',
'ip_address',
'user_agent',
'notes',
'created_at'
];
protected $casts = [
'created_at' => 'timestamp',
'rewards_given' => 'array',
'invite_rewards' => 'array',
'multiplier_applied' => 'float'
];
/**
* 关联兑换码
*/
public function code(): BelongsTo
{
return $this->belongsTo(GiftCardCode::class, 'code_id');
}
/**
* 关联模板
*/
public function template(): BelongsTo
{
return $this->belongsTo(GiftCardTemplate::class, 'template_id');
}
/**
* 关联使用用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* 关联邀请人
*/
public function inviteUser(): BelongsTo
{
return $this->belongsTo(User::class, 'invite_user_id');
}
/**
* 创建使用记录
*/
public static function createRecord(
GiftCardCode $code,
User $user,
array $rewards,
array $options = []
): self {
return self::create([
'code_id' => $code->id,
'template_id' => $code->template_id,
'user_id' => $user->id,
'invite_user_id' => $user->invite_user_id,
'rewards_given' => $rewards,
'invite_rewards' => $options['invite_rewards'] ?? null,
'user_level_at_use' => $user->plan ? $user->plan->sort : null,
'plan_id_at_use' => $user->plan_id,
'multiplier_applied' => $options['multiplier'] ?? 1.0,
// 'ip_address' => $options['ip_address'] ?? null,
'user_agent' => $options['user_agent'] ?? null,
'notes' => $options['notes'] ?? null,
'created_at' => time(),
]);
}
}

View File

@@ -31,7 +31,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int|null $actual_commission_balance
* @property int|null $commission_rate
* @property int|null $commission_auto_check
*
* @property int|null $commission_balance
* @property int|null $discount_amount
* @property int|null $paid_at
* @property string|null $callback_no
*
* @property-read Plan $plan
* @property-read Payment|null $payment
* @property-read User $user

View File

@@ -28,6 +28,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
* @property string|null $network 网络类型
* @property int|null $parent_id 父节点ID
* @property float|null $rate 倍率
* @property array|null $rate_time_ranges 倍率时间范围
* @property int|null $sort 排序
* @property array|null $protocol_settings 协议设置
* @property int $created_at
@@ -114,7 +115,9 @@ class Server extends Model
'last_push_at' => 'integer',
'show' => 'boolean',
'created_at' => 'timestamp',
'updated_at' => 'timestamp'
'updated_at' => 'timestamp',
'rate_time_ranges' => 'array',
'rate_time_enable' => 'boolean',
];
private const PROTOCOL_CONFIGURATIONS = [
@@ -449,4 +452,16 @@ class Server extends Model
}
);
}
public function getCurrentRate(): float
{
$now = date('H:i');
$ranges = $this->rate_time_ranges ?? [];
foreach ($ranges as $range) {
if ($now >= $range['start'] && $now <= $range['end']) {
return (float) $range['rate'];
}
}
return (float) $this->rate;
}
}

View File

@@ -28,7 +28,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TrafficResetLog extends Model
{
protected $table = 'v2_traffic_reset_logs';
protected $fillable = [
'user_id',
'reset_type',
@@ -64,6 +64,8 @@ class TrafficResetLog extends Model
public const SOURCE_API = 'api';
public const SOURCE_CRON = 'cron';
public const SOURCE_USER_ACCESS = 'user_access';
public const SOURCE_ORDER = 'order';
public const SOURCE_GIFT_CARD = 'gift_card';
/**
* 获取重置类型的多语言名称
@@ -139,9 +141,9 @@ class TrafficResetLog extends Model
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}
}
}

View File

@@ -32,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int|null $balance 余额
* @property int|null $commission_balance 佣金余额
* @property float $commission_rate 返佣比例
* @property int|null $commission_type 返佣类型
* @property int|null $device_limit 设备限制数量
* @property int|null $discount 折扣
* @property int|null $last_login_at 最后登录时间

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(),
]);
}
}

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;
}
}

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;
}

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;
}
/**
* 设置试用计划
*/

View File

@@ -0,0 +1,98 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 礼品卡模板表
Schema::create('v2_gift_card_template', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('礼品卡名称');
$table->text('description')->nullable()->comment('礼品卡描述');
$table->tinyInteger('type')->comment('卡片类型1余额 2有效期 3流量 4重置包 5套餐 6组合 7盲盒 8任务 9等级 10节日');
$table->tinyInteger('status')->default(1)->comment('状态0禁用 1启用');
$table->json('conditions')->nullable()->comment('使用条件配置');
$table->json('rewards')->comment('奖励配置');
$table->json('limits')->nullable()->comment('限制条件');
$table->json('special_config')->nullable()->comment('特殊配置(节日时间、等级倍率等)');
$table->string('icon')->nullable()->comment('卡片图标');
$table->string('background_image')->nullable()->comment('背景图片URL');
$table->string('theme_color', 7)->default('#1890ff')->comment('主题色');
$table->integer('sort')->default(0)->comment('排序');
$table->integer('admin_id')->comment('创建管理员ID');
$table->integer('created_at');
$table->integer('updated_at');
$table->index(['type', 'status'], 'idx_gift_template_type_status');
$table->index('created_at', 'idx_gift_template_created_at');
});
// 礼品卡兑换码表
Schema::create('v2_gift_card_code', function (Blueprint $table) {
$table->id();
$table->integer('template_id')->comment('模板ID');
$table->string('code', 32)->unique()->comment('兑换码');
$table->string('batch_id', 32)->nullable()->comment('批次ID');
$table->tinyInteger('status')->default(0)->comment('状态0未使用 1已使用 2已过期 3已禁用');
$table->integer('user_id')->nullable()->comment('使用用户ID');
$table->integer('used_at')->nullable()->comment('使用时间');
$table->integer('expires_at')->nullable()->comment('过期时间');
$table->json('actual_rewards')->nullable()->comment('实际获得的奖励(用于盲盒等)');
$table->integer('usage_count')->default(0)->comment('使用次数(分享卡)');
$table->integer('max_usage')->default(1)->comment('最大使用次数');
$table->json('metadata')->nullable()->comment('额外数据');
$table->integer('created_at');
$table->integer('updated_at');
$table->index('template_id', 'idx_gift_code_template_id');
$table->index('status', 'idx_gift_code_status');
$table->index('user_id', 'idx_gift_code_user_id');
$table->index('batch_id', 'idx_gift_code_batch_id');
$table->index('expires_at', 'idx_gift_code_expires_at');
$table->index(['code', 'status', 'expires_at'], 'idx_gift_code_lookup');
});
// 礼品卡使用记录表
Schema::create('v2_gift_card_usage', function (Blueprint $table) {
$table->id();
$table->integer('code_id')->comment('兑换码ID');
$table->integer('template_id')->comment('模板ID');
$table->integer('user_id')->comment('使用用户ID');
$table->integer('invite_user_id')->nullable()->comment('邀请人ID');
$table->json('rewards_given')->comment('实际发放的奖励');
$table->json('invite_rewards')->nullable()->comment('邀请人获得的奖励');
$table->integer('user_level_at_use')->nullable()->comment('使用时用户等级');
$table->integer('plan_id_at_use')->nullable()->comment('使用时用户套餐ID');
$table->decimal('multiplier_applied', 3, 2)->default(1.00)->comment('应用的倍率');
$table->string('ip_address', 45)->nullable()->comment('使用IP地址');
$table->text('user_agent')->nullable()->comment('用户代理');
$table->text('notes')->nullable()->comment('备注');
$table->integer('created_at');
$table->index('code_id', 'idx_gift_usage_code_id');
$table->index('template_id', 'idx_gift_usage_template_id');
$table->index('user_id', 'idx_gift_usage_user_id');
$table->index('invite_user_id', 'idx_gift_usage_invite_user_id');
$table->index('created_at', 'idx_gift_usage_created_at');
$table->index(['user_id', 'created_at'], 'idx_gift_usage_user_usage');
$table->index(['template_id', 'created_at'], 'idx_gift_usage_template_stats');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v2_gift_card_usage');
Schema::dropIfExists('v2_gift_card_code');
Schema::dropIfExists('v2_gift_card_template');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->boolean('rate_time_enable')->default(false)->comment('是否启用动态倍率')->after('rate');
$table->json('rate_time_ranges')->nullable()->comment('动态倍率规则')->after('rate_time_enable');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v2_server', function (Blueprint $table) {
$table->dropColumn('rate_time_enable');
$table->dropColumn('rate_time_ranges');
});
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link rel="icon" type="image/png" href="/images/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadcn Admin</title>
<meta
name="description"
content="Admin Dashboard UI built with Shadcn and Vite."
/>
<script>
window.settings = {
base_url: 'http://127.0.0.1:8000',
title: 'Xboard',
version: '1.0.0',
logo: 'https://xboard.io/i6mages/logo.png',
secure_path: '/afbced4e',
}
</script>
<script src="./locales/en-US.js"></script>
<script src="./locales/zh-CN.js"></script>
<script src="./locales/ko-KR.js"></script>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
<link rel="stylesheet" crossorigin href="./assets/vendor.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -161,6 +161,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"planManagement": "Plan Management",
"orderManagement": "Order Management",
"couponManagement": "Coupon Management",
"giftCardManagement": "Gift Card Management",
"userManagement": "User Management",
"trafficResetLogs": "Traffic Reset Logs",
"ticketManagement": "Ticket Management"
@@ -555,7 +556,8 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"subscribe_path": {
"title": "Subscription Path",
"description": "Subscription path, modifying will change the original subscribe path",
"current_format": "Current subscription path format: {path}/xxxxxxxxxx"
"current_format": "Current subscription path format: {path}/xxxxxxxxxx",
"restart_tip": "You may need to restart the service for the new subscribe path to take effect."
},
"show_info_to_server": {
"title": "Show Subscription Info in Nodes",
@@ -932,6 +934,8 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"error": "Copy failed",
"errorLog": "Error copying to clipboard"
},
"submit": "Submit",
"saving": "Saving...",
"table": {
"noData": "No data available",
"pagination": {
@@ -955,6 +959,15 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"updating": "Updating...",
"updateSuccess": "Update successful, system will restart shortly",
"updateFailed": "Update failed, please try again later"
},
"time": {
"day": "day",
"hour": " hour(s)"
},
"reset": "Reset",
"export": "Export",
"currency": {
"yuan": "Yuan"
}
},
"dashboard": {
@@ -1164,6 +1177,241 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"loading": "Searching..."
}
},
"giftCard": {
"types": {
"1": "General Gift Card",
"2": "Plan-Specific Gift Card",
"3": "Mystery Box Reward",
"4": "Task Gift Card"
},
"status": {
"0": "Unused",
"1": "Used",
"2": "Disabled",
"3": "Expired"
},
"template": {
"title": "Gift Card Templates",
"add": "Add Template",
"search": "Search template name...",
"form": {
"name": {
"label": "Name",
"placeholder": "Enter template name"
},
"sort": {
"label": "Sort",
"placeholder": "Smaller numbers come first"
},
"type": {
"label": "Type",
"placeholder": "Select gift card type"
},
"description": {
"label": "Description",
"placeholder": "Enter gift card description"
},
"status": {
"label": "Status",
"description": "If disabled, this template cannot be used to generate or redeem new gift cards."
},
"display": {
"title": "Display Settings"
},
"theme_color": {
"label": "Theme Color"
},
"icon": {
"label": "Icon",
"placeholder": "Enter the URL of the icon"
},
"background_image": {
"label": "Background Image",
"placeholder": "Enter the URL of the background image"
},
"conditions": {
"title": "Usage Conditions",
"new_user_max_days": {
"label": "New User Registration Day Limit",
"placeholder": "e.g., 7 (only for users registered within 7 days)"
},
"new_user_only": {
"label": "New Users Only"
},
"paid_user_only": {
"label": "Paid Users Only"
},
"require_invite": {
"label": "Requires Invitation"
},
"allowed_plans": {
"label": "Allowed Plans",
"placeholder": "Select allowed plans (leave empty for no restriction)"
},
"disallowed_plans": {
"label": "Disallowed Plans",
"placeholder": "Select disallowed plans (leave empty for no restriction)"
}
},
"limits": {
"title": "Usage Limits",
"max_use_per_user": {
"label": "Max Uses Per User",
"placeholder": "Leave empty for no limit"
},
"cooldown_hours": {
"label": "Cooldown for Same Type (Hours)",
"placeholder": "Leave empty for no limit"
},
"invite_reward_rate": {
"label": "Inviter Reward Rate",
"placeholder": "e.g., 0.2 (for 20%)",
"description": "If the user has an inviter, the inviter's reward = balance reward * this rate"
}
},
"rewards": {
"title": "Reward Content",
"balance": {
"label": "Reward Amount (in Yuan)",
"short_label": "Balance",
"placeholder": "Enter the reward amount in Yuan"
},
"transfer_enable": {
"label": "Reward Traffic (in bytes)",
"short_label": "Traffic",
"placeholder": "Enter the reward traffic in bytes"
},
"expire_days": {
"label": "Extend Validity (in days)",
"short_label": "Validity",
"placeholder": "Enter the number of days to extend"
},
"transfer": {
"label": "Reward Traffic (in bytes)",
"placeholder": "Enter the reward traffic in bytes"
},
"days": {
"label": "Extend Validity (in days)",
"placeholder": "Enter the number of days to extend"
},
"device_limit": {
"label": "Increase Device Limit",
"short_label": "Devices",
"placeholder": "Enter the number of devices to increase"
},
"reset_package": {
"label": "Reset Monthly Traffic",
"description": "If enabled, the user's current plan traffic usage will be reset to zero upon redemption."
},
"reset_count": {
"description": "This type of card will reset the user's traffic usage for the current month."
},
"task_card": {
"description": "The specific rewards for task gift cards will be configured in the task system."
},
"plan_id": {
"label": "Specify Plan",
"short_label": "Plan",
"placeholder": "Select a plan"
},
"plan_validity_days": {
"label": "Plan Validity (in days)",
"short_label": "Plan Validity",
"placeholder": "Leave empty to use the plan's default validity"
},
"random_rewards": {
"label": "Random Reward Pool",
"add": "Add Random Reward Item",
"weight": "Weight"
}
},
"special_config": {
"title": "Special Configuration",
"start_time": {
"label": "Event Start Time",
"placeholder": "Pick a start date"
},
"end_time": {
"label": "Event End Time",
"placeholder": "Pick an end date"
},
"festival_bonus": {
"label": "Festival Reward Multiplier",
"placeholder": "e.g., 1.5 (for 1.5x)"
}
}
}
},
"code": {
"title": "Code Management",
"add": "Generate Codes",
"search": "Search codes...",
"form": {
"template_id": {
"label": "Gift Card Template",
"placeholder": "Select a template"
},
"count": {
"label": "Quantity to Generate",
"placeholder": "Enter the quantity to generate"
},
"prefix": {
"label": "Code Prefix",
"placeholder": "Leave empty for default prefix GC"
},
"expires_hours": {
"label": "Validity (Hours)",
"placeholder": "From generation time, leave empty for no expiration"
},
"max_usage": {
"label": "Max Usage Count",
"placeholder": "Total times each code can be used"
},
"download_csv": "Export as CSV"
},
"table": {
"code": "Code",
"template": "Template",
"status": "Status",
"expires_at": "Expires At",
"used_at": "Used At",
"used_by": "Used By",
"max_usage": "Max Uses",
"usage_count": "Usage Count"
},
"messages": {
"generateSuccess": "Codes generated successfully",
"generateFailed": "Failed to generate codes",
"deleteConfirm": "Are you sure you want to delete this code? This action cannot be undone.",
"deleteSuccess": "Code deleted successfully",
"deleteFailed": "Failed to delete code"
}
},
"messages": {
"formInvalid": "Form validation failed, please check your input.",
"templateCreated": "Template created successfully",
"templateUpdated": "Template updated successfully",
"createTemplateFailed": "Failed to create template",
"updateTemplateFailed": "Failed to update template",
"deleteConfirm": "Are you sure you want to delete this template? All codes under it will also be deleted.",
"deleteSuccess": "Template deleted successfully",
"deleteFailed": "Failed to delete template",
"codesGenerated": "Codes generated successfully"
},
"table": {
"columns": {
"no_rewards": "No Rewards"
}
},
"common": {
"currency": {
"yuan": "Yuan"
},
"time": {
"day": "day"
}
}
},
"route": {
"title": "Route Management",
"description": "Manage all route groups, including adding, deleting, and editing operations.",
@@ -1705,6 +1953,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"error_gte_zero": "Base rate must be greater than or equal to 0"
},
"dynamic_rate": {
"section_title": "Dynamic Rate Configuration",
"enable_label": "Enable Dynamic Rate",
"enable_description": "Set different rate multipliers based on time periods",
"rules_label": "Time Period Rules",

View File

@@ -161,6 +161,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"planManagement": "套餐管理",
"orderManagement": "订单管理",
"couponManagement": "优惠券管理",
"giftCardManagement": "礼品卡管理",
"userManagement": "用户管理",
"ticketManagement": "工单管理",
"trafficResetLogs": "流量重置日志"
@@ -475,7 +476,8 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"subscribe_path": {
"title": "订阅路径",
"description": "订阅路径修改后将会改变原有的subscribe路径",
"current_format": "当前订阅路径格式:{path}/xxxxxxxxxx"
"current_format": "当前订阅路径格式:{path}/xxxxxxxxxx",
"restart_tip": "修改订阅路径后,可能需要重启服务才能生效。"
},
"show_info_to_server": {
"title": "在订阅中展示订阅信息",
@@ -937,6 +939,8 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"error": "复制失败",
"errorLog": "复制到剪贴板时出错"
},
"submit": "提交",
"saving": "保存中...",
"table": {
"noData": "暂无数据",
"pagination": {
@@ -960,6 +964,15 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"updating": "更新中...",
"updateSuccess": "更新成功,系统将在稍后自动重启",
"updateFailed": "更新失败,请稍后重试"
},
"time": {
"day": "天",
"hour": "小时"
},
"reset": "重置",
"export": "导出",
"currency": {
"yuan": "元"
}
},
"dashboard": {
@@ -1156,6 +1169,348 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"pagination": "第 {{current}}/{{total}} 页,共 {{count}} 条"
}
},
"giftCard": {
"title": "礼品卡管理",
"description": "在这里可以管理礼品卡模板、兑换码和使用记录等功能。",
"tabs": {
"templates": "模板管理",
"codes": "兑换码管理",
"usages": "使用记录",
"statistics": "统计数据"
},
"template": {
"title": "模板管理",
"description": "管理礼品卡模板,包括创建、编辑和删除模板。",
"table": {
"title": "模板列表",
"columns": {
"id": "ID",
"name": "名称",
"type": "类型",
"status": "状态",
"sort": "排序",
"rewards": "奖励内容",
"created_at": "创建时间",
"actions": "操作",
"no_rewards": "无奖励"
}
},
"form": {
"add": "添加模板",
"edit": "编辑模板",
"name": {
"label": "模板名称",
"placeholder": "请输入模板名称",
"required": "请输入模板名称"
},
"sort": {
"label": "排序",
"placeholder": "数字越小越靠前"
},
"type": {
"label": "类型",
"placeholder": "请选择礼品卡类型"
},
"description": {
"label": "描述",
"placeholder": "请输入礼品卡描述"
},
"status": {
"label": "状态",
"description": "禁用后,此模板将无法生成或兑换新的礼品卡。"
},
"display": {
"title": "显示效果"
},
"theme_color": {
"label": "主题颜色"
},
"icon": {
"label": "图标",
"placeholder": "请输入图标的URL"
},
"background_image": {
"label": "背景图片",
"placeholder": "请输入背景图片的URL"
},
"conditions": {
"title": "使用条件",
"new_user_max_days": {
"label": "新用户注册天数限制",
"placeholder": "例如: 7 (仅限注册7天内的用户)"
},
"new_user_only": {
"label": "仅限新用户"
},
"paid_user_only": {
"label": "仅限付费用户"
},
"require_invite": {
"label": "需要邀请关系"
},
"allowed_plans": {
"label": "允许的套餐",
"placeholder": "选择允许兑换的套餐 (留空则不限制)"
},
"disallowed_plans": {
"label": "禁止的套餐",
"placeholder": "选择禁止兑换的套餐 (留空则不限制)"
}
},
"limits": {
"title": "使用限制",
"max_use_per_user": {
"label": "单用户最大使用次数",
"placeholder": "留空则不限制"
},
"cooldown_hours": {
"label": "同类卡冷却时间(小时)",
"placeholder": "留空则不限制"
},
"invite_reward_rate": {
"label": "邀请人奖励比例",
"placeholder": "例如: 0.2 (代表20%)",
"description": "使用者有邀请人时,给邀请人的奖励 = 余额奖励 * 此比例"
}
},
"rewards": {
"title": "奖励内容",
"balance": {
"label": "奖励余额 (元)",
"short_label": "余额",
"placeholder": "请输入奖励的金额(元)"
},
"transfer_enable": {
"label": "奖励流量 (字节)",
"short_label": "流量",
"placeholder": "请输入奖励的流量(字节)"
},
"expire_days": {
"label": "延长有效期 (天)",
"short_label": "有效期",
"placeholder": "请输入延长的天数"
},
"transfer": {
"label": "奖励流量 (字节)",
"placeholder": "请输入奖励的流量(字节)"
},
"days": {
"label": "延长有效期 (天)",
"placeholder": "请输入延长的天数"
},
"device_limit": {
"label": "增加设备数",
"short_label": "设备数",
"placeholder": "请输入增加的设备数量"
},
"reset_package": {
"label": "重置当月流量",
"description": "开启后,兑换时会将用户当前套餐的已用流量清零。"
},
"reset_count": {
"description": "该类型卡将重置用户当月的流量使用。"
},
"task_card": {
"description": "任务礼品卡的具体奖励将在任务系统中配置。"
},
"plan_id": {
"label": "指定套餐",
"short_label": "套餐",
"placeholder": "请选择一个套餐"
},
"plan_validity_days": {
"label": "套餐有效期 (天)",
"short_label": "套餐有效期",
"placeholder": "留空则使用套餐默认有效期"
},
"random_rewards": {
"label": "随机奖励池",
"add": "添加随机奖励项",
"weight": "权重"
}
},
"special_config": {
"title": "特殊配置",
"start_time": {
"label": "活动开始时间",
"placeholder": "请选择开始日期"
},
"end_time": {
"label": "活动结束时间",
"placeholder": "请选择结束日期"
},
"festival_bonus": {
"label": "节日奖励乘数",
"placeholder": "例如: 1.5 (代表1.5倍)"
}
},
"submit": {
"saving": "保存中...",
"save": "保存"
}
},
"actions": {
"edit": "编辑",
"delete": "删除",
"deleteConfirm": {
"title": "确认删除",
"description": "此操作将永久删除该模板,确定要继续吗?",
"confirmText": "删除"
}
}
},
"code": {
"title": "兑换码管理",
"form": {
"generate": "生成兑换码",
"template_id": {
"label": "选择模板",
"placeholder": "请选择一个模板来生成兑换码"
},
"count": {
"label": "生成数量"
},
"prefix": {
"label": "自定义前缀 (可选)"
},
"expires_hours": {
"label": "有效期 (小时)"
},
"max_usage": {
"label": "最大使用次数"
},
"download_csv": "导出CSV",
"submit": {
"generating": "生成中...",
"generate": "立即生成"
}
},
"description": "管理礼品卡兑换码,包括生成、查看和导出兑换码。",
"generate": {
"title": "生成兑换码",
"template": "选择模板",
"count": "生成数量",
"prefix": "自定义前缀",
"expires_hours": "有效期 (小时)",
"max_usage": "最大使用次数",
"submit": "生成"
},
"table": {
"title": "兑换码列表",
"columns": {
"id": "ID",
"code": "兑换码",
"template_name": "模板名称",
"status": "状态",
"expires_at": "过期时间",
"usage_count": "已用次数",
"max_usage": "可用次数",
"created_at": "创建时间"
}
},
"actions": {
"enable": "启用",
"disable": "禁用",
"export": "导出",
"exportConfirm": {
"title": "确认导出",
"description": "将导出选定批次的所有兑换码为文本文件。确定要继续吗?",
"confirmText": "导出"
}
},
"status": {
"0": "未使用",
"1": "已使用",
"2": "已禁用",
"3": "已过期"
}
},
"usage": {
"title": "使用记录",
"description": "查看礼品卡的使用记录和详细信息。",
"table": {
"columns": {
"id": "ID",
"code": "兑换码",
"template_name": "模板名称",
"user_email": "用户邮箱",
"rewards_given": "获得奖励",
"invite_rewards": "邀请奖励",
"multiplier_applied": "倍数加成",
"ip_address": "IP地址",
"created_at": "使用时间",
"actions": "操作"
}
},
"actions": {
"view": "查看详情"
}
},
"statistics": {
"title": "统计数据",
"description": "查看礼品卡的统计数据和使用情况分析。",
"total": {
"title": "总体统计",
"templates_count": "模板总数",
"active_templates_count": "活跃模板数",
"codes_count": "兑换码总数",
"used_codes_count": "已使用兑换码",
"usages_count": "使用记录数"
},
"daily": {
"title": "每日使用量",
"chart": "使用量趋势图"
},
"type": {
"title": "类型统计",
"chart": "类型分布图"
},
"dateRange": {
"label": "日期范围",
"start": "开始日期",
"end": "结束日期"
}
},
"types": {
"1": "通用礼品卡",
"2": "套餐礼品卡",
"3": "盲盒礼品卡",
"4": "任务礼品卡"
},
"common": {
"search": "搜索礼品卡...",
"reset": "重置",
"filter": "筛选",
"export": "导出",
"refresh": "刷新",
"back": "返回",
"close": "关闭",
"confirm": "确认",
"cancel": "取消",
"enabled": "已启用",
"disabled": "已禁用",
"loading": "加载中...",
"noData": "暂无数据",
"success": "操作成功",
"error": "操作失败"
},
"messages": {
"formInvalid": "请检查表单输入是否正确",
"templateCreated": "模板创建成功",
"templateUpdated": "模板更新成功",
"templateDeleted": "模板删除成功",
"codeGenerated": "兑换码生成成功",
"generateCodeFailed": "兑换码生成失败",
"codeStatusUpdated": "兑换码状态更新成功",
"updateCodeStatusFailed": "兑换码状态更新失败",
"codesExported": "兑换码导出成功",
"createTemplateFailed": "创建模板失败",
"updateTemplateFailed": "更新模板失败",
"deleteTemplateFailed": "删除模板失败",
"loadDataFailed": "加载数据失败",
"codesGenerated": "兑换码生成成功"
}
},
"route": {
"title": "路由管理",
"description": "管理所有路由组,包括添加、删除、编辑等操作。",
@@ -1672,6 +2027,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"error_gte_zero": "基础倍率必须大于或等于0"
},
"dynamic_rate": {
"section_title": "动态倍率配置",
"enable_label": "启用动态倍率",
"enable_description": "根据时间段设置不同的倍率乘数",
"rules_label": "时间段规则",