feat: enhance plan validation, traffic system and email verification

- feat: add plan price validation
- feat: make traffic packages stackable
- feat: add commission and invite info to admin order details
- feat: apply email whitelist to verification code API
- fix: subscription link copy compatibility for non-HTTPS
- fix: resolve route editing 500 error in certain cases
- refactor: restructure traffic reset logic
This commit is contained in:
xboard
2025-06-22 01:18:38 +08:00
parent 7bab761db6
commit 4fe2f35183
34 changed files with 2176 additions and 539 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\V1\Guest;
use App\Http\Controllers\Controller;
use App\Utils\Dict;
use App\Utils\Helper;
use Illuminate\Support\Facades\Http;
class CommController extends Controller
@@ -12,12 +13,12 @@ class CommController extends Controller
{
$data = [
'tos_url' => admin_setting('tos_url'),
'is_email_verify' => (int)admin_setting('email_verify', 0) ? 1 : 0,
'is_invite_force' => (int)admin_setting('invite_force', 0) ? 1 : 0,
'email_whitelist_suffix' => (int)admin_setting('email_whitelist_enable', 0)
? $this->getEmailSuffix()
'is_email_verify' => (int) admin_setting('email_verify', 0) ? 1 : 0,
'is_invite_force' => (int) admin_setting('invite_force', 0) ? 1 : 0,
'email_whitelist_suffix' => (int) admin_setting('email_whitelist_enable', 0)
? Helper::getEmailSuffix()
: 0,
'is_recaptcha' => (int)admin_setting('recaptcha_enable', 0) ? 1 : 0,
'is_recaptcha' => (int) admin_setting('recaptcha_enable', 0) ? 1 : 0,
'recaptcha_site_key' => admin_setting('recaptcha_site_key'),
'app_description' => admin_setting('app_description'),
'app_url' => admin_setting('app_url'),
@@ -25,13 +26,4 @@ class CommController extends Controller
];
return $this->success($data);
}
private function getEmailSuffix()
{
$suffix = admin_setting('email_whitelist_suffix', Dict::EMAIL_WHITELIST_SUFFIX_DEFAULT);
if (!is_array($suffix)) {
return preg_split('/,/', $suffix);
}
return $suffix;
}
}
@@ -2,13 +2,13 @@
namespace App\Http\Controllers\V1\Passport;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Passport\CommSendEmailVerify;
use App\Jobs\SendEmailJob;
use App\Models\InviteCode;
use App\Models\User;
use App\Utils\CacheKey;
use App\Utils\Dict;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use ReCaptcha\ReCaptcha;
@@ -25,7 +25,22 @@ class CommController extends Controller
return $this->fail([400, __('Invalid code is incorrect')]);
}
}
$email = $request->input('email');
// 检查白名单后缀限制
if ((int) admin_setting('email_whitelist_enable', 0)) {
$isRegisteredEmail = User::where('email', $email)->exists();
if (!$isRegisteredEmail) {
$allowedSuffixes = Helper::getEmailSuffix();
$emailSuffix = substr(strrchr($email, '@'), 1);
if (!in_array($emailSuffix, $allowedSuffixes)) {
return $this->fail([400, __('Email suffix is not in whitelist')]);
}
}
}
if (Cache::get(CacheKey::get('LAST_SEND_EMAIL_VERIFY_TIMESTAMP', $email))) {
return $this->fail([400, __('Email verification code has been sent, please request again later')]);
}
@@ -2,7 +2,6 @@
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\UserChangePassword;
use App\Http\Requests\User\UserTransfer;
@@ -15,7 +14,6 @@ use App\Services\AuthService;
use App\Services\UserService;
use App\Utils\CacheKey;
use App\Utils\Helper;
use Auth;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -22,7 +22,7 @@ class OrderController extends Controller
public function detail(Request $request)
{
$order = Order::with(['user', 'plan', 'commission_log'])->find($request->input('id'));
$order = Order::with(['user', 'plan', 'commission_log', 'invite_user'])->find($request->input('id'));
if (!$order)
return $this->fail([400202, '订单不存在']);
if ($order->surplus_order_ids) {
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\PlanSave;
use App\Models\Order;
use App\Models\Plan;
use App\Models\User;
@@ -32,27 +33,17 @@ class PlanController extends Controller
return $this->success($plans);
}
public function save(Request $request)
public function save(PlanSave $request)
{
$params = $request->validate([
'id' => 'nullable|integer',
'name' => 'required|string',
'content' => 'nullable|string',
'reset_traffic_method' => 'integer|nullable',
'transfer_enable' => 'integer|required',
'prices' => 'array|nullable',
'group_id' => 'integer|nullable',
'speed_limit' => 'integer|nullable',
'device_limit' => 'integer|nullable',
'capacity_limit' => 'integer|nullable',
]);
$params = $request->validated();
if ($request->input('id')) {
$plan = Plan::find($request->input('id'));
if (!$plan) {
return $this->fail([400202, '该订阅不存在']);
}
DB::beginTransaction();
// update user group id and transfer
try {
if ($request->input('force_update')) {
User::where('plan_id', $plan->id)->update([
@@ -6,18 +6,13 @@ use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\ServerRoute;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class RouteController extends Controller
{
public function fetch(Request $request)
{
$routes = ServerRoute::get();
// TODO: remove on 1.8.0
foreach ($routes as $k => $route) {
$array = json_decode($route->match, true);
if (is_array($array)) $routes[$k]['match'] = $array;
}
// TODO: remove on 1.8.0
return [
'data' => $routes
];
@@ -38,15 +33,13 @@ class RouteController extends Controller
]);
$params['match'] = array_filter($params['match']);
// TODO: remove on 1.8.0
$params['match'] = json_encode($params['match']);
// TODO: remove on 1.8.0
if ($request->input('id')) {
try {
$route = ServerRoute::find($request->input('id'));
$route->update($params);
return $this->success(true);
} catch (\Exception $e) {
\Log::error($e);
Log::error($e);
return $this->fail([500,'保存失败']);
}
}
@@ -54,7 +47,7 @@ class RouteController extends Controller
ServerRoute::create($params);
return $this->success(true);
}catch(\Exception $e){
\Log::error($e);
Log::error($e);
return $this->fail([500,'创建失败']);
}
}
@@ -0,0 +1,234 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\TrafficResetLog;
use App\Services\TrafficResetService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
/**
* 流量重置管理控制器
*/
class TrafficResetController extends Controller
{
private TrafficResetService $trafficResetService;
public function __construct(TrafficResetService $trafficResetService)
{
$this->trafficResetService = $trafficResetService;
}
/**
* 获取流量重置日志列表
*/
public function logs(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'nullable|integer',
'user_email' => 'nullable|string',
'reset_type' => 'nullable|string|in:' . implode(',', array_keys(TrafficResetLog::getResetTypeNames())),
'trigger_source' => 'nullable|string|in:' . implode(',', array_keys(TrafficResetLog::getSourceNames())),
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'per_page' => 'nullable|integer|min:1|max:10000',
'page' => 'nullable|integer|min:1',
]);
$query = TrafficResetLog::with(['user:id,email'])
->orderBy('reset_time', 'desc');
// 筛选条件
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('user_email')) {
$query->whereHas('user', function ($query) use ($request) {
$query->where('email', 'like', '%' . $request->user_email . '%');
});
}
if ($request->filled('reset_type')) {
$query->where('reset_type', $request->reset_type);
}
if ($request->filled('trigger_source')) {
$query->where('trigger_source', $request->trigger_source);
}
if ($request->filled('start_date')) {
$query->where('reset_time', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->where('reset_time', '<=', $request->end_date . ' 23:59:59');
}
$perPage = $request->get('per_page', 20);
$logs = $query->paginate($perPage);
// 格式化数据
$logs->getCollection()->transform(function ($log) {
return [
'id' => $log->id,
'user_id' => $log->user_id,
'user_email' => $log->user->email ?? 'N/A',
'reset_type' => $log->reset_type,
'reset_type_name' => $log->getResetTypeName(),
'reset_time' => $log->reset_time,
'old_traffic' => [
'upload' => $log->old_upload,
'download' => $log->old_download,
'total' => $log->old_total,
'formatted' => $log->formatTraffic($log->old_total),
],
'new_traffic' => [
'upload' => $log->new_upload,
'download' => $log->new_download,
'total' => $log->new_total,
'formatted' => $log->formatTraffic($log->new_total),
],
'trigger_source' => $log->trigger_source,
'trigger_source_name' => $log->getSourceName(),
'metadata' => $log->metadata,
'created_at' => $log->created_at,
];
});
return response()->json([
'data' => $logs->items(),
'pagination' => [
'current_page' => $logs->currentPage(),
'last_page' => $logs->lastPage(),
'per_page' => $logs->perPage(),
'total' => $logs->total(),
],
]);
}
/**
* 获取流量重置统计信息
*/
public function stats(Request $request): JsonResponse
{
$request->validate([
'days' => 'nullable|integer|min:1|max:365',
]);
$days = $request->get('days', 30);
$startDate = now()->subDays($days)->startOfDay();
$stats = [
'total_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)->count(),
'auto_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)
->where('trigger_source', TrafficResetLog::SOURCE_AUTO)
->count(),
'manual_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)
->where('trigger_source', TrafficResetLog::SOURCE_MANUAL)
->count(),
'cron_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)
->where('trigger_source', TrafficResetLog::SOURCE_CRON)
->count(),
];
return response()->json([
'data' => $stats
]);
}
/**
* 手动重置用户流量
*/
public function resetUser(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|integer|exists:v2_user,id',
'reason' => 'nullable|string|max:255',
]);
$user = User::find($request->user_id);
if (!$this->trafficResetService->canReset($user)) {
return response()->json([
'message' => __('traffic_reset.user_cannot_reset')
], 400);
}
$metadata = [];
if ($request->filled('reason')) {
$metadata['reason'] = $request->reason;
$metadata['admin_id'] = auth()->user()?->id;
}
$success = $this->trafficResetService->manualReset($user, $metadata);
if (!$success) {
return response()->json([
'message' => __('traffic_reset.reset_failed')
], 500);
}
return response()->json([
'message' => __('traffic_reset.reset_success'),
'data' => [
'user_id' => $user->id,
'email' => $user->email,
'reset_time' => now(),
'next_reset_at' => $user->fresh()->next_reset_at,
]
]);
}
/**
* 获取用户重置历史
*/
public function userHistory(Request $request, int $userId): JsonResponse
{
$request->validate([
'limit' => 'nullable|integer|min:1|max:50',
]);
$user = User::findOrFail($userId);
$limit = $request->get('limit', 10);
$history = $this->trafficResetService->getUserResetHistory($user, $limit);
$data = $history->map(function ($log) {
return [
'id' => $log->id,
'reset_type' => $log->reset_type,
'reset_type_name' => $log->getResetTypeName(),
'reset_time' => $log->reset_time,
'old_traffic' => [
'upload' => $log->old_upload,
'download' => $log->old_download,
'total' => $log->old_total,
'formatted' => $log->formatTraffic($log->old_total),
],
'trigger_source' => $log->trigger_source,
'trigger_source_name' => $log->getSourceName(),
'metadata' => $log->metadata,
];
});
return response()->json([
"data" => [
'user' => [
'id' => $user->id,
'email' => $user->email,
'reset_count' => $user->reset_count,
'last_reset_at' => $user->last_reset_at,
'next_reset_at' => $user->next_reset_at,
],
'history' => $data,
]
]);
}
}
+134 -36
View File
@@ -2,56 +2,154 @@
namespace App\Http\Requests\Admin;
use App\Models\Plan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class PlanSave extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
* Determine if the user is authorized to make this request.
*/
public function rules()
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => 'required',
'content' => '',
'group_id' => 'required',
'transfer_enable' => 'required',
'month_price' => 'nullable|integer',
'quarter_price' => 'nullable|integer',
'half_year_price' => 'nullable|integer',
'year_price' => 'nullable|integer',
'two_year_price' => 'nullable|integer',
'three_year_price' => 'nullable|integer',
'onetime_price' => 'nullable|integer',
'reset_price' => 'nullable|integer',
'reset_traffic_method' => 'nullable|integer|in:0,1,2,3,4',
'capacity_limit' => 'nullable|integer',
'speed_limit' => 'nullable|integer'
'id' => 'nullable|integer',
'name' => 'required|string|max:255',
'content' => 'nullable|string',
'reset_traffic_method' => 'integer|nullable',
'transfer_enable' => 'integer|required|min:1',
'prices' => 'nullable|array',
'prices.*' => 'nullable|numeric|min:0',
'group_id' => 'integer|nullable',
'speed_limit' => 'integer|nullable|min:0',
'device_limit' => 'integer|nullable|min:0',
'capacity_limit' => 'integer|nullable|min:0',
];
}
public function messages()
/**
* Configure the validator instance.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$this->validatePrices($validator);
});
}
/**
* 验证价格配置
*/
protected function validatePrices(Validator $validator): void
{
$prices = $this->input('prices', []);
if (empty($prices)) {
return;
}
// 获取所有有效的周期
$validPeriods = array_keys(Plan::getAvailablePeriods());
foreach ($prices as $period => $price) {
// 验证周期是否有效
if (!in_array($period, $validPeriods)) {
$validator->errors()->add(
"prices.{$period}",
"不支持的订阅周期: {$period}"
);
continue;
}
// 价格可以为 null、空字符串或大于 0 的数字
if ($price !== null && $price !== '') {
// 转换为数字进行验证
$numericPrice = is_numeric($price) ? (float) $price : null;
if ($numericPrice === null) {
$validator->errors()->add(
"prices.{$period}",
"价格必须是数字格式"
);
} elseif ($numericPrice <= 0) {
$validator->errors()->add(
"prices.{$period}",
"价格必须大于 0(如不需要此周期请留空或设为 null)"
);
}
}
}
}
/**
* 处理验证后的数据
*/
protected function passedValidation(): void
{
// 清理和格式化价格数据
$prices = $this->input('prices', []);
$cleanedPrices = [];
foreach ($prices as $period => $price) {
// 只保留有效的正数价格
if ($price !== null && $price !== '' && is_numeric($price)) {
$numericPrice = (float) $price;
if ($numericPrice > 0) {
// 转换为浮点数并保留两位小数
$cleanedPrices[$period] = round($numericPrice, 2);
}
}
}
// 更新请求中的价格数据
$this->merge(['prices' => $cleanedPrices]);
}
/**
* Get custom error messages for validator errors.
*/
public function messages(): array
{
return [
'name.required' => '套餐名称不能为空',
'type.required' => '套餐类型不能为空',
'type.in' => '套餐类型格式有误',
'group_id.required' => '权限组不能为空',
'transfer_enable.required' => '流量不能为空',
'month_price.integer' => '月付金额格式误',
'quarter_price.integer' => '季付金额格式有误',
'half_year_price.integer' => '半年付金额格式有误',
'year_price.integer' => '年付金额格式有误',
'two_year_price.integer' => '两年付金额格式有误',
'three_year_price.integer' => '三年付金额格式有误',
'onetime_price.integer' => '一次性金额有误',
'reset_price.integer' => '流量重置包金额有误',
'reset_traffic_method.integer' => '流量重置方式格式有误',
'reset_traffic_method.in' => '流量重置方式格式有误',
'capacity_limit.integer' => '容纳用户量限制格式有误',
'speed_limit.integer' => '限速格式有误'
'name.max' => '套餐名称不能超过 255 个字符',
'transfer_enable.required' => '流量配额不能为空',
'transfer_enable.integer' => '流量配额必须是整数',
'transfer_enable.min' => '流量配额必须大于 0',
'prices.array' => '价格配置格式误',
'prices.*.numeric' => '价格必须是数字',
'prices.*.min' => '价格不能为负数',
'group_id.integer' => '权限组ID必须是整数',
'speed_limit.integer' => '速度限制必须是整数',
'speed_limit.min' => '速度限制不能为负数',
'device_limit.integer' => '设备限制必须是整数',
'device_limit.min' => '设备限制不能为负数',
'capacity_limit.integer' => '容量限制必须是整数',
'capacity_limit.min' => '容量限制不能为负数',
];
}
/**
* Handle a failed validation attempt.
*/
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(
response()->json([
'data' => false,
'message' => $validator->errors()->first(),
'errors' => $validator->errors()->toArray()
], 422)
);
}
}
+11
View File
@@ -16,6 +16,7 @@ use App\Http\Controllers\V2\Admin\KnowledgeController;
use App\Http\Controllers\V2\Admin\PaymentController;
use App\Http\Controllers\V2\Admin\SystemController;
use App\Http\Controllers\V2\Admin\ThemeController;
use App\Http\Controllers\V2\Admin\TrafficResetController;
use Illuminate\Contracts\Routing\Registrar;
class AdminRoute
@@ -229,6 +230,16 @@ class AdminRoute
$router->get('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'getConfig']);
$router->post('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'updateConfig']);
});
// 流量重置管理
$router->group([
'prefix' => 'traffic-reset'
], function ($router) {
$router->get('logs', [TrafficResetController::class, 'logs']);
$router->get('stats', [TrafficResetController::class, 'stats']);
$router->get('user/{userId}/history', [TrafficResetController::class, 'userHistory']);
$router->post('reset-user', [TrafficResetController::class, 'resetUser']);
});
});
}