feat: enhance plugin management

- Add command support for plugin management
- Optimize plugin management page layout
- Add email copy functionality for users
- Convert payment methods and Telegram Bot to plugin system
This commit is contained in:
xboard
2025-07-26 18:49:58 +08:00
parent 02d853d46a
commit 58868268dd
56 changed files with 3677 additions and 1329 deletions
+68
View File
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Services\Plugin\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter;
use App\Models\User;
@@ -15,6 +16,8 @@ use function Laravel\Prompts\confirm;
use function Laravel\Prompts\text;
use function Laravel\Prompts\note;
use function Laravel\Prompts\select;
use App\Models\Plugin;
use Illuminate\Support\Str;
class XboardInstall extends Command
{
@@ -157,6 +160,11 @@ class XboardInstall extends Command
if (!self::registerAdmin($email, $password)) {
abort(500, '管理员账号注册失败,请重试');
}
self::restoreProtectedPlugins($this);
$this->info('正在安装默认插件...');
PluginManager::installDefaultPlugins();
$this->info('默认插件安装完成');
$this->info('🎉:一切就绪');
$this->info("管理员邮箱:{$email}");
$this->info("管理员密码:{$password}");
@@ -356,4 +364,64 @@ class XboardInstall extends Command
}
}
}
/**
* 还原内置受保护插件(可在安装和更新时调用)
*/
public static function restoreProtectedPlugins(Command $console = null)
{
exec("git config core.filemode false", $output, $returnVar);
$cmd = "git status --porcelain plugins/ 2>/dev/null";
exec($cmd, $output, $returnVar);
if (!empty($output)) {
$hasNonNewFiles = false;
foreach ($output as $line) {
$status = trim(substr($line, 0, 2));
if ($status !== 'A') {
$hasNonNewFiles = true;
break;
}
}
if ($hasNonNewFiles) {
if ($console)
$console->info("检测到 plugins 目录有变更,正在还原...");
foreach ($output as $line) {
$status = trim(substr($line, 0, 2));
$filePath = trim(substr($line, 3));
if (strpos($filePath, 'plugins/') === 0 && $status !== 'A') {
$relativePath = substr($filePath, 8);
if ($console) {
$action = match ($status) {
'M' => '修改',
'D' => '删除',
'R' => '重命名',
'C' => '复制',
default => '变更'
};
$console->info("还原插件文件 [{$relativePath}] ({$action})");
}
$cmd = "git checkout HEAD -- {$filePath}";
exec($cmd, $gitOutput, $gitReturnVar);
if ($gitReturnVar === 0) {
if ($console)
$console->info("插件文件 [{$relativePath}] 已还原。");
} else {
if ($console)
$console->error("插件文件 [{$relativePath}] 还原失败。");
}
}
}
} else {
if ($console)
$console->info("plugins 目录状态正常,无需还原。");
}
} else {
if ($console)
$console->info("plugins 目录状态正常,无需还原。");
}
}
}
+10 -2
View File
@@ -6,7 +6,10 @@ use App\Services\ThemeService;
use App\Services\UpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use App\Services\Plugin\PluginManager;
use App\Models\Plugin;
use Illuminate\Support\Str;
use App\Console\Commands\XboardInstall;
class XboardUpdate extends Command
{
@@ -44,12 +47,17 @@ class XboardUpdate extends Command
$this->info('正在导入数据库请稍等...');
Artisan::call("migrate");
$this->info(Artisan::output());
Artisan::call('horizon:terminate');
$this->info('正在检查内置插件文件...');
XboardInstall::restoreProtectedPlugins($this);
$this->info('正在检查并安装默认插件...');
PluginManager::installDefaultPlugins();
$this->info('默认插件检查完成');
Artisan::call('reset:traffic', ['--fix-null' => true]);
$updateService = new UpdateService();
$updateService->updateVersionCache();
$themeService = app(ThemeService::class);
$themeService->refreshCurrentTheme();
Artisan::call('horizon:terminate');
$this->info('更新完毕,队列服务已重启,你无需进行任何操作。');
}
}
+4
View File
@@ -64,6 +64,10 @@ class Kernel extends ConsoleKernel
{
$this->load(__DIR__ . '/Commands');
try {
app(PluginManager::class)->initializeEnabledPlugins();
} catch (\Exception $e) {
}
require base_path('routes/console.php');
}
}
+11 -8
View File
@@ -11,13 +11,16 @@ use App\Traits\HasPluginConfig;
*/
abstract class PluginController extends Controller
{
use HasPluginConfig;
use HasPluginConfig;
/**
* 执行插件操作前的检查
*/
protected function beforePluginAction(): ?array
{
return null;
}
/**
* 执行插件操作前的检查
*/
protected function beforePluginAction(): ?array
{
if (!$this->isPluginEnabled()) {
return [400, '插件未启用'];
}
return null;
}
}
@@ -2,14 +2,12 @@
namespace App\Http\Controllers\V1\Guest;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Services\OrderService;
use App\Services\PaymentService;
use App\Services\TelegramService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Services\Plugin\HookManager;
class PaymentController extends Controller
@@ -30,7 +28,7 @@ class PaymentController extends Controller
}
return (isset($verify['custom_result']) ? $verify['custom_result'] : 'success');
} catch (\Exception $e) {
\Log::error($e);
Log::error($e);
return $this->fail([500, 'fail']);
}
}
@@ -48,22 +46,7 @@ class PaymentController extends Controller
return false;
}
$payment = Payment::where('id', $order->payment_id)->first();
$telegramService = new TelegramService();
$message = sprintf(
"💰成功收款%s元\n" .
"———————————————\n" .
"支付接口:%s\n" .
"支付渠道:%s\n" .
"本站订单:`%s`"
,
$order->total_amount / 100,
$payment->payment,
$payment->name,
$order->trade_no
);
$telegramService->sendMessageWithAdmin($message);
HookManager::call('payment.notify.success', $order);
return true;
}
}
@@ -4,121 +4,123 @@ namespace App\Http\Controllers\V1\Guest;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Services\TelegramService;
use App\Services\UserService;
use Illuminate\Http\Request;
class TelegramController extends Controller
{
protected $msg;
protected $commands = [];
protected $telegramService;
protected ?object $msg = null;
protected TelegramService $telegramService;
protected UserService $userService;
public function __construct(TelegramService $telegramService)
public function __construct(TelegramService $telegramService, UserService $userService)
{
$this->telegramService = $telegramService;
$this->userService = $userService;
}
public function webhook(Request $request)
public function webhook(Request $request): void
{
if ($request->input('access_token') !== md5(admin_setting('telegram_bot_token'))) {
$expectedToken = md5(admin_setting('telegram_bot_token'));
if ($request->input('access_token') !== $expectedToken) {
throw new ApiException('access_token is error', 401);
}
$data = json_decode(request()->getContent(),true);
$data = $request->json()->all();
$this->formatMessage($data);
$this->formatChatJoinRequest($data);
$this->handle();
}
private function handle()
private function handle(): void
{
if (!$this->msg) return;
if (!$this->msg)
return;
$msg = $this->msg;
$commandName = explode('@', $msg->command);
// To reduce request, only commands contains @ will get the bot name
if (count($commandName) == 2) {
$botName = $this->getBotName();
if ($commandName[1] === $botName){
$msg->command = $commandName[0];
}
}
$this->processBotName($msg);
try {
foreach (glob(base_path('app//Plugins//Telegram//Commands') . '/*.php') as $file) {
$command = basename($file, '.php');
$class = '\\App\\Plugins\\Telegram\\Commands\\' . $command;
if (!class_exists($class)) continue;
$instance = new $class();
if ($msg->message_type === 'message') {
if (!isset($instance->command)) continue;
if ($msg->command !== $instance->command) continue;
$instance->handle($msg);
return;
}
if ($msg->message_type === 'reply_message') {
if (!isset($instance->regex)) continue;
if (!preg_match($instance->regex, $msg->reply_text, $match)) continue;
$instance->handle($msg, $match);
return;
}
HookManager::call('telegram.message.before', [$msg]);
$handled = HookManager::filter('telegram.message.handle', false, [$msg]);
if (!$handled) {
HookManager::call('telegram.message.unhandled', [$msg]);
}
HookManager::call('telegram.message.after', [$msg]);
} catch (\Exception $e) {
HookManager::call('telegram.message.error', [$msg, $e]);
$this->telegramService->sendMessage($msg->chat_id, $e->getMessage());
}
}
private function getBotName()
private function processBotName(object $msg): void
{
$commandParts = explode('@', $msg->command);
if (count($commandParts) === 2) {
$botName = $this->getBotName();
if ($commandParts[1] === $botName) {
$msg->command = $commandParts[0];
}
}
}
private function getBotName(): string
{
$response = $this->telegramService->getMe();
return $response->result->username;
}
private function formatMessage(array $data)
private function formatMessage(array $data): void
{
if (!isset($data['message'])) return;
if (!isset($data['message']['text'])) return;
$obj = new \StdClass();
$text = explode(' ', $data['message']['text']);
$obj->command = $text[0];
$obj->args = array_slice($text, 1);
$obj->chat_id = $data['message']['chat']['id'];
$obj->message_id = $data['message']['message_id'];
$obj->message_type = 'message';
$obj->text = $data['message']['text'];
$obj->is_private = $data['message']['chat']['type'] === 'private';
if (isset($data['message']['reply_to_message']['text'])) {
$obj->message_type = 'reply_message';
$obj->reply_text = $data['message']['reply_to_message']['text'];
if (!isset($data['message']['text']))
return;
$message = $data['message'];
$text = explode(' ', $message['text']);
$this->msg = (object) [
'command' => $text[0],
'args' => array_slice($text, 1),
'chat_id' => $message['chat']['id'],
'message_id' => $message['message_id'],
'message_type' => 'message',
'text' => $message['text'],
'is_private' => $message['chat']['type'] === 'private',
];
if (isset($message['reply_to_message']['text'])) {
$this->msg->message_type = 'reply_message';
$this->msg->reply_text = $message['reply_to_message']['text'];
}
$this->msg = $obj;
}
private function formatChatJoinRequest(array $data)
private function formatChatJoinRequest(array $data): void
{
if (!isset($data['chat_join_request'])) return;
if (!isset($data['chat_join_request']['from']['id'])) return;
if (!isset($data['chat_join_request']['chat']['id'])) return;
$user = \App\Models\User::where('telegram_id', $data['chat_join_request']['from']['id'])
->first();
$joinRequest = $data['chat_join_request'] ?? null;
if (!$joinRequest)
return;
$chatId = $joinRequest['chat']['id'] ?? null;
$userId = $joinRequest['from']['id'] ?? null;
if (!$chatId || !$userId)
return;
$user = User::where('telegram_id', $userId)->first();
if (!$user) {
$this->telegramService->declineChatJoinRequest(
$data['chat_join_request']['chat']['id'],
$data['chat_join_request']['from']['id']
);
$this->telegramService->declineChatJoinRequest($chatId, $userId);
return;
}
$userService = new \App\Services\UserService();
if (!$userService->isAvailable($user)) {
$this->telegramService->declineChatJoinRequest(
$data['chat_join_request']['chat']['id'],
$data['chat_join_request']['from']['id']
);
if (!$this->userService->isAvailable($user)) {
$this->telegramService->declineChatJoinRequest($chatId, $userId);
return;
}
$userService = new \App\Services\UserService();
$this->telegramService->approveChatJoinRequest(
$data['chat_join_request']['chat']['id'],
$data['chat_join_request']['from']['id']
);
$this->telegramService->approveChatJoinRequest($chatId, $userId);
}
}
+38 -108
View File
@@ -9,12 +9,11 @@ use App\Http\Resources\TicketResource;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\User;
use App\Services\TelegramService;
use App\Services\TicketService;
use App\Utils\Dict;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Services\Plugin\HookManager;
use Illuminate\Support\Facades\Log;
class TicketController extends Controller
{
@@ -42,39 +41,20 @@ class TicketController extends Controller
public function save(TicketSave $request)
{
try{
DB::beginTransaction();
if (Ticket::where('status', 0)->where('user_id', $request->user()->id)->lockForUpdate()->first()) {
DB::rollBack();
return $this->fail([400, '存在未关闭的工单']);
}
$ticket = Ticket::create(array_merge($request->only([
'subject',
'level'
]), [
'user_id' => $request->user()->id
]));
if (!$ticket) {
throw new \Exception(__('There are other unresolved tickets'));
}
$ticketMessage = TicketMessage::create([
'user_id' => $request->user()->id,
'ticket_id' => $ticket->id,
'message' => $request->input('message')
]);
if (!$ticketMessage) {
throw new \Exception(__('Failed to open ticket'));
}
DB::commit();
try {
$ticketService = new TicketService();
$ticket = $ticketService->createTicket(
$request->user()->id,
$request->input('subject'),
$request->input('level'),
$request->input('message')
);
HookManager::call('ticket.create.after', $ticket);
$this->sendNotify($ticket, $request->input('message'), $request->user()->id);
return $this->success(true);
}catch(\Exception $e){
DB::rollBack();
\Log::error($e);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([400, $e->getMessage()]);
}
}
public function reply(Request $request)
@@ -95,18 +75,19 @@ class TicketController extends Controller
return $this->fail([400, __('The ticket is closed and cannot be replied')]);
}
if ($request->user()->id == $this->getLastMessage($ticket->id)->user_id) {
return $this->fail([400, __('Please wait for the technical enginneer to reply')]);
return $this->fail(codeResponse: [400, __('Please wait for the technical enginneer to reply')]);
}
$ticketService = new TicketService();
if (!$ticketService->reply(
$ticket,
$request->input('message'),
$request->user()->id
)) {
if (
!$ticketService->reply(
$ticket,
$request->input('message'),
$request->user()->id
)
) {
return $this->fail([400, __('Ticket reply failed')]);
}
HookManager::call('ticket.reply.user.after', [$ticket, $this->getLastMessage($ticket->id)]);
$this->sendNotify($ticket, $request->input('message'), $request->user()->id);
return $this->success(true);
}
@@ -138,13 +119,15 @@ class TicketController extends Controller
public function withdraw(TicketWithdraw $request)
{
if ((int)admin_setting('withdraw_close_enable', 0)) {
if ((int) admin_setting('withdraw_close_enable', 0)) {
return $this->fail([400, 'Unsupported withdraw']);
}
if (!in_array(
$request->input('withdraw_method'),
admin_setting('commission_withdraw_method',Dict::WITHDRAW_METHOD_WHITELIST_DEFAULT)
)) {
if (
!in_array(
$request->input('withdraw_method'),
admin_setting('commission_withdraw_method', Dict::WITHDRAW_METHOD_WHITELIST_DEFAULT)
)
) {
return $this->fail([422, __('Unsupported withdrawal method')]);
}
$user = User::find($request->user()->id);
@@ -152,77 +135,24 @@ class TicketController extends Controller
if ($limit > ($user->commission_balance / 100)) {
return $this->fail([422, __('The current required minimum withdrawal commission is :limit', ['limit' => $limit])]);
}
try{
DB::beginTransaction();
try {
$ticketService = new TicketService();
$subject = __('[Commission Withdrawal Request] This ticket is opened by the system');
$ticket = Ticket::create([
'subject' => $subject,
'level' => 2,
'user_id' => $request->user()->id
]);
if (!$ticket) {
return $this->fail([400, __('Failed to open ticket')]);
}
$message = sprintf("%s\r\n%s",
$message = sprintf(
"%s\r\n%s",
__('Withdrawal method') . "" . $request->input('withdraw_method'),
__('Withdrawal account') . "" . $request->input('withdraw_account')
);
$ticketMessage = TicketMessage::create([
'user_id' => $request->user()->id,
'ticket_id' => $ticket->id,
'message' => $message
]);
if (!$ticketMessage) {
DB::rollBack();
return $this->fail([400, __('Failed to open ticket')]);
}
DB::commit();
}catch(\Exception $e){
DB::rollBack();
$ticket = $ticketService->createTicket(
$request->user()->id,
$subject,
2,
$message
);
} catch (\Exception $e) {
throw $e;
}
$this->sendNotify($ticket, $message, $request->user()->id);
HookManager::call('ticket.create.after', $ticket);
return $this->success(true);
}
private function sendNotify(Ticket $ticket, string $message, $user_id)
{
$user = User::find($user_id)->load('plan');
$transfer_enable = $this->getFlowData($user->transfer_enable); // 总流量
$remaining_traffic = $this->getFlowData($user->transfer_enable - $user->u - $user->d); // 剩余流量
$u = $this->getFlowData($user->u); // 上传
$d = $this->getFlowData($user->d); // 下载
$expired_at = date("Y-m-d h:m:s", $user->expired_at); // 到期时间
$money = $user->balance / 100;
$affmoney = $user->commission_balance / 100;
$plan = $user->plan;
$ip = request()->ip();
$region = filter_var($ip,FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? (new \Ip2Region())->simple($ip) : "NULL";
$TGmessage = "📮工单提醒 #{$ticket->id}\n———————————————\n";
$TGmessage .= "邮箱: `{$user->email}`\n";
$TGmessage .= "用户位置: \n`{$region}`\n";
if($user->plan){
$TGmessage .= "套餐与流量: \n`{$plan->name} {$transfer_enable}/{$remaining_traffic}`\n";
$TGmessage .= "上传/下载: \n`{$u}/{$d}`\n";
$TGmessage .= "到期时间: \n`{$expired_at}`\n";
}else{
$TGmessage .= "套餐与流量: \n`未订购任何套餐`\n";
}
$TGmessage .= "余额/佣金余额: \n`{$money}/{$affmoney}`\n";
$TGmessage .= "主题:\n`{$ticket->subject}`\n内容:\n`{$message}`\n";
$telegramService = new TelegramService();
$telegramService->sendMessageWithAdmin($TGmessage, true);
}
private function getFlowData($b)
{
$m = $b / (1024 * 1024);
if ($m >= 1024) {
$g = $m / 1024;
$text = round($g, 2) . "GB";
} else {
$text = round($m, 2) . "MB";
}
return $text;
}
}
@@ -71,7 +71,6 @@ class ConfigController extends Controller
public function setTelegramWebhook(Request $request)
{
// 判断站点网址
$app_url = admin_setting('app_url');
if (blank($app_url))
return $this->fail([422, '请先设置站点网址']);
@@ -81,17 +80,14 @@ class ConfigController extends Controller
$telegramService = new TelegramService($request->input('telegram_bot_token'));
$telegramService->getMe();
$telegramService->setWebhook($hookUrl);
$telegramService->registerBotCommands();
return $this->success(true);
}
public function fetch(Request $request)
{
$key = $request->input('key');
// 构建配置数据映射
$configMappings = $this->getConfigMappings();
// 如果请求特定分组,直接返回
if ($key && isset($configMappings[$key])) {
return $this->success([$key => $configMappings[$key]]);
}
@@ -9,16 +9,18 @@ use App\Services\PaymentService;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PaymentController extends Controller
{
public function getPaymentMethods()
{
$methods = [];
foreach (glob(base_path('app//Payments') . '/*.php') as $file) {
array_push($methods, pathinfo($file)['filename']);
}
return $this->success($methods);
$pluginMethods = PaymentService::getAllPaymentMethodNames();
$methods = array_merge($methods, $pluginMethods);
return $this->success(array_unique($methods));
}
public function fetch()
@@ -37,23 +39,29 @@ class PaymentController extends Controller
public function getPaymentForm(Request $request)
{
$paymentService = new PaymentService($request->input('payment'), $request->input('id'));
return $this->success(collect($paymentService->form())->values());
try {
$paymentService = new PaymentService($request->input('payment'), $request->input('id'));
return $this->success(collect($paymentService->form()));
} catch (\Exception $e) {
return $this->fail([400, '支付方式不存在或未启用']);
}
}
public function show(Request $request)
{
$payment = Payment::find($request->input('id'));
if (!$payment) return $this->fail([400202 ,'支付方式不存在']);
if (!$payment)
return $this->fail([400202, '支付方式不存在']);
$payment->enable = !$payment->enable;
if (!$payment->save()) return $this->fail([500 ,'保存失败']);
if (!$payment->save())
return $this->fail([500, '保存失败']);
return $this->success(true);
}
public function save(Request $request)
{
if (!admin_setting('app_url')) {
return $this->fail([400 ,'请在站点配置中配置站点地址']);
return $this->fail([400, '请在站点配置中配置站点地址']);
}
$params = $request->validate([
'name' => 'required',
@@ -73,18 +81,19 @@ class PaymentController extends Controller
]);
if ($request->input('id')) {
$payment = Payment::find($request->input('id'));
if (!$payment) return $this->fail([400202 ,'支付方式不存在']);
if (!$payment)
return $this->fail([400202, '支付方式不存在']);
try {
$payment->update($params);
} catch (\Exception $e) {
\Log::error($e);
return $this->fail([500 ,'保存失败']);
Log::error($e);
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
$params['uuid'] = Helper::randomChar(8);
if (!Payment::create($params)) {
return $this->fail([500 ,'保存失败']);
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
@@ -92,7 +101,8 @@ class PaymentController extends Controller
public function drop(Request $request)
{
$payment = Payment::find($request->input('id'));
if (!$payment) return $this->fail([400202 ,'支付方式不存在']);
if (!$payment)
return $this->fail([400202, '支付方式不存在']);
return $this->success($payment->delete());
}
@@ -105,7 +115,7 @@ class PaymentController extends Controller
'ids.required' => '参数有误',
'ids.array' => '参数有误'
]);
try{
try {
DB::beginTransaction();
foreach ($request->input('ids') as $k => $v) {
if (!Payment::find($v)->update(['sort' => $k + 1])) {
@@ -113,11 +123,11 @@ class PaymentController extends Controller
}
}
DB::commit();
}catch(\Exception $e){
} catch (\Exception $e) {
DB::rollBack();
return $this->fail([500 ,'保存失败']);
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
}
@@ -23,14 +23,43 @@ class PluginController extends Controller
$this->configService = $configService;
}
/**
* 获取所有插件类型
*/
public function types()
{
return response()->json([
'data' => [
[
'value' => Plugin::TYPE_FEATURE,
'label' => '功能',
'description' => '提供功能扩展的插件,如Telegram登录、邮件通知等',
'icon' => '🔧'
],
[
'value' => Plugin::TYPE_PAYMENT,
'label' => '支付方式',
'description' => '提供支付接口的插件,如支付宝、微信支付等',
'icon' => '💳'
]
]
]);
}
/**
* 获取插件列表
*/
public function index()
public function index(Request $request)
{
$installedPlugins = Plugin::get()
$type = $request->query('type');
$installedPlugins = Plugin::when($type, function($query) use ($type) {
return $query->byType($type);
})
->get()
->keyBy('code')
->toArray();
$pluginPath = base_path('plugins');
$plugins = [];
@@ -42,8 +71,14 @@ class PluginController extends Controller
if (File::exists($configFile)) {
$config = json_decode(File::get($configFile), true);
$code = $config['code'];
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
// 如果指定了类型,过滤插件
if ($type && $pluginType !== $type) {
continue;
}
$installed = isset($installedPlugins[$code]);
// 使用配置服务获取配置
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
$readmeFile = collect(['README.md', 'readme.md'])
->map(fn($f) => $directory . '/' . $f)
@@ -56,8 +91,11 @@ class PluginController extends Controller
'version' => $config['version'],
'description' => $config['description'],
'author' => $config['author'],
'type' => $pluginType,
'is_installed' => $installed,
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
'is_protected' => in_array($code, Plugin::PROTECTED_PLUGINS),
'can_be_deleted' => !in_array($code, Plugin::PROTECTED_PLUGINS),
'config' => $pluginConfig,
'readme' => $readmeContent,
];
@@ -236,8 +274,17 @@ class PluginController extends Controller
'code' => 'required|string'
]);
$code = $request->input('code');
// 检查是否为受保护的插件
if (in_array($code, Plugin::PROTECTED_PLUGINS)) {
return response()->json([
'message' => '该插件为系统默认插件,不允许删除'
], 403);
}
try {
$this->pluginManager->delete($request->input('code'));
$this->pluginManager->delete($code);
return response()->json([
'message' => '插件删除成功'
]);
@@ -164,7 +164,6 @@ class UserController extends Controller
$users = $userModel->orderBy('id', 'desc')
->paginate($pageSize, ['*'], 'page', $current);
/** @phpstan-ignore-next-line */
$users->getCollection()->transform(function ($user): array {
return self::transformUserData($user);
});
+1
View File
@@ -247,6 +247,7 @@ class AdminRoute
$router->group([
'prefix' => 'plugin'
], function ($router) {
$router->get('/types', [\App\Http\Controllers\V2\Admin\PluginController::class, 'types']);
$router->get('/getPlugins', [\App\Http\Controllers\V2\Admin\PluginController::class, 'index']);
$router->post('/upload', [\App\Http\Controllers\V2\Admin\PluginController::class, 'upload']);
$router->post('/delete', [\App\Http\Controllers\V2\Admin\PluginController::class, 'delete']);
+45 -1
View File
@@ -3,6 +3,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
/**
* @property int $id
@@ -16,11 +18,27 @@ use Illuminate\Database\Eloquent\Model;
* @property string $license
* @property string $requires
* @property string $config
* @property string $type
*/
class Plugin extends Model
{
protected $table = 'v2_plugins';
const TYPE_FEATURE = 'feature';
const TYPE_PAYMENT = 'payment';
// 默认不可删除的插件列表
const PROTECTED_PLUGINS = [
'epay', // EPay
'alipay_f2f', // Alipay F2F
'btcpay', // BTCPay
'coinbase', // Coinbase
'coin_payments', // CoinPayments
'mgate', // MGate
'smogate', // Smogate
'telegram', // Telegram
];
protected $guarded = [
'id',
'created_at',
@@ -28,6 +46,32 @@ class Plugin extends Model
];
protected $casts = [
'is_enabled' => 'boolean'
'is_enabled' => 'boolean',
];
public function scopeByType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
public function isFeaturePlugin(): bool
{
return $this->type === self::TYPE_FEATURE;
}
public function isPaymentPlugin(): bool
{
return $this->type === self::TYPE_PAYMENT;
}
public function isProtected(): bool
{
return in_array($this->code, self::PROTECTED_PLUGINS);
}
public function canBeDeleted(): bool
{
return !$this->isProtected();
}
}
+1
View File
@@ -40,6 +40,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int|null $is_admin 是否管理员
* @property int|null $next_reset_at 下次流量重置时间
* @property int|null $last_reset_at 上次流量重置时间
* @property int|null $telegram_id Telegram ID
* @property int $reset_count 流量重置次数
* @property int $created_at
* @property int $updated_at
-9
View File
@@ -1,9 +0,0 @@
*
!.gitignore
!AlipayF2F.php
!BTCPay.php
!Coinbase.php
!CoinPayments.php
!EPay.php
!MGate.php
-99
View File
@@ -1,99 +0,0 @@
<?php
/**
* 自己写别抄,抄NMB抄
*/
namespace App\Payments;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class AlipayF2F implements PaymentInterface
{
protected $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'app_id' => [
'label' => '支付宝APPID',
'description' => '',
'type' => 'input',
],
'private_key' => [
'label' => '支付宝私钥',
'description' => '',
'type' => 'input',
],
'public_key' => [
'label' => '支付宝公钥',
'description' => '',
'type' => 'input',
],
'product_name' => [
'label' => '自定义商品名称',
'description' => '将会体现在支付宝账单中',
'type' => 'input'
]
];
}
public function pay($order): array
{
try {
$gateway = new \Library\AlipayF2F();
$gateway->setMethod('alipay.trade.precreate');
$gateway->setAppId($this->config['app_id']);
$gateway->setPrivateKey($this->config['private_key']); // 可以是路径,也可以是密钥内容
$gateway->setAlipayPublicKey($this->config['public_key']); // 可以是路径,也可以是密钥内容
$gateway->setNotifyUrl($order['notify_url']);
$gateway->setBizContent([
'subject' => $this->config['product_name'] ?? (admin_setting('app_name', 'XBoard') . ' - 订阅'),
'out_trade_no' => $order['trade_no'],
'total_amount' => $order['total_amount'] / 100
]);
$gateway->send();
return [
'type' => 0, // 0:qrcode 1:url
'data' => $gateway->getQrCodeUrl()
];
} catch (\Exception $e) {
\Log::error($e);
throw new ApiException($e->getMessage());
}
}
public function notify($params)
{
if ($params['trade_status'] !== 'TRADE_SUCCESS')
return false;
$gateway = new \Library\AlipayF2F();
$gateway->setAppId($this->config['app_id']);
$gateway->setPrivateKey($this->config['private_key']); // 可以是路径,也可以是密钥内容
$gateway->setAlipayPublicKey($this->config['public_key']); // 可以是路径,也可以是密钥内容
try {
if ($gateway->verify($params)) {
/**
* Payment is successful
*/
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
} else {
/**
* Payment is not successful
*/
return false;
}
} catch (\Exception $e) {
/**
* Payment is not successful
*/
return false;
}
}
}
-153
View File
@@ -1,153 +0,0 @@
<?php
namespace App\Payments;
use App\Exceptions\ApiException;
use App\Contracts\PaymentInterface;
class BTCPay implements PaymentInterface
{
protected $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'btcpay_url' => [
'label' => 'API接口所在网址(包含最后的斜杠)',
'description' => '',
'type' => 'input',
],
'btcpay_storeId' => [
'label' => 'storeId',
'description' => '',
'type' => 'input',
],
'btcpay_api_key' => [
'label' => 'API KEY',
'description' => '个人设置中的API KEY(非商店设置中的)',
'type' => 'input',
],
'btcpay_webhook_key' => [
'label' => 'WEBHOOK KEY',
'description' => '',
'type' => 'input',
],
];
}
public function pay($order): array
{
$params = [
'jsonResponse' => true,
'amount' => sprintf('%.2f', $order['total_amount'] / 100),
'currency' => 'CNY',
'metadata' => [
'orderId' => $order['trade_no']
]
];
$params_string = @json_encode($params);
$ret_raw = self::_curlPost($this->config['btcpay_url'] . 'api/v1/stores/' . $this->config['btcpay_storeId'] . '/invoices', $params_string);
$ret = @json_decode($ret_raw, true);
if (empty($ret['checkoutLink'])) {
throw new ApiException("error!");
}
return [
'type' => 1, // Redirect to url
'data' => $ret['checkoutLink'],
];
}
public function notify($params): array|bool
{
$payload = trim(request()->getContent());
$headers = getallheaders();
//IS Btcpay-Sig
//NOT BTCPay-Sig
//API doc is WRONG!
$headerName = 'Btcpay-Sig';
$signraturHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$json_param = json_decode($payload, true);
$computedSignature = "sha256=" . \hash_hmac('sha256', $payload, $this->config['btcpay_webhook_key']);
if (!self::hashEqual($signraturHeader, $computedSignature)) {
throw new ApiException('HMAC signature does not match', 400);
}
//get order id store in metadata
$context = stream_context_create(array(
'http' => array(
'method' => 'GET',
'header' => "Authorization:" . "token " . $this->config['btcpay_api_key'] . "\r\n"
)
));
$invoiceDetail = file_get_contents($this->config['btcpay_url'] . 'api/v1/stores/' . $this->config['btcpay_storeId'] . '/invoices/' . $json_param['invoiceId'], false, $context);
$invoiceDetail = json_decode($invoiceDetail, true);
$out_trade_no = $invoiceDetail['metadata']["orderId"];
$pay_trade_no = $json_param['invoiceId'];
return [
'trade_no' => $out_trade_no,
'callback_no' => $pay_trade_no
];
}
private function _curlPost($url, $params = false)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
array('Authorization:' . 'token ' . $this->config['btcpay_api_key'], 'Content-Type: application/json')
);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* @param string $str1
* @param string $str2
* @return bool
*/
private function hashEqual($str1, $str2)
{
if (function_exists('hash_equals')) {
return \hash_equals($str1, $str2);
}
if (strlen($str1) != strlen($str2)) {
return false;
} else {
$res = $str1 ^ $str2;
$ret = 0;
for ($i = strlen($res) - 1; $i >= 0; $i--) {
$ret |= ord($res[$i]);
}
return !$ret;
}
}
}
-110
View File
@@ -1,110 +0,0 @@
<?php
namespace App\Payments;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class CoinPayments implements PaymentInterface
{
protected $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'coinpayments_merchant_id' => [
'label' => 'Merchant ID',
'description' => '商户 ID,填写您在 Account Settings 中得到的 ID',
'type' => 'input',
],
'coinpayments_ipn_secret' => [
'label' => 'IPN Secret',
'description' => '通知密钥,填写您在 Merchant Settings 中自行设置的值',
'type' => 'input',
],
'coinpayments_currency' => [
'label' => '货币代码',
'description' => '填写您的货币代码(大写),建议与 Merchant Settings 中的值相同',
'type' => 'input',
]
];
}
public function pay($order): array
{
// IPN notifications are slow, when the transaction is successful, we should return to the user center to avoid user confusion
$parseUrl = parse_url($order['return_url']);
$port = isset($parseUrl['port']) ? ":{$parseUrl['port']}" : '';
$successUrl = "{$parseUrl['scheme']}://{$parseUrl['host']}{$port}";
$params = [
'cmd' => '_pay_simple',
'reset' => 1,
'merchant' => $this->config['coinpayments_merchant_id'],
'item_name' => $order['trade_no'],
'item_number' => $order['trade_no'],
'want_shipping' => 0,
'currency' => $this->config['coinpayments_currency'],
'amountf' => sprintf('%.2f', $order['total_amount'] / 100),
'success_url' => $successUrl,
'cancel_url' => $order['return_url'],
'ipn_url' => $order['notify_url']
];
$params_string = http_build_query($params);
return [
'type' => 1, // Redirect to url
'data' => 'https://www.coinpayments.net/index.php?' . $params_string
];
}
public function notify($params)
{
if (!isset($params['merchant']) || $params['merchant'] != trim($this->config['coinpayments_merchant_id'])) {
throw new ApiException('No or incorrect Merchant ID passed');
}
$headers = getallheaders();
ksort($params);
reset($params);
$request = stripslashes(http_build_query($params));
$headerName = 'Hmac';
$signHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$hmac = hash_hmac("sha512", $request, trim($this->config['coinpayments_ipn_secret']));
// if ($hmac != $signHeader) { <-- Use this if you are running a version of PHP below 5.6.0 without the hash_equals function
// throw new ApiException(400, 'HMAC signature does not match');
// }
if (!hash_equals($hmac, $signHeader)) {
throw new ApiException('HMAC signature does not match', 400);
}
// HMAC Signature verified at this point, load some variables.
$status = $params['status'];
if ($status >= 100 || $status == 2) {
// payment is complete or queued for nightly payout, success
return [
'trade_no' => $params['item_number'],
'callback_no' => $params['txn_id'],
'custom_result' => 'IPN OK'
];
} else if ($status < 0) {
//payment error, this is usually final but payments will sometimes be reopened if there was no exchange rate conversion or with seller consent
throw new ApiException('Payment Timed Out or Error');
} else {
//payment is pending, you can optionally add a note to the order page
return ('IPN OK: pending');
}
}
}
-136
View File
@@ -1,136 +0,0 @@
<?php
namespace App\Payments;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class Coinbase implements PaymentInterface
{
protected $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'coinbase_url' => [
'label' => '接口地址',
'description' => '',
'type' => 'input',
],
'coinbase_api_key' => [
'label' => 'API KEY',
'description' => '',
'type' => 'input',
],
'coinbase_webhook_key' => [
'label' => 'WEBHOOK KEY',
'description' => '',
'type' => 'input',
],
];
}
public function pay($order): array
{
$params = [
'name' => '订阅套餐',
'description' => '订单号 ' . $order['trade_no'],
'pricing_type' => 'fixed_price',
'local_price' => [
'amount' => sprintf('%.2f', $order['total_amount'] / 100),
'currency' => 'CNY'
],
'metadata' => [
"outTradeNo" => $order['trade_no'],
],
];
$params_string = http_build_query($params);
$ret_raw = self::_curlPost($this->config['coinbase_url'], $params_string);
$ret = @json_decode($ret_raw, true);
if (empty($ret['data']['hosted_url'])) {
throw new ApiException("error!");
}
return [
'type' => 1,
'data' => $ret['data']['hosted_url'],
];
}
public function notify($params): array
{
$payload = trim(request()->getContent());
$json_param = json_decode($payload, true);
$headerName = 'X-Cc-Webhook-Signature';
$headers = getallheaders();
$signatureHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$computedSignature = \hash_hmac('sha256', $payload, $this->config['coinbase_webhook_key']);
if (!self::hashEqual($signatureHeader, $computedSignature)) {
throw new ApiException('HMAC signature does not match', 400);
}
$out_trade_no = $json_param['event']['data']['metadata']['outTradeNo'];
$pay_trade_no = $json_param['event']['id'];
return [
'trade_no' => $out_trade_no,
'callback_no' => $pay_trade_no
];
}
private function _curlPost($url, $params = false)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
array('X-CC-Api-Key:' . $this->config['coinbase_api_key'], 'X-CC-Version: 2018-03-22')
);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* @param string $str1
* @param string $str2
* @return bool
*/
public function hashEqual($str1, $str2)
{
if (function_exists('hash_equals')) {
return \hash_equals($str1, $str2);
}
if (strlen($str1) != strlen($str2)) {
return false;
} else {
$res = $str1 ^ $str2;
$ret = 0;
for ($i = strlen($res) - 1; $i >= 0; $i--) {
$ret |= ord($res[$i]);
}
return !$ret;
}
}
}
-81
View File
@@ -1,81 +0,0 @@
<?php
namespace App\Payments;
use App\Contracts\PaymentInterface;
class EPay implements PaymentInterface
{
protected $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'url' => [
'label' => 'URL',
'description' => '',
'type' => 'input',
],
'pid' => [
'label' => 'PID',
'description' => '',
'type' => 'input',
],
'key' => [
'label' => 'KEY',
'description' => '',
'type' => 'input',
],
'type' => [
'label' => 'TYPE',
'description' => 'alipay / qqpay / wxpay',
'type' => 'input',
],
];
}
public function pay($order): array
{
$params = [
'money' => $order['total_amount'] / 100,
'name' => $order['trade_no'],
'notify_url' => $order['notify_url'],
'return_url' => $order['return_url'],
'out_trade_no' => $order['trade_no'],
'pid' => $this->config['pid']
];
if (optional($this->config)['type']) {
$params['type'] = $this->config['type'];
}
ksort($params);
reset($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->config['key'];
$params['sign'] = md5($str);
$params['sign_type'] = 'MD5';
return [
'type' => 1, // 0:qrcode 1:url
'data' => $this->config['url'] . '/submit.php?' . http_build_query($params)
];
}
public function notify($params): array|bool
{
$sign = $params['sign'];
unset($params['sign']);
unset($params['sign_type']);
ksort($params);
reset($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->config['key'];
if ($sign !== md5($str)) {
return false;
}
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
}
}
-104
View File
@@ -1,104 +0,0 @@
<?php
/**
* 自己写别抄,抄NMB抄
*/
namespace App\Payments;
use App\Exceptions\ApiException;
use \Curl\Curl;
use App\Contracts\PaymentInterface;
class MGate implements PaymentInterface
{
private $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'mgate_url' => [
'label' => 'API地址',
'description' => '',
'type' => 'input',
],
'mgate_app_id' => [
'label' => 'APPID',
'description' => '',
'type' => 'input',
],
'mgate_app_secret' => [
'label' => 'AppSecret',
'description' => '',
'type' => 'input',
],
'mgate_source_currency' => [
'label' => '源货币',
'description' => '默认CNY',
'type' => 'input'
]
];
}
public function pay($order): array
{
$params = [
'out_trade_no' => $order['trade_no'],
'total_amount' => $order['total_amount'],
'notify_url' => $order['notify_url'],
'return_url' => $order['return_url']
];
if (isset($this->config['mgate_source_currency'])) {
$params['source_currency'] = $this->config['mgate_source_currency'];
}
$params['app_id'] = $this->config['mgate_app_id'];
ksort($params);
$str = http_build_query($params) . $this->config['mgate_app_secret'];
$params['sign'] = md5($str);
$curl = new Curl();
$curl->setUserAgent('MGate');
$curl->setOpt(CURLOPT_SSL_VERIFYPEER, 0);
$curl->post($this->config['mgate_url'] . '/v1/gateway/fetch', http_build_query($params));
$result = $curl->response;
if (!$result) {
throw new ApiException('网络异常');
}
if ($curl->error) {
if (isset($result->errors)) {
$errors = (array) $result->errors;
throw new ApiException($errors[array_keys($errors)[0]][0]);
}
if (isset($result->message)) {
throw new ApiException($result->message);
}
throw new ApiException('未知错误');
}
$curl->close();
if (!isset($result->data->trade_no)) {
throw new ApiException('接口请求失败');
}
return [
'type' => 1, // 0:qrcode 1:url
'data' => $result->data->pay_url
];
}
public function notify($params): array|bool
{
$sign = $params['sign'];
unset($params['sign']);
ksort($params);
reset($params);
$str = http_build_query($params) . $this->config['mgate_app_secret'];
if ($sign !== md5($str)) {
return false;
}
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
}
}
-50
View File
@@ -1,50 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
class Bind extends Telegram {
public $command = '/bind';
public $description = '将Telegram账号绑定到网站';
public function handle($message, $match = []) {
if (!$message->is_private) return;
if (!isset($message->args[0])) {
throw new ApiException('参数有误,请携带订阅地址发送', 422);
}
$subscribeUrl = $message->args[0];
$subscribeUrl = parse_url($subscribeUrl);
// 首先尝试从查询参数获取token
$token = null;
if (isset($subscribeUrl['query'])) {
parse_str($subscribeUrl['query'], $query);
$token = $query['token'] ?? null;
}
if (!$token && isset($subscribeUrl['path'])) {
$pathParts = explode('/', trim($subscribeUrl['path'], '/'));
$token = end($pathParts);
}
if (!$token) {
throw new ApiException('订阅地址无效');
}
$user = User::where('token', $token)->first();
if (!$user) {
throw new ApiException('用户不存在');
}
if ($user->telegram_id) {
throw new ApiException('该账号已经绑定了Telegram账号');
}
$user->telegram_id = $message->chat_id;
if (!$user->save()) {
throw new ApiException('设置失败');
}
$telegramService = $this->telegramService;
$telegramService->sendMessage($message->chat_id, '绑定成功');
}
}
@@ -1,21 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
class GetLatestUrl extends Telegram {
public $command = '/getlatesturl';
public $description = '将Telegram账号绑定到网站';
public function handle($message, $match = []) {
$telegramService = $this->telegramService;
$text = sprintf(
"%s的最新网址是:%s",
admin_setting('app_name', 'XBoard'),
admin_setting('app_url')
);
$telegramService->sendMessage($message->chat_id, $text, 'markdown');
}
}
@@ -1,38 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
use App\Services\TicketService;
class ReplyTicket extends Telegram {
public $regex = '/[#](.*)/';
public $description = '快速工单回复';
public function handle($message, $match = []) {
if (!$message->is_private) return;
$this->replayTicket($message, $match[1]);
}
private function replayTicket($msg, $ticketId)
{
$user = User::where('telegram_id', $msg->chat_id)->first();
if (!$user) {
throw new ApiException('用户不存在');
}
if (!$msg->text) return;
if (!($user->is_admin || $user->is_staff)) return;
$ticketService = new TicketService();
$ticketService->replyByAdmin(
$ticketId,
$msg->text,
$user->id
);
$telegramService = $this->telegramService;
$telegramService->sendMessage($msg->chat_id, "#`{$ticketId}` 的工单已回复成功", 'markdown');
$telegramService->sendMessageWithAdmin("#`{$ticketId}` 的工单已由 {$user->email} 进行回复", true);
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
use App\Utils\Helper;
class Traffic extends Telegram {
public $command = '/traffic';
public $description = '查询流量信息';
public function handle($message, $match = []) {
$telegramService = $this->telegramService;
if (!$message->is_private) return;
$user = User::where('telegram_id', $message->chat_id)->first();
if (!$user) {
$telegramService->sendMessage($message->chat_id, '没有查询到您的用户信息,请先绑定账号', 'markdown');
return;
}
$transferEnable = Helper::trafficConvert($user->transfer_enable);
$up = Helper::trafficConvert($user->u);
$down = Helper::trafficConvert($user->d);
$remaining = Helper::trafficConvert($user->transfer_enable - ($user->u + $user->d));
$text = "🚥流量查询\n———————————————\n计划流量:`{$transferEnable}`\n已用上行:`{$up}`\n已用下行:`{$down}`\n剩余流量:`{$remaining}`";
$telegramService->sendMessage($message->chat_id, $text, 'markdown');
}
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
class UnBind extends Telegram {
public $command = '/unbind';
public $description = '将Telegram账号从网站解绑';
public function handle($message, $match = []) {
if (!$message->is_private) return;
$user = User::where('telegram_id', $message->chat_id)->first();
$telegramService = $this->telegramService;
if (!$user) {
$telegramService->sendMessage($message->chat_id, '没有查询到您的用户信息,请先绑定账号', 'markdown');
return;
}
$user->telegram_id = NULL;
if (!$user->save()) {
throw new ApiException('解绑失败');
}
$telegramService->sendMessage($message->chat_id, '解绑成功', 'markdown');
}
}
-15
View File
@@ -1,15 +0,0 @@
<?php
namespace App\Plugins\Telegram;
use App\Services\TelegramService;
abstract class Telegram {
abstract protected function handle($message, $match);
public $telegramService;
public function __construct()
{
$this->telegramService = new TelegramService();
}
}
+64 -18
View File
@@ -2,36 +2,57 @@
namespace App\Services;
use App\Exceptions\ApiException;
use App\Models\Payment;
use App\Services\Plugin\PluginManager;
use App\Services\Plugin\HookManager;
class PaymentService
{
public $method;
protected $class;
protected $config;
protected $payment;
protected $pluginManager;
protected $class;
public function __construct($method, $id = NULL, $uuid = NULL)
{
$this->method = $method;
$this->class = '\\App\\Payments\\' . $this->method;
if (!class_exists($this->class))
throw new ApiException('gate is not found');
if ($id)
$this->pluginManager = app(PluginManager::class);
if ($method === 'temp') {
return;
}
if ($id) {
$payment = Payment::find($id)->toArray();
if ($uuid)
}
if ($uuid) {
$payment = Payment::where('uuid', $uuid)->first()->toArray();
}
$this->config = [];
if (isset($payment)) {
$this->config = $payment['config'];
$this->config = is_string($payment['config']) ? json_decode($payment['config'], true) : $payment['config'];
$this->config['enable'] = $payment['enable'];
$this->config['id'] = $payment['id'];
$this->config['uuid'] = $payment['uuid'];
$this->config['notify_domain'] = $payment['notify_domain'];
$this->config['notify_domain'] = $payment['notify_domain'] ?? '';
}
;
$paymentMethods = $this->getAvailablePaymentMethods();
if (isset($paymentMethods[$this->method])) {
$pluginCode = $paymentMethods[$this->method]['plugin_code'];
$paymentPlugins = $this->pluginManager->getEnabledPaymentPlugins();
foreach ($paymentPlugins as $plugin) {
if ($plugin->getPluginCode() === $pluginCode) {
$plugin->setConfig($this->config);
$this->payment = $plugin;
return;
}
}
}
$this->payment = new $this->class($this->config);
}
@@ -64,18 +85,43 @@ class PaymentService
public function form()
{
$form = $this->payment->form();
$keys = array_keys($form);
$result = [];
foreach ($form as $key => $field) {
$form[$key] = [
'label' => $field['label'],
'field_name' => $key,
'field_type' => $field['type'],
$result[$key] = [
'type' => $field['type'],
'label' => $field['label'] ?? '',
'placeholder' => $field['placeholder'] ?? '',
'value' => $this->config[$key] ?? '',
'select_options' => $field['select_options'] ?? [],
'description' => $field['description'] ?? '',
'value' => $this->config[$key] ?? $field['default'] ?? '',
'options' => $field['select_options'] ?? $field['options'] ?? []
];
}
return $form;
return $result;
}
/**
* 获取所有可用的支付方式
*/
public function getAvailablePaymentMethods(): array
{
$methods = [];
$methods = HookManager::filter('available_payment_methods', $methods);
return $methods;
}
/**
* 获取所有支付方式名称列表(用于管理后台)
*/
public static function getAllPaymentMethodNames(): array
{
$pluginManager = app(PluginManager::class);
$pluginManager->initializeEnabledPlugins();
$instance = new self('temp');
$methods = $instance->getAvailablePaymentMethods();
return array_keys($methods);
}
}
+29
View File
@@ -99,6 +99,35 @@ abstract class AbstractPlugin
HookManager::remove($hook);
}
/**
* 注册 Artisan 命令
*/
protected function registerCommand(string $commandClass): void
{
if (class_exists($commandClass)) {
app('Illuminate\Contracts\Console\Kernel')->registerCommand(new $commandClass());
}
}
/**
* 注册插件命令目录
*/
public function registerCommands(): void
{
$commandsPath = $this->basePath . '/Commands';
if (File::exists($commandsPath)) {
$files = File::glob($commandsPath . '/*.php');
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$commandClass = $this->namespace . '\\Commands\\' . $className;
if (class_exists($commandClass)) {
$this->registerCommand($commandClass);
}
}
}
}
/**
* 中断当前请求并返回新的响应
*
+62 -3
View File
@@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\Finder\Finder;
class PluginManager
{
@@ -114,13 +113,25 @@ class PluginManager
*/
protected function loadViews(string $pluginCode): void
{
$viewsPath = $this->getPluginPath($pluginCode) . '/resources/views';
$viewsPath = $this->getPluginPath($pluginCode) . '/views';
if (File::exists($viewsPath)) {
View::addNamespace(Str::studly($pluginCode), $viewsPath);
}
}
/**
* 注册插件命令
*/
protected function registerPluginCommands(string $pluginCode, AbstractPlugin $pluginInstance): void
{
try {
// 调用插件的命令注册方法
$pluginInstance->registerCommands();
} catch (\Exception $e) {
Log::error("Failed to register commands for plugin '{$pluginCode}': " . $e->getMessage());
}
}
/**
* 安装插件
*/
@@ -163,6 +174,7 @@ class PluginManager
'code' => $pluginCode,
'name' => $config['name'],
'version' => $config['version'],
'type' => $config['type'] ?? Plugin::TYPE_FEATURE,
'is_enabled' => false,
'config' => json_encode($defaultValues),
'installed_at' => now(),
@@ -259,6 +271,14 @@ class PluginManager
return false;
}
// 验证插件类型
if (isset($config['type'])) {
$validTypes = ['feature', 'payment'];
if (!in_array($config['type'], $validTypes)) {
return false;
}
}
return true;
}
@@ -478,6 +498,7 @@ class PluginManager
$this->registerServiceProvider($pluginCode);
$this->loadRoutes($pluginCode);
$this->loadViews($pluginCode);
$this->registerPluginCommands($pluginCode, $pluginInstance);
$pluginInstance->boot();
@@ -535,4 +556,42 @@ class PluginManager
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
}
/**
* Get enabled plugins by type
*/
public function getEnabledPluginsByType(string $type): array
{
$this->initializeEnabledPlugins();
$enabledPluginCodes = Plugin::where('is_enabled', true)
->byType($type)
->pluck('code')
->all();
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
}
/**
* Get enabled payment plugins
*/
public function getEnabledPaymentPlugins(): array
{
return $this->getEnabledPluginsByType('payment');
}
/**
* install default plugins
*/
public static function installDefaultPlugins(): void
{
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
if (!Plugin::where('code', $pluginCode)->exists()) {
$pluginManager = app(self::class);
$pluginManager->install($pluginCode);
$pluginManager->enable($pluginCode);
Log::info("Installed and enabled default plugin: {$pluginCode}");
}
}
}
}
+115 -40
View File
@@ -1,85 +1,160 @@
<?php
namespace App\Services;
use App\Exceptions\ApiException;
use App\Jobs\SendTelegramJob;
use App\Models\User;
use \Curl\Curl;
use App\Services\Plugin\HookManager;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramService {
protected $api;
class TelegramService
{
protected PendingRequest $http;
protected string $apiUrl;
public function __construct($token = '')
public function __construct(?string $token = null)
{
$this->api = 'https://api.telegram.org/bot' . admin_setting('telegram_bot_token', $token) . '/';
$botToken = admin_setting('telegram_bot_token', $token);
$this->apiUrl = "https://api.telegram.org/bot{$botToken}/";
$this->http = Http::timeout(30)
->retry(3, 1000)
->withHeaders([
'Accept' => 'application/json',
]);
}
public function sendMessage(int $chatId, string $text, string $parseMode = '')
public function sendMessage(int $chatId, string $text, string $parseMode = ''): void
{
if ($parseMode === 'markdown') {
$text = str_replace('_', '\_', $text);
}
$text = $parseMode === 'markdown' ? str_replace('_', '\_', $text) : $text;
$this->request('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => $parseMode
'parse_mode' => $parseMode ?: null,
]);
}
public function approveChatJoinRequest(int $chatId, int $userId)
public function approveChatJoinRequest(int $chatId, int $userId): void
{
$this->request('approveChatJoinRequest', [
'chat_id' => $chatId,
'user_id' => $userId
'user_id' => $userId,
]);
}
public function declineChatJoinRequest(int $chatId, int $userId)
public function declineChatJoinRequest(int $chatId, int $userId): void
{
$this->request('declineChatJoinRequest', [
'chat_id' => $chatId,
'user_id' => $userId
'user_id' => $userId,
]);
}
public function getMe()
public function getMe(): object
{
return $this->request('getMe');
}
public function setWebhook(string $url)
public function setWebhook(string $url): object
{
return $this->request('setWebhook', [
'url' => $url
]);
$result = $this->request('setWebhook', ['url' => $url]);
return $result;
}
private function request(string $method, array $params = [])
/**
* 注册 Bot 命令列表
*/
public function registerBotCommands(): void
{
$curl = new Curl();
$curl->get($this->api . $method . '?' . http_build_query($params));
$response = $curl->response;
$curl->close();
if (!isset($response->ok)) throw new ApiException('请求失败');
if (!$response->ok) {
throw new ApiException('来自TG的错误:' . $response->description);
}
return $response;
}
try {
$commands = HookManager::filter('telegram.bot.commands', []);
public function sendMessageWithAdmin($message, $isStaff = false)
{
if (!admin_setting('telegram_bot_enable', 0)) return;
$users = User::where(function ($query) use ($isStaff) {
$query->where('is_admin', 1);
if ($isStaff) {
$query->orWhere('is_staff', 1);
if (empty($commands)) {
Log::warning('没有找到任何 Telegram Bot 命令');
return;
}
})
->where('telegram_id', '!=', NULL)
->get();
$this->request('setMyCommands', [
'commands' => json_encode($commands),
'scope' => json_encode(['type' => 'default'])
]);
Log::info('Telegram Bot 命令注册成功', [
'commands_count' => count($commands),
'commands' => $commands
]);
} catch (\Exception $e) {
Log::error('Telegram Bot 命令注册失败', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
}
/**
* 获取当前注册的命令列表
*/
public function getMyCommands(): object
{
return $this->request('getMyCommands');
}
/**
* 删除所有命令
*/
public function deleteMyCommands(): object
{
return $this->request('deleteMyCommands');
}
public function sendMessageWithAdmin(string $message, bool $isStaff = false): void
{
$query = User::where('telegram_id', '!=', null);
$query->where(
fn($q) => $q->where('is_admin', 1)
->when($isStaff, fn($q) => $q->orWhere('is_staff', 1))
);
$users = $query->get();
foreach ($users as $user) {
SendTelegramJob::dispatch($user->telegram_id, $message);
}
}
protected function request(string $method, array $params = []): object
{
try {
$response = $this->http->get($this->apiUrl . $method, $params);
if (!$response->successful()) {
throw new ApiException("HTTP 请求失败: {$response->status()}");
}
$data = $response->object();
if (!isset($data->ok)) {
throw new ApiException('无效的 Telegram API 响应');
}
if (!$data->ok) {
$description = $data->description ?? '未知错误';
throw new ApiException("Telegram API 错误: {$description}");
}
return $data;
} catch (\Exception $e) {
Log::error('Telegram API 请求失败', [
'method' => $method,
'params' => $params,
'error' => $e->getMessage(),
]);
throw new ApiException("Telegram 服务错误: {$e->getMessage()}");
}
}
}
+40 -6
View File
@@ -11,10 +11,11 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use App\Services\Plugin\HookManager;
class TicketService {
class TicketService
{
public function reply($ticket, $message, $userId)
{
try{
try {
DB::beginTransaction();
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
@@ -31,13 +32,13 @@ class TicketService {
}
DB::commit();
return $ticketMessage;
}catch(\Exception $e){
} catch (\Exception $e) {
DB::rollback();
return false;
}
}
public function replyByAdmin($ticketId, $message, $userId):void
public function replyByAdmin($ticketId, $message, $userId): void
{
$ticket = Ticket::where('id', $ticketId)
->first();
@@ -45,7 +46,7 @@ class TicketService {
throw new ApiException('工单不存在');
}
$ticket->status = Ticket::STATUS_OPENING;
try{
try {
DB::beginTransaction();
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
@@ -62,13 +63,46 @@ class TicketService {
}
DB::commit();
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
}catch(\Exception $e){
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
$this->sendEmailNotify($ticket, $ticketMessage);
}
public function createTicket($userId, $subject, $level, $message)
{
try {
DB::beginTransaction();
if (Ticket::where('status', 0)->where('user_id', $userId)->lockForUpdate()->first()) {
DB::rollBack();
throw new ApiException('存在未关闭的工单');
}
$ticket = Ticket::create([
'user_id' => $userId,
'subject' => $subject,
'level' => $level
]);
if (!$ticket) {
throw new ApiException('工单创建失败');
}
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
'ticket_id' => $ticket->id,
'message' => $message
]);
if (!$ticketMessage) {
DB::rollBack();
throw new ApiException('工单消息创建失败');
}
DB::commit();
return $ticket;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
// 半小时内不再重复通知
private function sendEmailNotify(Ticket $ticket, TicketMessage $ticketMessage)
{
-2
View File
@@ -134,9 +134,7 @@ class Setting
*/
private function flush(): void
{
// 清除共享的Redis缓存
$this->cache->forget(self::CACHE_KEY);
// 清除当前请求的实例内存缓存
$this->loadedSettings = null;
}
}
-2
View File
@@ -7,8 +7,6 @@ use Illuminate\Support\Arr;
class Helper
{
private static $subscribeUrlCache = null;
public static function uuidToBase64($uuid, $length)
{
return base64_encode(substr($uuid, 0, $length));