diff --git a/app/Models/User.php b/app/Models/User.php index a826e4a16..910e762b0 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -47,6 +47,8 @@ * @property int $created_at * @property int $updated_at * @property bool $commission_auto_check 是否自动计算佣金 + * @property string|null $hiddify_user_id Hiddify 用户 ID + * @property string|null $hiddify_subscribe_url Hiddify 订阅链接 * * @property-read User|null $invite_user 邀请人信息 * @property-read \App\Models\Plan|null $plan 用户订阅计划 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..28be2288e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "Xboard", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "chokidar": "^4.0.3" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + } + }, + "dependencies": { + "chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "requires": { + "readdirp": "^4.0.1" + } + }, + "readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" + } + } +} diff --git a/plugins-core/Hiddify/Plugin.php b/plugins-core/Hiddify/Plugin.php new file mode 100644 index 000000000..e7a8d18a9 --- /dev/null +++ b/plugins-core/Hiddify/Plugin.php @@ -0,0 +1,263 @@ +listen('order.open.after', [$this, 'handleOrderOpened']); + $this->filter('user.subscribe.response', [$this, 'filterSubscribeResponse']); + } + + public function handleOrderOpened(Order $order): void + { + $config = $this->getConfig(); + + if (! (bool) ($config['auto_sale_enable'] ?? false)) { + return; + } + + if (empty($config['admin_url']) || empty($config['admin_user']) || empty($config['admin_password'])) { + Log::warning('[Hiddify] 插件未配置完整,跳过自动创建用户'); + return; + } + + $user = $order->user; + if (! $user || ! $user->plan_id) { + return; + } + + try { + $subscribeUrl = $this->ensureHiddifyUser($user); + if ($subscribeUrl) { + $user->hiddify_subscribe_url = $subscribeUrl; + $user->save(); + } + } catch (Throwable $exception) { + Log::error('[Hiddify] 自动创建用户失败:' . $exception->getMessage(), [ + 'user_id' => $user->id, + 'order_id' => $order->id, + ]); + } + } + + public function filterSubscribeResponse(mixed $payload): mixed + { + if ($payload instanceof User) { + if (! empty($payload->hiddify_subscribe_url)) { + $payload['subscribe_url'] = $payload->hiddify_subscribe_url; + } + return $payload; + } + + if (is_array($payload) && ! empty($payload['id'])) { + $user = User::find($payload['id']); + if ($user && ! empty($user->hiddify_subscribe_url)) { + $payload['subscribe_url'] = $user->hiddify_subscribe_url; + } + } + + return $payload; + } + + protected function ensureHiddifyUser(User $user): ?string + { + if (! empty($user->hiddify_subscribe_url)) { + return $user->hiddify_subscribe_url; + } + + $token = $this->authenticate(); + if (! $token) { + return null; + } + + $result = $this->createHiddifyUser($user, $token); + if (empty($result)) { + return null; + } + + $subscribeUrl = $this->resolveSubscribeUrl($result, $user); + if (! empty($subscribeUrl)) { + $user->hiddify_user_id = $this->arrayGet($result, 'id') ?? $this->arrayGet($result, 'user_id'); + $user->hiddify_subscribe_url = $subscribeUrl; + $user->save(); + } + + return $subscribeUrl; + } + + protected function authenticate(): ?string + { + $adminUrl = rtrim($this->getConfig('admin_url'), '/'); + $adminUser = $this->getConfig('admin_user'); + $adminPassword = $this->getConfig('admin_password'); + + $payload = [ + 'password' => $adminPassword, + ]; + + if (filter_var($adminUser, FILTER_VALIDATE_EMAIL)) { + $payload['email'] = $adminUser; + } else { + $payload['username'] = $adminUser; + } + + $paths = ['/api/v1/auth/login', '/api/v1/login']; + foreach ($paths as $path) { + $response = Http::acceptJson() + ->withOptions(['verify' => (bool) ($this->getConfig('verify_ssl') ?? false)]) + ->post($adminUrl . $path, $payload); + + if ($response->status() === 404) { + continue; + } + + if (! $response->successful()) { + if ($response->status() === 401 || $response->status() === 422) { + break; + } + continue; + } + + $data = $response->json(); + $token = $this->arrayGet($data, 'access_token') + ?? $this->arrayGet($data, 'token') + ?? $this->arrayGet($data, 'data.token') + ?? $this->arrayGet($data, 'data.access_token'); + + if ($token) { + return $token; + } + } + + throw new \RuntimeException('Hiddify 登录失败,无法获取访问令牌。'); + } + + protected function createHiddifyUser(User $user, string $token): ?array + { + $adminUrl = rtrim($this->getConfig('admin_url'), '/'); + $username = $this->buildHiddifyUsername($user); + $password = Str::random(20); + + $payload = array_filter([ + 'username' => $username, + 'password' => $password, + 'email' => $user->email ?: $username . '@example.com', + 'expired_at' => $user->expired_at ? date('c', $user->expired_at) : null, + 'transfer_enable' => $this->convertTransferEnableToBytes($user->transfer_enable), + ], fn ($value) => $value !== null && $value !== ''); + + $response = Http::withToken($token) + ->acceptJson() + ->withOptions(['verify' => (bool) ($this->getConfig('verify_ssl') ?? false)]) + ->post($adminUrl . '/api/v1/users', $payload); + + if ($response->successful()) { + return $response->json(); + } + + if ($response->status() === 409) { + return $this->fetchExistingHiddifyUser($adminUrl, $token, $username); + } + + throw new \RuntimeException('Hiddify 创建用户失败:' . $response->body()); + } + + protected function convertTransferEnableToBytes(mixed $transferEnable): ?int + { + if (empty($transferEnable)) { + return null; + } + + return (int) max(0, $transferEnable * 1024); + } + + protected function fetchExistingHiddifyUser(string $adminUrl, string $token, string $username): ?array + { + $response = Http::withToken($token) + ->acceptJson() + ->withOptions(['verify' => (bool) ($this->getConfig('verify_ssl') ?? false)]) + ->get($adminUrl . '/api/v1/users', ['username' => $username]); + + if ($response->successful()) { + $data = $response->json(); + if (is_array($data['data'] ?? $data)) { + $items = $data['data'] ?? $data; + if (! empty($items)) { + return $items[0]; + } + } + } + + $response = Http::withToken($token) + ->acceptJson() + ->withOptions(['verify' => (bool) ($this->getConfig('verify_ssl') ?? false)]) + ->get($adminUrl . '/api/v1/users/' . urlencode($username)); + + if ($response->successful()) { + return $response->json(); + } + + return null; + } + + protected function resolveSubscribeUrl(array $result, User $user): ?string + { + $adminUrl = rtrim($this->getConfig('admin_url'), '/'); + $template = $this->getConfig('subscribe_path_template') ?: '/sub/{username}'; + $username = $this->buildHiddifyUsername($user); + + foreach (['subscribe_url', 'link', 'url', 'data.url', 'data.link'] as $key) { + $value = $this->arrayGet($result, $key); + if (! empty($value)) { + return (string) $value; + } + } + + $replacements = [ + '{admin_url}' => $adminUrl, + '{username}' => $username, + '{id}' => $this->arrayGet($result, 'id') ?: $username, + ]; + + $path = str_replace(array_keys($replacements), array_values($replacements), $template); + return rtrim($adminUrl, '/') . '/' . ltrim($path, '/'); + } + + protected function buildHiddifyUsername(User $user): string + { + $emailUser = preg_replace('/[^a-zA-Z0-9_\-]/', '_', strtolower($user->email ?? '')); + if (empty($emailUser)) { + $emailUser = 'user' . $user->id; + } + + return 'xboard_' . $user->id . '_' . Str::slug($emailUser, '_'); + } + + protected function arrayGet(array $array, string $key): mixed + { + if (strpos($key, '.') === false) { + return $array[$key] ?? null; + } + + $segments = explode('.', $key); + $value = $array; + foreach ($segments as $segment) { + if (! is_array($value) || ! array_key_exists($segment, $value)) { + return null; + } + $value = $value[$segment]; + } + + return $value; + } +} diff --git a/plugins-core/Hiddify/README.md b/plugins-core/Hiddify/README.md new file mode 100644 index 000000000..be964fb38 --- /dev/null +++ b/plugins-core/Hiddify/README.md @@ -0,0 +1,21 @@ +# Hiddify Panel Integration + +该插件用于将 XBoard 与 Hiddify 管理面板对接,自动在用户支付后创建 Hiddify 用户并将订阅链接返回给客户端。 + +## 安装步骤 + +1. 在后台插件管理页面安装并启用 `Hiddify Panel` 插件。 +2. 进入插件配置,填写: + - `Hiddify 管理面板地址` + - `管理员账号` + - `管理员密码` +3. 启用自动销售功能。 +4. 如果面板使用自签名证书,请关闭 SSL 验证。 +5. 如 Hiddify API 未直接返回订阅链接,可使用 `订阅链接模板` 定制 `/{username}` 或 `/sub/{id}` 形式的链接。 + +## 功能说明 + +- 订单完成后自动创建 Hiddify 面板用户。 +- 若 Hiddify 返回订阅链接,则自动保存并在用户订阅接口中展示。 +- 通过 `订阅链接模板` 可以自定义链接生成规则。 +- 实现基于 Hiddify Manager API 文档:https://hiddify.com/manager/contribution/How-to-use-API-in-HiddifyManager-project/ diff --git a/plugins-core/Hiddify/config.json b/plugins-core/Hiddify/config.json new file mode 100644 index 000000000..3bcd2c1a0 --- /dev/null +++ b/plugins-core/Hiddify/config.json @@ -0,0 +1,50 @@ +{ + "name": "Hiddify Panel", + "code": "hiddify", + "type": "feature", + "version": "1.0.0", + "description": "Connect XBoard to a Hiddify panel and auto-create subscription users after payment.", + "author": "XBoard Team", + "require": { + "xboard": ">=1.0.0" + }, + "config": { + "auto_sale_enable": { + "type": "boolean", + "default": true, + "label": "启用自动销售", + "description": "订单完成后自动在 Hiddify 面板创建用户并生成订阅链接。" + }, + "admin_url": { + "type": "string", + "default": "", + "label": "Hiddify 管理面板地址", + "placeholder": "https://hiddify.example.com", + "description": "请输入 Hiddify 面板管理地址,不要包含尾部斜杠。" + }, + "admin_user": { + "type": "string", + "default": "", + "label": "管理员账号", + "description": "Hiddify 面板管理员登录账号(用户名或邮箱)。" + }, + "admin_password": { + "type": "password", + "default": "", + "label": "管理员密码", + "description": "Hiddify 面板管理员登录密码。" + }, + "verify_ssl": { + "type": "boolean", + "default": false, + "label": "SSL 验证", + "description": "如果 Hiddify 面板使用自签名证书,请关闭 SSL 验证。" + }, + "subscribe_path_template": { + "type": "string", + "default": "/sub/{username}", + "label": "订阅链接模板", + "description": "如果 Hiddify API 未返回订阅链接,系统会使用此模板生成。支持 {admin_url}、{username}、{id}。" + } + } +} diff --git a/plugins-core/Hiddify/database/migrations/2026_05_07_000001_add_hiddify_fields_to_users.php b/plugins-core/Hiddify/database/migrations/2026_05_07_000001_add_hiddify_fields_to_users.php new file mode 100644 index 000000000..8d1ea2cb4 --- /dev/null +++ b/plugins-core/Hiddify/database/migrations/2026_05_07_000001_add_hiddify_fields_to_users.php @@ -0,0 +1,33 @@ +string('hiddify_user_id')->nullable()->after('token'); + } + + if (! Schema::hasColumn('v2_user', 'hiddify_subscribe_url')) { + $table->text('hiddify_subscribe_url')->nullable()->after('hiddify_user_id'); + } + }); + } + + public function down(): void + { + Schema::table('v2_user', function (Blueprint $table) { + if (Schema::hasColumn('v2_user', 'hiddify_subscribe_url')) { + $table->dropColumn('hiddify_subscribe_url'); + } + if (Schema::hasColumn('v2_user', 'hiddify_user_id')) { + $table->dropColumn('hiddify_user_id'); + } + }); + } +};