commit 5b9c398e68b7d23bd9b57b37b125ca01a17c0fa9 Author: wushumin Date: Thu Apr 16 11:17:18 2026 +0800 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..38154b7 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE= +DB_USERNAME= +DB_PASSWORD= + +USER_TOKEN_TTL=604800 +ADMIN_TOKEN_TTL=86400 +ADMIN_INIT_USERNAME=admin +ADMIN_INIT_PASSWORD= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..516299c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/runtime +/.idea +/.vscode +/vendor +*.log +.env +/tests/tmp +/tests/.phpunit.result.cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94ce154 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM php:8.3.22-cli-alpine + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" + +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ + && apk update --no-cache \ + && docker-php-source extract + +# install extensions +RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl + +# enable opcache and pcntl +RUN docker-php-ext-enable opcache pcntl +RUN docker-php-source delete \ + rm -rf /var/cache/apk/* + +RUN mkdir -p /app +WORKDIR /app \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c66292 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 walkor and contributors (see https://github.com/walkor/webman/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4031784 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +
+

webman

+ +基于workerman开发的超高性能PHP框架 + + +

学习

+ + + +
+ +

赞助商

+ +

特别赞助

+ + + + +

铂金赞助

+ + + + +
+ + +
+ +

请作者喝咖啡

+ + + +
+如果您觉得webman对您有所帮助,欢迎捐赠。 + + +
+ + +
+

LICENSE

+The webman is open-sourced software licensed under the MIT. +
+ +
+ + diff --git a/alter_orders_pay.php b/alter_orders_pay.php new file mode 100644 index 0000000..6b22f55 --- /dev/null +++ b/alter_orders_pay.php @@ -0,0 +1,73 @@ +load(); +} + +$capsule = new Capsule; +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +if (Capsule::schema()->hasTable('orders')) { + if (!Capsule::schema()->hasColumn('orders', 'pay_channel')) { + Capsule::schema()->table('orders', function ($table) { + $table->string('pay_channel', 20)->nullable()->after('pay_time'); + }); + echo "Added 'pay_channel' to orders.\n"; + } + if (!Capsule::schema()->hasColumn('orders', 'pay_status')) { + Capsule::schema()->table('orders', function ($table) { + $table->string('pay_status', 20)->default('unpaid')->after('pay_channel'); + }); + echo "Added 'pay_status' to orders.\n"; + } + if (!Capsule::schema()->hasColumn('orders', 'pay_merchant_id')) { + Capsule::schema()->table('orders', function ($table) { + $table->unsignedBigInteger('pay_merchant_id')->default(0)->after('pay_status'); + }); + echo "Added 'pay_merchant_id' to orders.\n"; + } + if (!Capsule::schema()->hasColumn('orders', 'pay_out_trade_no')) { + Capsule::schema()->table('orders', function ($table) { + $table->string('pay_out_trade_no', 64)->nullable()->after('pay_merchant_id'); + }); + echo "Added 'pay_out_trade_no' to orders.\n"; + } +} + +if (!Capsule::schema()->hasTable('payment_transactions')) { + Capsule::schema()->create('payment_transactions', function ($table) { + $table->id(); + $table->unsignedBigInteger('order_id')->index(); + $table->string('channel', 20)->default('wechat')->index(); + $table->unsignedBigInteger('merchant_id')->default(0)->index(); + $table->string('out_trade_no', 64)->unique(); + $table->decimal('amount', 10, 2)->default(0.00); + $table->string('status', 20)->default('created')->index(); + $table->string('prepay_id', 64)->nullable(); + $table->string('code_url', 255)->nullable(); + $table->json('raw_json')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + echo "Table 'payment_transactions' created successfully.\n"; +} + +echo "Alter orders_pay completed.\n"; + diff --git a/alter_orders_return.php b/alter_orders_return.php new file mode 100644 index 0000000..9726445 --- /dev/null +++ b/alter_orders_return.php @@ -0,0 +1,39 @@ +load(); +} + +$capsule = new Capsule; + +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); + +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +if (!Capsule::schema()->hasColumn('orders', 'return_express_company')) { + Capsule::schema()->table('orders', function ($table) { + $table->string('return_express_company', 32)->nullable()->after('express_no'); + $table->string('return_express_no', 64)->nullable()->after('return_express_company'); + $table->timestamp('return_ship_time')->nullable()->after('return_express_no'); + }); + echo "Added return shipping fields to orders.\n"; +} + +echo "Alter orders completed.\n"; + diff --git a/alter_tables.php b/alter_tables.php new file mode 100644 index 0000000..70be03d --- /dev/null +++ b/alter_tables.php @@ -0,0 +1,48 @@ +load(); +} + +$capsule = new Capsule; + +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); + +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +// 补充 roles 字段 +if (!Capsule::schema()->hasColumn('roles', 'description')) { + Capsule::schema()->table('roles', function ($table) { + $table->string('description', 255)->nullable()->after('name'); + }); + echo "Added 'description' to roles.\n"; +} + +// 补充 permissions 字段 +if (!Capsule::schema()->hasColumn('permissions', 'parent_id')) { + Capsule::schema()->table('permissions', function ($table) { + $table->unsignedBigInteger('parent_id')->default(0)->after('id'); + $table->tinyInteger('type')->default(1)->comment('1菜单 2按钮')->after('code'); + $table->integer('sort')->default(0)->after('type'); + }); + echo "Added 'parent_id', 'type', 'sort' to permissions.\n"; +} + +echo "Alter tables completed.\n"; diff --git a/alter_user_wechat_identities.php b/alter_user_wechat_identities.php new file mode 100644 index 0000000..6b08419 --- /dev/null +++ b/alter_user_wechat_identities.php @@ -0,0 +1,44 @@ +load(); +} + +$capsule = new Capsule; +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +if (!Capsule::schema()->hasTable('user_wechat_identities')) { + Capsule::schema()->create('user_wechat_identities', function ($table) { + $table->id(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('app_id', 32)->index(); + $table->string('openid', 64)->index(); + $table->string('unionid', 64)->nullable()->index(); + $table->string('scene', 20)->default('unknown')->index(); + $table->timestamps(); + + $table->unique(['user_id', 'app_id']); + $table->unique(['app_id', 'openid']); + }); + echo "Table 'user_wechat_identities' created successfully.\n"; +} + +echo "Alter user_wechat_identities completed.\n"; + diff --git a/alter_wechat_apps.php b/alter_wechat_apps.php new file mode 100644 index 0000000..4930943 --- /dev/null +++ b/alter_wechat_apps.php @@ -0,0 +1,45 @@ +load(); +} + +$capsule = new Capsule; +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +if (!Capsule::schema()->hasTable('wechat_apps')) { + Capsule::schema()->create('wechat_apps', function ($table) { + $table->id(); + $table->string('name', 50); + $table->string('type', 20)->default('h5'); + $table->string('app_id', 32)->unique(); + $table->string('app_secret', 64)->nullable(); + $table->tinyInteger('status')->default(1); + $table->string('remark', 255)->nullable(); + $table->timestamps(); + + $table->index(['type']); + $table->index(['status']); + }); + echo "Table 'wechat_apps' created successfully.\n"; +} + +echo "Alter wechat_apps completed.\n"; + diff --git a/alter_wechat_merchants.php b/alter_wechat_merchants.php new file mode 100644 index 0000000..6dc0728 --- /dev/null +++ b/alter_wechat_merchants.php @@ -0,0 +1,51 @@ +load(); +} + +$capsule = new Capsule; +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +if (!Capsule::schema()->hasTable('wechat_merchants')) { + Capsule::schema()->create('wechat_merchants', function ($table) { + $table->id(); + $table->string('name', 50); + $table->string('mode', 20)->default('direct'); + $table->string('mch_id', 32); + $table->string('app_id', 32)->nullable(); + $table->string('sub_mch_id', 32)->nullable(); + $table->string('sub_app_id', 32)->nullable(); + $table->string('service_provider', 50)->nullable(); + $table->tinyInteger('is_default')->default(0); + $table->tinyInteger('status')->default(1); + $table->string('remark', 255)->nullable(); + $table->timestamps(); + + $table->index(['mch_id']); + $table->index(['mode']); + $table->index(['status']); + $table->index(['is_default']); + }); + echo "Table 'wechat_merchants' created successfully.\n"; +} + +echo "Alter wechat_merchants completed.\n"; + diff --git a/alter_wechat_merchants_cert_files.php b/alter_wechat_merchants_cert_files.php new file mode 100644 index 0000000..adc17a7 --- /dev/null +++ b/alter_wechat_merchants_cert_files.php @@ -0,0 +1,46 @@ +load(); +} + +$capsule = new Capsule; +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +if (!Capsule::schema()->hasTable('wechat_merchants')) { + echo "Table 'wechat_merchants' not found.\n"; + exit(1); +} + +if (!Capsule::schema()->hasColumn('wechat_merchants', 'apiclient_cert_path')) { + Capsule::schema()->table('wechat_merchants', function ($table) { + $table->string('apiclient_cert_path', 255)->nullable()->after('private_key_pem'); + }); + echo "Added 'apiclient_cert_path' to wechat_merchants.\n"; +} +if (!Capsule::schema()->hasColumn('wechat_merchants', 'apiclient_key_path')) { + Capsule::schema()->table('wechat_merchants', function ($table) { + $table->string('apiclient_key_path', 255)->nullable()->after('apiclient_cert_path'); + }); + echo "Added 'apiclient_key_path' to wechat_merchants.\n"; +} + +echo "Alter wechat_merchants_cert_files completed.\n"; + diff --git a/alter_wechat_merchants_pay.php b/alter_wechat_merchants_pay.php new file mode 100644 index 0000000..bda284a --- /dev/null +++ b/alter_wechat_merchants_pay.php @@ -0,0 +1,58 @@ +load(); +} + +$capsule = new Capsule; +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +if (!Capsule::schema()->hasTable('wechat_merchants')) { + echo "Table 'wechat_merchants' not found.\n"; + exit(1); +} + +if (!Capsule::schema()->hasColumn('wechat_merchants', 'serial_no')) { + Capsule::schema()->table('wechat_merchants', function ($table) { + $table->string('serial_no', 64)->nullable()->after('app_id'); + }); + echo "Added 'serial_no' to wechat_merchants.\n"; +} +if (!Capsule::schema()->hasColumn('wechat_merchants', 'api_v3_key')) { + Capsule::schema()->table('wechat_merchants', function ($table) { + $table->string('api_v3_key', 64)->nullable()->after('serial_no'); + }); + echo "Added 'api_v3_key' to wechat_merchants.\n"; +} +if (!Capsule::schema()->hasColumn('wechat_merchants', 'private_key_pem')) { + Capsule::schema()->table('wechat_merchants', function ($table) { + $table->text('private_key_pem')->nullable()->after('api_v3_key'); + }); + echo "Added 'private_key_pem' to wechat_merchants.\n"; +} +if (!Capsule::schema()->hasColumn('wechat_merchants', 'notify_url')) { + Capsule::schema()->table('wechat_merchants', function ($table) { + $table->string('notify_url', 255)->nullable()->after('private_key_pem'); + }); + echo "Added 'notify_url' to wechat_merchants.\n"; +} + +echo "Alter wechat_merchants_pay completed.\n"; + diff --git a/app/admin/controller/AdminUserController.php b/app/admin/controller/AdminUserController.php new file mode 100644 index 0000000..1e5f939 --- /dev/null +++ b/app/admin/controller/AdminUserController.php @@ -0,0 +1,134 @@ +get('page', 1); + $limit = (int)$request->get('limit', 15); + + $query = AdminUser::with('roles'); + + if ($username = $request->get('username')) { + $query->where('username', 'like', "%{$username}%"); + } + + $total = $query->count(); + $list = $query->offset(($page - 1) * $limit) + ->limit($limit) + ->orderBy('id', 'desc') + ->get(); + + return jsonResponse([ + 'total' => $total, + 'list' => $list + ]); + } + + public function create(Request $request) + { + $username = trim($request->post('username', '')); + $password = $request->post('password', ''); + $roleIds = $request->post('role_ids', []); + + if (!$username || !$password) { + return jsonResponse(null, '用户名和密码必填', 400); + } + + if (AdminUser::where('username', $username)->exists()) { + return jsonResponse(null, '用户名已存在', 400); + } + + DB::beginTransaction(); + try { + $admin = AdminUser::create([ + 'username' => $username, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'status' => (int)$request->post('status', 1), + 'is_super' => (int)$request->post('is_super', 0), + ]); + + if (!empty($roleIds)) { + $admin->roles()->sync($roleIds); + } + DB::commit(); + return jsonResponse(null, '创建成功'); + } catch (\Exception $e) { + DB::rollBack(); + return jsonResponse(null, '创建失败: ' . $e->getMessage(), 500); + } + } + + public function update(Request $request) + { + $id = (int)$request->post('id'); + $admin = AdminUser::find($id); + if (!$admin) { + return jsonResponse(null, '用户不存在', 404); + } + + $username = trim($request->post('username', '')); + if ($username && $username !== $admin->username) { + if (AdminUser::where('username', $username)->exists()) { + return jsonResponse(null, '用户名已存在', 400); + } + $admin->username = $username; + } + + $password = $request->post('password'); + if ($password) { + $admin->password_hash = password_hash($password, PASSWORD_DEFAULT); + } + + if ($request->post('status') !== null) { + $admin->status = (int)$request->post('status'); + } + + if ($request->post('is_super') !== null) { + $admin->is_super = (int)$request->post('is_super'); + } + + $roleIds = $request->post('role_ids'); + + DB::beginTransaction(); + try { + $admin->save(); + if (is_array($roleIds)) { + $admin->roles()->sync($roleIds); + } + DB::commit(); + return jsonResponse(null, '更新成功'); + } catch (\Exception $e) { + DB::rollBack(); + return jsonResponse(null, '更新失败: ' . $e->getMessage(), 500); + } + } + + public function delete(Request $request) + { + $id = (int)$request->post('id'); + if ($id === 1) { + return jsonResponse(null, '超级管理员不可删除', 403); + } + $admin = AdminUser::find($id); + if (!$admin) { + return jsonResponse(null, '用户不存在', 404); + } + + DB::beginTransaction(); + try { + $admin->roles()->detach(); + $admin->delete(); + DB::commit(); + return jsonResponse(null, '删除成功'); + } catch (\Exception $e) { + DB::rollBack(); + return jsonResponse(null, '删除失败: ' . $e->getMessage(), 500); + } + } +} diff --git a/app/admin/controller/AuthController.php b/app/admin/controller/AuthController.php new file mode 100644 index 0000000..1d4f341 --- /dev/null +++ b/app/admin/controller/AuthController.php @@ -0,0 +1,64 @@ +post('username', '')); + $password = (string)$request->post('password', ''); + if ($username === '' || $password === '') { + return jsonResponse(null, '参数错误', 400); + } + + $admin = AdminUser::where('username', $username)->first(); + if (!$admin) { + return jsonResponse(null, '账号或密码错误', 401); + } + if (intval($admin->status) !== 1) { + return jsonResponse(null, '账号已禁用', 403); + } + if (!password_verify($password, $admin->password_hash)) { + return jsonResponse(null, '账号或密码错误', 401); + } + + $token = AuthService::issueAdminToken($admin); + return jsonResponse([ + 'token' => $token, + 'admin' => $admin + ], '登录成功'); + } + + public function me(Request $request) + { + $admin = $request->admin; + $permissions = []; + if (intval($admin->is_super) === 1) { + $permissions = ['*']; + } else { + $admin->loadMissing(['roles.permissions']); + $map = []; + foreach ($admin->roles as $role) { + foreach ($role->permissions as $permission) { + $map[$permission->code] = true; + } + } + $permissions = array_keys($map); + } + return jsonResponse([ + 'admin' => $admin, + 'permissions' => $permissions + ]); + } + + public function logout(Request $request) + { + AuthService::revokeAdminToken($request->token ?? null); + return jsonResponse(null, '已退出登录'); + } +} + diff --git a/app/admin/controller/DashboardController.php b/app/admin/controller/DashboardController.php new file mode 100644 index 0000000..b9f18e8 --- /dev/null +++ b/app/admin/controller/DashboardController.php @@ -0,0 +1,26 @@ + Order::where('created_at', '>=', $today . ' 00:00:00')->count(), + 'wait_receive_orders' => Order::whereIn('status', ['shipping', 'wait_receive'])->count(), + 'inspecting_orders' => Order::where('status', 'inspecting')->count(), + 'today_reports' => Report::where('created_at', '>=', $today . ' 00:00:00')->count(), + 'total_users' => User::count(), + 'total_amount' => Order::where('status', 'finished')->sum('total_price') ?? 0 + ]; + + return jsonResponse($stat); + } +} diff --git a/app/admin/controller/OrderController.php b/app/admin/controller/OrderController.php new file mode 100644 index 0000000..d2cd4c7 --- /dev/null +++ b/app/admin/controller/OrderController.php @@ -0,0 +1,85 @@ +get('status', 'all'); + $page = max(1, intval($request->get('page', 1))); + $pageSize = min(50, max(1, intval($request->get('page_size', 10)))); + + $query = Order::query(); + if ($status !== 'all') { + $query->where('status', $status); + } + + if ($orderNo = $request->get('order_no')) { + $query->where('order_no', 'like', "%{$orderNo}%"); + } + + $total = $query->count(); + $items = $query->orderBy('id', 'desc') + ->offset(($page - 1) * $pageSize) + ->limit($pageSize) + ->get(); + + return jsonResponse([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize + ]); + } + + public function detail(Request $request) + { + $id = (int)$request->get('id'); + $order = Order::with(['logs'])->find($id); + + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + return jsonResponse($order); + } + + public function receive(Request $request) + { + $id = (int)$request->post('id'); + $order = Order::find($id); + + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + try { + \app\common\service\OrderFlowService::adminReceive($order, $request->admin->id); + return jsonResponse(null, '确认收件成功,已进入鉴定状态'); + } catch (\Exception $e) { + return jsonResponse(null, $e->getMessage(), 400); + } + } + + public function returnShip(Request $request) + { + $id = (int)$request->post('id'); + $expressCompany = trim((string)$request->post('express_company', '')); + $expressNo = trim((string)$request->post('express_no', '')); + + $order = Order::find($id); + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + try { + \app\common\service\OrderFlowService::adminReturnShip($order, $request->admin->id, $expressCompany, $expressNo); + return jsonResponse(null, '回寄信息已提交'); + } catch (\Exception $e) { + return jsonResponse(null, $e->getMessage(), 400); + } + } +} diff --git a/app/admin/controller/PermissionController.php b/app/admin/controller/PermissionController.php new file mode 100644 index 0000000..860d71e --- /dev/null +++ b/app/admin/controller/PermissionController.php @@ -0,0 +1,94 @@ +get(); + return jsonResponse($permissions); + } + + public function create(Request $request) + { + $name = trim($request->post('name', '')); + $code = trim($request->post('code', '')); + $parent_id = (int)$request->post('parent_id', 0); + $type = (int)$request->post('type', 1); // 1菜单 2按钮 + $sort = (int)$request->post('sort', 0); + + if (!$name || !$code) { + return jsonResponse(null, '名称和代码必填', 400); + } + + if (Permission::where('code', $code)->exists()) { + return jsonResponse(null, '代码已存在', 400); + } + + $permission = Permission::create([ + 'name' => $name, + 'code' => $code, + 'parent_id' => $parent_id, + 'type' => $type, + 'sort' => $sort, + ]); + + return jsonResponse($permission, '创建成功'); + } + + public function update(Request $request) + { + $id = (int)$request->post('id'); + $permission = Permission::find($id); + if (!$permission) { + return jsonResponse(null, '权限不存在', 404); + } + + $name = trim($request->post('name', '')); + $code = trim($request->post('code', '')); + + if ($name) $permission->name = $name; + if ($code && $code !== $permission->code) { + if (Permission::where('code', $code)->exists()) { + return jsonResponse(null, '代码已存在', 400); + } + $permission->code = $code; + } + + if ($request->post('parent_id') !== null) { + $permission->parent_id = (int)$request->post('parent_id'); + } + if ($request->post('type') !== null) { + $permission->type = (int)$request->post('type'); + } + if ($request->post('sort') !== null) { + $permission->sort = (int)$request->post('sort'); + } + + $permission->save(); + return jsonResponse(null, '更新成功'); + } + + public function delete(Request $request) + { + $id = (int)$request->post('id'); + $permission = Permission::find($id); + if (!$permission) { + return jsonResponse(null, '权限不存在', 404); + } + + if (Permission::where('parent_id', $id)->exists()) { + return jsonResponse(null, '存在子权限,不可删除', 400); + } + + $permission->delete(); + // Remove from role_permissions + \Illuminate\Database\Capsule\Manager::table('role_permissions')->where('permission_id', $id)->delete(); + + return jsonResponse(null, '删除成功'); + } +} diff --git a/app/admin/controller/ReportController.php b/app/admin/controller/ReportController.php new file mode 100644 index 0000000..d438c86 --- /dev/null +++ b/app/admin/controller/ReportController.php @@ -0,0 +1,114 @@ +get('page', 1))); + $pageSize = min(50, max(1, intval($request->get('page_size', 10)))); + + $query = Report::with(['order', 'inspector']); + + if ($reportNo = $request->get('report_no')) { + $query->where('report_no', 'like', "%{$reportNo}%"); + } + + $total = $query->count(); + $items = $query->orderBy('id', 'desc') + ->offset(($page - 1) * $pageSize) + ->limit($pageSize) + ->get(); + + return jsonResponse([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize + ]); + } + + public function detail(Request $request) + { + $id = (int)$request->get('id'); + $report = Report::with(['order.logs', 'inspector'])->find($id); + + if (!$report) { + return jsonResponse(null, '报告不存在', 404); + } + + return jsonResponse($report); + } + + public function create(Request $request) + { + $orderId = (int)$request->post('order_id'); + $conclusion = trim($request->post('conclusion', '')); // REAL, FAKE, DOUBT + $level = trim($request->post('level', '')); + $flawsJson = $request->post('flaws_json', []); + $imagesJson = $request->post('images_json', []); + + if (!in_array($conclusion, ['REAL', 'FAKE', 'DOUBT'])) { + return jsonResponse(null, '鉴定结论不合法', 400); + } + + if (empty($imagesJson)) { + return jsonResponse(null, '必须上传证据图片', 400); + } + + $order = Order::find($orderId); + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + if ($order->status !== 'inspecting') { + return jsonResponse(null, '订单当前状态不可出具报告', 400); + } + + if (Report::where('order_id', $orderId)->exists()) { + return jsonResponse(null, '该订单已出具报告,不可重复出具', 400); + } + + DB::beginTransaction(); + try { + $reportNo = 'R' . date('YmdHis') . rand(1000, 9999); + $verifyCode = bin2hex(random_bytes(8)); // 16字符防伪码 + + $report = Report::create([ + 'report_no' => $reportNo, + 'order_id' => $orderId, + 'conclusion' => $conclusion, + 'level' => $level, + 'flaws_json' => $flawsJson, + 'images_json' => $imagesJson, + 'inspector_id' => $request->admin->id, + 'verify_code' => $verifyCode + ]); + + // 扭转订单状态 + $order->status = 'finished'; + $order->save(); + + $conclusionMap = [ + 'REAL' => '正品', + 'FAKE' => '仿品', + 'DOUBT' => '存疑' + ]; + $conclusionText = $conclusionMap[$conclusion] ?? '未知'; + + OrderFlowService::addLog($orderId, 'report_generated', '报告已出具', "鉴定结论:{$conclusionText}", 'admin', $request->admin->id); + + DB::commit(); + return jsonResponse($report, '报告出具成功'); + } catch (\Exception $e) { + DB::rollBack(); + return jsonResponse(null, '出具报告失败: ' . $e->getMessage(), 500); + } + } +} diff --git a/app/admin/controller/RoleController.php b/app/admin/controller/RoleController.php new file mode 100644 index 0000000..d806f5b --- /dev/null +++ b/app/admin/controller/RoleController.php @@ -0,0 +1,140 @@ +get('page', 1); + $limit = (int)$request->get('limit', 15); + + $query = Role::with('permissions'); + + if ($name = $request->get('name')) { + $query->where('name', 'like', "%{$name}%"); + } + + $total = $query->count(); + $list = $query->offset(($page - 1) * $limit) + ->limit($limit) + ->orderBy('id', 'desc') + ->get(); + + return jsonResponse([ + 'total' => $total, + 'list' => $list + ]); + } + + public function all(Request $request) + { + $roles = Role::all(); + return jsonResponse($roles); + } + + public function create(Request $request) + { + $name = trim($request->post('name', '')); + $code = trim($request->post('code', '')); + $description = trim($request->post('description', '')); + $permissionIds = $request->post('permission_ids', []); + + if (!$name || !$code) { + return jsonResponse(null, '角色名称和编码必填', 400); + } + + if (Role::where('name', $name)->exists()) { + return jsonResponse(null, '角色名称已存在', 400); + } + if (Role::where('code', $code)->exists()) { + return jsonResponse(null, '角色编码已存在', 400); + } + + DB::beginTransaction(); + try { + $role = Role::create([ + 'name' => $name, + 'code' => $code, + 'description' => $description, + ]); + + if (!empty($permissionIds)) { + $role->permissions()->sync($permissionIds); + } + DB::commit(); + return jsonResponse(null, '创建成功'); + } catch (\Exception $e) { + DB::rollBack(); + return jsonResponse(null, '创建失败: ' . $e->getMessage(), 500); + } + } + + public function update(Request $request) + { + $id = (int)$request->post('id'); + $role = Role::find($id); + if (!$role) { + return jsonResponse(null, '角色不存在', 404); + } + + $name = trim($request->post('name', '')); + if ($name && $name !== $role->name) { + if (Role::where('name', $name)->exists()) { + return jsonResponse(null, '角色名称已存在', 400); + } + $role->name = $name; + } + + $code = trim($request->post('code', '')); + if ($code && $code !== $role->code) { + if (Role::where('code', $code)->exists()) { + return jsonResponse(null, '角色编码已存在', 400); + } + $role->code = $code; + } + + if ($request->post('description') !== null) { + $role->description = trim($request->post('description')); + } + + $permissionIds = $request->post('permission_ids'); + + DB::beginTransaction(); + try { + $role->save(); + if (is_array($permissionIds)) { + $role->permissions()->sync($permissionIds); + } + DB::commit(); + return jsonResponse(null, '更新成功'); + } catch (\Exception $e) { + DB::rollBack(); + return jsonResponse(null, '更新失败: ' . $e->getMessage(), 500); + } + } + + public function delete(Request $request) + { + $id = (int)$request->post('id'); + $role = Role::find($id); + if (!$role) { + return jsonResponse(null, '角色不存在', 404); + } + + DB::beginTransaction(); + try { + $role->permissions()->detach(); + $role->delete(); + DB::table('admin_roles')->where('role_id', $id)->delete(); + DB::commit(); + return jsonResponse(null, '删除成功'); + } catch (\Exception $e) { + DB::rollBack(); + return jsonResponse(null, '删除失败: ' . $e->getMessage(), 500); + } + } +} diff --git a/app/admin/controller/UploadController.php b/app/admin/controller/UploadController.php new file mode 100644 index 0000000..cd4fe9a --- /dev/null +++ b/app/admin/controller/UploadController.php @@ -0,0 +1,37 @@ +file('file'); + if (!$file || !$file->isValid()) { + return jsonResponse(null, '未找到文件或文件无效', 400); + } + + $ext = strtolower($file->getUploadExtension()); + if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { + return jsonResponse(null, '仅支持图片文件', 400); + } + + $dir = public_path() . '/upload/images/' . date('Ymd'); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + $filename = uniqid() . bin2hex(random_bytes(4)) . '.' . $ext; + $path = $dir . '/' . $filename; + $file->move($path); + + $url = '/upload/images/' . date('Ymd') . '/' . $filename; + + return jsonResponse([ + 'url' => $url, + 'name' => $file->getUploadName(), + ], '上传成功'); + } +} diff --git a/app/admin/controller/UserController.php b/app/admin/controller/UserController.php new file mode 100644 index 0000000..f0a67b5 --- /dev/null +++ b/app/admin/controller/UserController.php @@ -0,0 +1,53 @@ +get('page', 1))); + $pageSize = min(50, max(1, intval($request->get('page_size', 10)))); + + $query = User::query(); + + if ($mobile = $request->get('mobile')) { + $query->where('mobile', 'like', "%{$mobile}%"); + } + + if ($nickname = $request->get('nickname')) { + $query->where('nickname', 'like', "%{$nickname}%"); + } + + $total = $query->count(); + $items = $query->orderBy('id', 'desc') + ->offset(($page - 1) * $pageSize) + ->limit($pageSize) + ->get(); + + return jsonResponse([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize + ]); + } + + public function updateStatus(Request $request) + { + $id = (int)$request->post('id'); + $status = (int)$request->post('status'); + + $user = User::find($id); + if (!$user) { + return jsonResponse(null, '用户不存在', 404); + } + + $user->status = $status === 1 ? 1 : 0; + $user->save(); + + return jsonResponse(null, '更新状态成功'); + } +} diff --git a/app/admin/controller/WechatAppController.php b/app/admin/controller/WechatAppController.php new file mode 100644 index 0000000..31c248e --- /dev/null +++ b/app/admin/controller/WechatAppController.php @@ -0,0 +1,136 @@ +get('page', 1); + $limit = (int)$request->get('limit', 15); + if ($page < 1) $page = 1; + if ($limit < 1) $limit = 15; + + $query = WechatApp::query(); + if (($name = trim((string)$request->get('name', ''))) !== '') { + $query->where('name', 'like', "%{$name}%"); + } + if (($appId = trim((string)$request->get('app_id', ''))) !== '') { + $query->where('app_id', 'like', "%{$appId}%"); + } + if (($type = trim((string)$request->get('type', ''))) !== '') { + $query->where('type', $type); + } + if ($request->get('status') !== null && $request->get('status') !== '') { + $query->where('status', (int)$request->get('status')); + } + + $total = $query->count(); + $list = $query->select([ + 'id', + 'name', + 'type', + 'app_id', + 'status', + 'remark', + 'created_at', + ]) + ->selectRaw("IF(app_secret IS NULL OR app_secret = '', 0, 1) as has_secret") + ->orderByDesc('id') + ->offset(($page - 1) * $limit) + ->limit($limit) + ->get(); + + return jsonResponse([ + 'total' => $total, + 'list' => $list, + ]); + } + + public function create(Request $request) + { + $name = trim((string)$request->post('name', '')); + $type = trim((string)$request->post('type', 'h5')); + $appId = trim((string)$request->post('app_id', '')); + $appSecret = trim((string)$request->post('app_secret', '')); + $status = (int)$request->post('status', 1); + $remark = trim((string)$request->post('remark', '')); + + if ($name === '' || $appId === '') { + return jsonResponse(null, '名称和AppID必填', 400); + } + if (!in_array($type, ['h5', 'mini'], true)) { + return jsonResponse(null, '类型不合法', 400); + } + + try { + $row = WechatApp::create([ + 'name' => $name, + 'type' => $type, + 'app_id' => $appId, + 'app_secret' => $appSecret ?: null, + 'status' => $status ? 1 : 0, + 'remark' => $remark ?: null, + ]); + return jsonResponse($row, '创建成功'); + } catch (\Throwable $e) { + return jsonResponse(null, '创建失败: ' . $e->getMessage(), 500); + } + } + + public function update(Request $request) + { + $id = (int)$request->post('id'); + $row = WechatApp::find($id); + if (!$row) { + return jsonResponse(null, '应用不存在', 404); + } + + $name = trim((string)$request->post('name', $row->name)); + $type = trim((string)$request->post('type', $row->type)); + $appId = trim((string)$request->post('app_id', $row->app_id)); + $appSecret = trim((string)$request->post('app_secret', '')); + $status = (int)$request->post('status', $row->status); + $remark = trim((string)$request->post('remark', $row->remark ?? '')); + + if ($name === '' || $appId === '') { + return jsonResponse(null, '名称和AppID必填', 400); + } + if (!in_array($type, ['h5', 'mini'], true)) { + return jsonResponse(null, '类型不合法', 400); + } + + DB::beginTransaction(); + try { + $row->name = $name; + $row->type = $type; + $row->app_id = $appId; + if ($appSecret !== '') { + $row->app_secret = $appSecret; + } + $row->status = $status ? 1 : 0; + $row->remark = $remark ?: null; + $row->save(); + DB::commit(); + return jsonResponse(null, '更新成功'); + } catch (\Throwable $e) { + DB::rollBack(); + return jsonResponse(null, '更新失败: ' . $e->getMessage(), 500); + } + } + + public function delete(Request $request) + { + $id = (int)$request->post('id'); + $row = WechatApp::find($id); + if (!$row) { + return jsonResponse(null, '应用不存在', 404); + } + $row->delete(); + return jsonResponse(null, '删除成功'); + } +} + diff --git a/app/admin/controller/WechatMerchantController.php b/app/admin/controller/WechatMerchantController.php new file mode 100644 index 0000000..67a5f94 --- /dev/null +++ b/app/admin/controller/WechatMerchantController.php @@ -0,0 +1,336 @@ +get('page', 1); + $limit = (int)$request->get('limit', 15); + if ($page < 1) $page = 1; + if ($limit < 1) $limit = 15; + + $query = WechatMerchant::query(); + + if (($name = trim((string)$request->get('name', ''))) !== '') { + $query->where('name', 'like', "%{$name}%"); + } + if (($mchId = trim((string)$request->get('mch_id', ''))) !== '') { + $query->where('mch_id', 'like', "%{$mchId}%"); + } + if ($request->get('status') !== null && $request->get('status') !== '') { + $query->where('status', (int)$request->get('status')); + } + + $total = $query->count(); + $list = $query->select([ + 'id', + 'name', + 'mode', + 'mch_id', + 'app_id', + 'sub_mch_id', + 'sub_app_id', + 'service_provider', + 'serial_no', + 'notify_url', + 'is_default', + 'status', + 'remark', + 'apiclient_cert_path', + 'apiclient_key_path', + 'created_at', + ]) + ->selectRaw("IF(api_v3_key IS NULL OR api_v3_key = '', 0, 1) as has_api_v3_key") + ->selectRaw("IF((private_key_pem IS NULL OR private_key_pem = '') AND (apiclient_key_path IS NULL OR apiclient_key_path = ''), 0, 1) as has_private_key") + ->selectRaw("IF(apiclient_cert_path IS NULL OR apiclient_cert_path = '', 0, 1) as has_apiclient_cert") + ->orderByDesc('is_default') + ->orderByDesc('id') + ->offset(($page - 1) * $limit) + ->limit($limit) + ->get(); + + return jsonResponse([ + 'total' => $total, + 'list' => $list, + ]); + } + + public function create(Request $request) + { + $name = trim((string)$request->post('name', '')); + $mode = trim((string)$request->post('mode', 'direct')); + $mchId = trim((string)$request->post('mch_id', '')); + $appId = trim((string)$request->post('app_id', '')); + $subMchId = trim((string)$request->post('sub_mch_id', '')); + $subAppId = trim((string)$request->post('sub_app_id', '')); + $serviceProvider = trim((string)$request->post('service_provider', '')); + $serialNo = trim((string)$request->post('serial_no', '')); + $apiV3Key = trim((string)$request->post('api_v3_key', '')); + $privateKeyPem = trim((string)$request->post('private_key_pem', '')); + $notifyUrl = trim((string)$request->post('notify_url', '')); + $remark = trim((string)$request->post('remark', '')); + $status = (int)$request->post('status', 1); + $isDefault = (int)$request->post('is_default', 0) ? 1 : 0; + + if ($name === '' || $mchId === '') { + return jsonResponse(null, '名称和商户号必填', 400); + } + if (!in_array($mode, ['direct', 'service_provider', 'third_party'], true)) { + return jsonResponse(null, '商户类型不合法', 400); + } + if ($mode === 'service_provider' && $subMchId === '') { + return jsonResponse(null, '服务商模式必须填写子商户号', 400); + } + + if ($this->existsConflict(0, $mode, $mchId, $subMchId, $appId, $subAppId)) { + return jsonResponse(null, '该商户配置已存在', 400); + } + + DB::beginTransaction(); + try { + if ($isDefault === 1) { + WechatMerchant::where('is_default', 1)->update(['is_default' => 0]); + } + $row = WechatMerchant::create([ + 'name' => $name, + 'mode' => $mode, + 'mch_id' => $mchId, + 'app_id' => $appId ?: null, + 'serial_no' => $serialNo ?: null, + 'api_v3_key' => $apiV3Key ?: null, + 'private_key_pem' => $privateKeyPem ?: null, + 'notify_url' => $notifyUrl ?: null, + 'sub_mch_id' => $subMchId ?: null, + 'sub_app_id' => $subAppId ?: null, + 'service_provider' => $serviceProvider ?: null, + 'remark' => $remark ?: null, + 'status' => $status ? 1 : 0, + 'is_default' => $isDefault, + ]); + DB::commit(); + return jsonResponse($row, '创建成功'); + } catch (\Throwable $e) { + DB::rollBack(); + return jsonResponse(null, '创建失败: ' . $e->getMessage(), 500); + } + } + + public function update(Request $request) + { + $id = (int)$request->post('id'); + $row = WechatMerchant::find($id); + if (!$row) { + return jsonResponse(null, '商户号不存在', 404); + } + + $name = trim((string)$request->post('name', $row->name)); + $mode = trim((string)$request->post('mode', $row->mode)); + $mchId = trim((string)$request->post('mch_id', $row->mch_id)); + $appId = trim((string)$request->post('app_id', $row->app_id ?? '')); + $subMchId = trim((string)$request->post('sub_mch_id', $row->sub_mch_id ?? '')); + $subAppId = trim((string)$request->post('sub_app_id', $row->sub_app_id ?? '')); + $serviceProvider = trim((string)$request->post('service_provider', $row->service_provider ?? '')); + $serialNo = trim((string)$request->post('serial_no', $row->serial_no ?? '')); + $apiV3Key = trim((string)$request->post('api_v3_key', '')); + $privateKeyPem = trim((string)$request->post('private_key_pem', '')); + $notifyUrl = trim((string)$request->post('notify_url', $row->notify_url ?? '')); + $remark = trim((string)$request->post('remark', $row->remark ?? '')); + $status = (int)$request->post('status', $row->status); + $isDefault = $request->post('is_default') !== null ? ((int)$request->post('is_default') ? 1 : 0) : (int)$row->is_default; + + if ($name === '' || $mchId === '') { + return jsonResponse(null, '名称和商户号必填', 400); + } + if (!in_array($mode, ['direct', 'service_provider', 'third_party'], true)) { + return jsonResponse(null, '商户类型不合法', 400); + } + if ($mode === 'service_provider' && $subMchId === '') { + return jsonResponse(null, '服务商模式必须填写子商户号', 400); + } + + if ($this->existsConflict($id, $mode, $mchId, $subMchId, $appId, $subAppId)) { + return jsonResponse(null, '该商户配置已存在', 400); + } + + DB::beginTransaction(); + try { + if ($isDefault === 1) { + WechatMerchant::where('is_default', 1)->where('id', '<>', $id)->update(['is_default' => 0]); + } + $row->name = $name; + $row->mode = $mode; + $row->mch_id = $mchId; + $row->app_id = $appId ?: null; + $row->serial_no = $serialNo ?: null; + if ($apiV3Key !== '') { + $row->api_v3_key = $apiV3Key; + } + if ($privateKeyPem !== '') { + $row->private_key_pem = $privateKeyPem; + } + $row->notify_url = $notifyUrl ?: null; + $row->sub_mch_id = $subMchId ?: null; + $row->sub_app_id = $subAppId ?: null; + $row->service_provider = $serviceProvider ?: null; + $row->remark = $remark ?: null; + $row->status = $status ? 1 : 0; + $row->is_default = $isDefault; + $row->save(); + DB::commit(); + return jsonResponse(null, '更新成功'); + } catch (\Throwable $e) { + DB::rollBack(); + return jsonResponse(null, '更新失败: ' . $e->getMessage(), 500); + } + } + + public function delete(Request $request) + { + $id = (int)$request->post('id'); + $row = WechatMerchant::find($id); + if (!$row) { + return jsonResponse(null, '商户号不存在', 404); + } + if ((int)$row->is_default === 1) { + return jsonResponse(null, '默认商户号不可删除', 400); + } + $row->delete(); + return jsonResponse(null, '删除成功'); + } + + public function uploadApiclientCert(Request $request) + { + return $this->uploadPem($request, 'apiclient_cert'); + } + + public function uploadApiclientKey(Request $request) + { + return $this->uploadPem($request, 'apiclient_key'); + } + + public function uploadApiV3Key(Request $request) + { + $id = (int)$request->post('id'); + $row = WechatMerchant::find($id); + if (!$row) { + return jsonResponse(null, '商户号不存在', 404); + } + + /** @var UploadFile|null $file */ + $file = $request->file('file'); + if (!$file || !$file->isValid()) { + return jsonResponse(null, '未找到文件或文件无效', 400); + } + + $size = $file->getSize(); + if ($size === false || $size > 1024) { + return jsonResponse(null, '文件过大', 400); + } + + $content = file_get_contents($file->getPathname()); + $content = is_string($content) ? $content : ''; + $key = preg_replace('/\s+/', '', $content); + $key = is_string($key) ? trim($key) : ''; + if ($key === '' || strlen($key) !== 32) { + return jsonResponse(null, 'APIv3 Key 格式不正确(应为32位)', 400); + } + + $row->api_v3_key = $key; + $row->save(); + + return jsonResponse([ + 'id' => $row->id, + 'has_api_v3_key' => 1, + ], '上传成功'); + } + + private function uploadPem(Request $request, string $type) + { + $id = (int)$request->post('id'); + $row = WechatMerchant::find($id); + if (!$row) { + return jsonResponse(null, '商户号不存在', 404); + } + + /** @var UploadFile|null $file */ + $file = $request->file('file'); + if (!$file || !$file->isValid()) { + return jsonResponse(null, '未找到文件或文件无效', 400); + } + $ext = strtolower($file->getUploadExtension()); + if ($ext !== 'pem') { + return jsonResponse(null, '仅支持 pem 文件', 400); + } + + $dir = runtime_path() . '/wechatpay/merchants/' . $row->id; + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + $filename = $type === 'apiclient_cert' ? 'apiclient_cert.pem' : 'apiclient_key.pem'; + $path = $dir . '/' . $filename; + $file->move($path); + @chmod($path, 0600); + + if ($type === 'apiclient_cert') { + $row->apiclient_cert_path = $path; + try { + $certPem = file_get_contents($path); + $x509 = openssl_x509_read($certPem); + if ($x509) { + $info = openssl_x509_parse($x509); + $serialHex = $info['serialNumberHex'] ?? ''; + if (is_string($serialHex) && $serialHex !== '') { + $row->serial_no = $row->serial_no ?: strtoupper($serialHex); + } + } + } catch (\Throwable $e) { + } + } else { + $row->apiclient_key_path = $path; + } + $row->save(); + + return jsonResponse([ + 'id' => $row->id, + 'serial_no' => $row->serial_no, + 'has_apiclient_cert' => $row->apiclient_cert_path ? 1 : 0, + 'has_private_key' => ($row->private_key_pem || $row->apiclient_key_path) ? 1 : 0, + ], '上传成功'); + } + + private function existsConflict(int $id, string $mode, string $mchId, string $subMchId, string $appId, string $subAppId): bool + { + $query = WechatMerchant::query()->where('mode', $mode)->where('mch_id', $mchId); + if ($mode === 'service_provider') { + $query->where('sub_mch_id', $subMchId); + } else { + $query->where(function ($q) { + $q->whereNull('sub_mch_id')->orWhere('sub_mch_id', ''); + }); + } + if ($appId !== '') { + $query->where('app_id', $appId); + } else { + $query->where(function ($q) { + $q->whereNull('app_id')->orWhere('app_id', ''); + }); + } + if ($subAppId !== '') { + $query->where('sub_app_id', $subAppId); + } else { + $query->where(function ($q) { + $q->whereNull('sub_app_id')->orWhere('sub_app_id', ''); + }); + } + if ($id > 0) { + $query->where('id', '<>', $id); + } + return $query->exists(); + } +} diff --git a/app/admin/middleware/AuthMiddleware.php b/app/admin/middleware/AuthMiddleware.php new file mode 100644 index 0000000..08f53ba --- /dev/null +++ b/app/admin/middleware/AuthMiddleware.php @@ -0,0 +1,35 @@ +getBearerToken($request); + $admin = AuthService::getAdminByToken($token); + if (!$admin) { + return jsonResponse(null, '未登录', 401); + } + $request->admin = $admin; + $request->token = $token; + return $handler($request); + } + + protected function getBearerToken(Request $request): ?string + { + $authorization = $request->header('authorization'); + if (!$authorization) { + return null; + } + if (stripos($authorization, 'Bearer ') === 0) { + return trim(substr($authorization, 7)); + } + return trim($authorization); + } +} + diff --git a/app/admin/middleware/PermissionMiddleware.php b/app/admin/middleware/PermissionMiddleware.php new file mode 100644 index 0000000..a4cb0d6 --- /dev/null +++ b/app/admin/middleware/PermissionMiddleware.php @@ -0,0 +1,39 @@ +admin ?? null; + if (!$admin) { + return jsonResponse(null, '未登录', 401); + } + if (intval($admin->is_super) === 1) { + return $handler($request); + } + + $route = $request->route; + $permissionCode = $route ? $route->getName() : null; + if (!$permissionCode) { + return $handler($request); + } + + $admin->loadMissing(['roles.permissions']); + $codes = []; + foreach ($admin->roles as $role) { + foreach ($role->permissions as $permission) { + $codes[$permission->code] = true; + } + } + if (!isset($codes[$permissionCode])) { + return jsonResponse(null, '无权限', 403); + } + return $handler($request); + } +} + diff --git a/app/api/controller/AuthController.php b/app/api/controller/AuthController.php new file mode 100644 index 0000000..b964e22 --- /dev/null +++ b/app/api/controller/AuthController.php @@ -0,0 +1,49 @@ +post('mobile', '')); + $code = trim((string)$request->post('code', '')); + if ($mobile === '' || $code === '') { + return jsonResponse(null, '参数错误', 400); + } + if (!preg_match('/^\d{11}$/', $mobile)) { + return jsonResponse(null, '手机号格式错误', 400); + } + + $user = User::firstOrCreate( + ['mobile' => $mobile], + ['nickname' => '用户' . substr($mobile, -4), 'status' => 1] + ); + if (intval($user->status) !== 1) { + return jsonResponse(null, '账号已禁用', 403); + } + + $token = AuthService::issueUserToken($user); + return jsonResponse([ + 'token' => $token, + 'user' => $user + ], '登录成功'); + } + + public function me(Request $request) + { + return jsonResponse([ + 'user' => $request->user + ]); + } + + public function logout(Request $request) + { + AuthService::revokeUserToken($request->token ?? null); + return jsonResponse(null, '已退出登录'); + } +} + diff --git a/app/api/controller/OrderController.php b/app/api/controller/OrderController.php new file mode 100644 index 0000000..5b6571f --- /dev/null +++ b/app/api/controller/OrderController.php @@ -0,0 +1,132 @@ +post(); + $userId = $request->user->id; + + try { + $order = OrderFlowService::createOrder($params, $userId); + + return jsonResponse([ + 'order_id' => $order->id, + 'order_no' => $order->order_no, + 'pay_amount' => $order->total_price + ], '下单成功'); + } catch (\Exception $e) { + return jsonResponse(null, '下单失败: ' . $e->getMessage(), 400); + } + } + + public function list(Request $request) + { + $status = $request->get('status', 'all'); + $userId = $request->user->id; + + $query = Order::where('user_id', $userId); + if ($status !== 'all') { + $query->where('status', $status); + } + + $orders = $query->orderBy('id', 'desc')->get(); + + return jsonResponse(['items' => $orders, 'total' => $orders->count()]); + } + + public function detail(Request $request, $id) + { + $userId = $request->user->id; + $order = Order::with(['logs'])->where('id', $id)->where('user_id', $userId)->first(); + + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + $timeline = []; + $isFirst = true; + foreach ($order->logs as $log) { + $timeline[] = [ + 'title' => $log->title, + 'time' => $log->created_at->format('Y-m-d H:i:s'), + 'desc' => $log->description, + 'is_current' => $isFirst, + 'is_done' => true + ]; + $isFirst = false; + } + + return jsonResponse([ + 'id' => $order->id, + 'order_no' => $order->order_no, + 'category' => $order->category, + 'service_type' => $order->service_type, + 'status' => $order->status, + 'is_fast' => (bool)$order->is_fast, + 'express_company' => $order->express_company, + 'express_no' => $order->express_no, + 'timeline' => $timeline + ]); + } + + public function pay(Request $request) + { + $orderId = (int)$request->post('order_id'); + $userId = $request->user->id; + $payType = trim((string)$request->post('pay_type', 'jsapi')); + $appId = trim((string)$request->post('app_id', '')); + + $order = Order::where('id', $orderId)->where('user_id', $userId)->first(); + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + try { + if ($payType === 'native') { + $pay = PaymentService::createWechatNativePay($order); + return jsonResponse($pay, '支付发起成功'); + } + + if ($appId === '') { + return jsonResponse(null, '缺少 app_id,无法发起 JSAPI 支付', 400); + } + + $openid = trim((string)$request->post('openid', '')); + $pay = PaymentService::createWechatJsapiPay($order, $appId, $openid); + return jsonResponse($pay, '支付发起成功'); + } catch (\Throwable $e) { + return jsonResponse(null, $e->getMessage(), 400); + } + } + + public function ship(Request $request) + { + $orderId = (int)$request->post('order_id'); + $expressCompany = trim($request->post('express_company', '')); + $expressNo = trim($request->post('express_no', '')); + + if (!$expressCompany || !$expressNo) { + return jsonResponse(null, '物流信息不完整', 400); + } + + $userId = $request->user->id; + $order = Order::where('id', $orderId)->where('user_id', $userId)->first(); + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + try { + OrderFlowService::userShip($order, $expressCompany, $expressNo); + return jsonResponse(null, '发货信息已提交'); + } catch (\Exception $e) { + return jsonResponse(null, $e->getMessage(), 400); + } + } +} diff --git a/app/api/controller/PayController.php b/app/api/controller/PayController.php new file mode 100644 index 0000000..f887037 --- /dev/null +++ b/app/api/controller/PayController.php @@ -0,0 +1,103 @@ +rawBody(); + + $timestamp = (string)$request->header('Wechatpay-Timestamp', ''); + $nonce = (string)$request->header('Wechatpay-Nonce', ''); + $signature = (string)$request->header('Wechatpay-Signature', ''); + if ($timestamp === '' || $nonce === '' || $signature === '') { + return json(['code' => 'FAIL', 'message' => 'missing headers'], 400); + } + + $merchants = WechatMerchant::where('status', 1)->get(); + if ($merchants->count() === 0) { + return json(['code' => 'FAIL', 'message' => 'no merchant'], 500); + } + + $client = new WechatPayV3Client($merchants->first()); + $ok = $client->verifyPlatformSignature($timestamp, $nonce, $body, $signature); + if (!$ok) { + return json(['code' => 'FAIL', 'message' => 'invalid signature'], 400); + } + + $payload = json_decode($body, true) ?: []; + $resource = $payload['resource'] ?? null; + if (!is_array($resource)) { + return json(['code' => 'FAIL', 'message' => 'invalid body'], 400); + } + + $decrypt = null; + $matchedMerchant = null; + foreach ($merchants as $m) { + $apiV3Key = (string)($m->api_v3_key ?? ''); + if ($apiV3Key === '') continue; + try { + $decrypt = $client->decryptNotifyResource($resource, $apiV3Key); + $matchedMerchant = $m; + break; + } catch (\Throwable $e) { + } + } + if (!$decrypt || !$matchedMerchant) { + return json(['code' => 'FAIL', 'message' => 'decrypt failed'], 400); + } + + $outTradeNo = (string)($decrypt['out_trade_no'] ?? ''); + $tradeState = (string)($decrypt['trade_state'] ?? ''); + if ($outTradeNo === '') { + return json(['code' => 'FAIL', 'message' => 'missing out_trade_no'], 400); + } + + $tx = PaymentTransaction::where('out_trade_no', $outTradeNo)->first(); + if (!$tx) { + return json(['code' => 'SUCCESS', 'message' => 'OK']); + } + + if ($tradeState !== 'SUCCESS') { + return json(['code' => 'SUCCESS', 'message' => 'OK']); + } + + DB::beginTransaction(); + try { + $tx->status = 'paid'; + $tx->paid_at = date('Y-m-d H:i:s'); + $tx->raw_json = $decrypt; + $tx->save(); + + $order = Order::find($tx->order_id); + if ($order) { + $order->pay_channel = 'wechat'; + $order->pay_status = 'paid'; + $order->pay_merchant_id = (int)$matchedMerchant->id; + $order->pay_out_trade_no = $outTradeNo; + if (!$order->pay_time) { + $order->pay_time = date('Y-m-d H:i:s'); + } + $order->save(); + + OrderFlowService::payOrder($order); + } + + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + return json(['code' => 'FAIL', 'message' => 'server error'], 500); + } + + return json(['code' => 'SUCCESS', 'message' => 'OK']); + } +} + diff --git a/app/api/controller/ReportController.php b/app/api/controller/ReportController.php new file mode 100644 index 0000000..003d22a --- /dev/null +++ b/app/api/controller/ReportController.php @@ -0,0 +1,70 @@ +get('order_id'); + $userId = $request->user->id; + + $order = Order::where('id', $orderId)->where('user_id', $userId)->first(); + if (!$order) { + return jsonResponse(null, '订单不存在', 404); + } + + $report = Report::with(['inspector'])->where('order_id', $orderId)->first(); + if (!$report) { + return jsonResponse(null, '报告尚未出具', 404); + } + + return jsonResponse([ + 'report_no' => $report->report_no, + 'conclusion' => $report->conclusion, + 'level' => $report->level, + 'flaws' => $report->flaws_json, + 'images' => $report->images_json, + 'verify_code' => $report->verify_code, + 'created_at' => $report->created_at->format('Y-m-d H:i:s'), + 'inspector' => [ + 'name' => $report->inspector->nickname ?? $report->inspector->username, + ] + ]); + } + + // 公开验证防伪码 (无需登录) + public function verify(Request $request) + { + $code = trim($request->get('code', '')); + if (!$code) { + return jsonResponse(null, '防伪码不能为空', 400); + } + + $report = Report::with(['order', 'inspector'])->where('verify_code', $code)->first(); + if (!$report) { + return jsonResponse(null, '无效的防伪码或报告不存在', 404); + } + + return jsonResponse([ + 'report_no' => $report->report_no, + 'conclusion' => $report->conclusion, + 'level' => $report->level, + 'flaws' => $report->flaws_json, + 'images' => $report->images_json, + 'created_at' => $report->created_at->format('Y-m-d H:i:s'), + 'order' => [ + 'category' => $report->order->category, + 'brand' => $report->order->brand, + 'model' => $report->order->model, + ], + 'inspector' => [ + 'name' => $report->inspector->nickname ?? $report->inspector->username, + ] + ], '验证成功,该报告真实有效'); + } +} diff --git a/app/api/controller/UploadController.php b/app/api/controller/UploadController.php new file mode 100644 index 0000000..7723107 --- /dev/null +++ b/app/api/controller/UploadController.php @@ -0,0 +1,37 @@ +file('file'); + if (!$file || !$file->isValid()) { + return jsonResponse(null, '未找到文件或文件无效', 400); + } + + $ext = strtolower($file->getUploadExtension()); + if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { + return jsonResponse(null, '仅支持图片文件', 400); + } + + $dir = public_path() . '/upload/images/' . date('Ymd'); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + $filename = uniqid() . bin2hex(random_bytes(4)) . '.' . $ext; + $path = $dir . '/' . $filename; + $file->move($path); + + $url = '/upload/images/' . date('Ymd') . '/' . $filename; + + return jsonResponse([ + 'url' => $url, + 'name' => $file->getUploadName(), + ], '上传成功'); + } +} diff --git a/app/api/controller/UserController.php b/app/api/controller/UserController.php new file mode 100644 index 0000000..3ffc5ca --- /dev/null +++ b/app/api/controller/UserController.php @@ -0,0 +1,49 @@ +user->id; + + $totalOrders = Order::where('user_id', $userId)->count(); + $totalReports = Report::whereHas('order', function ($query) use ($userId) { + $query->where('user_id', $userId); + })->count(); + + return jsonResponse([ + 'total_orders' => $totalOrders, + 'total_reports' => $totalReports + ]); + } + + public function updateInfo(Request $request) + { + $userId = $request->user->id; + $user = User::find($userId); + + if (!$user) { + return jsonResponse(null, '用户异常', 404); + } + + $nickname = trim($request->post('nickname', '')); + $avatar = trim($request->post('avatar', '')); + + if ($nickname) { + $user->nickname = $nickname; + } + if ($avatar) { + $user->avatar = $avatar; + } + + $user->save(); + + return jsonResponse($user, '更新成功'); + } +} diff --git a/app/api/controller/WechatAuthController.php b/app/api/controller/WechatAuthController.php new file mode 100644 index 0000000..44b4af1 --- /dev/null +++ b/app/api/controller/WechatAuthController.php @@ -0,0 +1,205 @@ +get('type', '')); + $query = WechatApp::query()->where('status', 1); + if ($type !== '') { + $query->where('type', $type); + } + $list = $query->select(['id', 'name', 'type', 'app_id'])->orderByDesc('id')->get(); + return jsonResponse($list); + } + + public function miniLogin(Request $request) + { + $appId = trim((string)$request->post('app_id', '')); + $code = trim((string)$request->post('code', '')); + if ($appId === '' || $code === '') { + return jsonResponse(null, '参数错误', 400); + } + + try { + $app = $this->getApp($appId, 'mini'); + } catch (\Throwable $e) { + return jsonResponse(null, $e->getMessage(), 400); + } + $secret = (string)($app->app_secret ?? ''); + if ($secret === '') { + return jsonResponse(null, '未配置 app_secret', 400); + } + + $url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' . urlencode($appId) . + '&secret=' . urlencode($secret) . + '&js_code=' . urlencode($code) . + '&grant_type=authorization_code'; + $res = $this->httpGetJson($url); + $openid = isset($res['openid']) ? trim((string)$res['openid']) : ''; + $unionid = isset($res['unionid']) ? trim((string)$res['unionid']) : ''; + if ($openid === '') { + $msg = $res['errmsg'] ?? '获取 openid 失败'; + return jsonResponse(null, (string)$msg, 400); + } + + $user = $this->resolveUserByWechatIdentity($appId, $openid, $unionid, 'mini'); + if (intval($user->status) !== 1) { + return jsonResponse(null, '账号已禁用', 403); + } + + $this->upsertIdentity($user->id, $appId, $openid, $unionid, 'mini'); + $user->openid = $openid; + $user->save(); + + $token = AuthService::issueUserToken($user); + return jsonResponse([ + 'token' => $token, + 'user' => $user, + 'openid' => $openid, + 'app_id' => $appId, + ], '登录成功'); + } + + public function h5Login(Request $request) + { + $appId = trim((string)$request->post('app_id', '')); + $code = trim((string)$request->post('code', '')); + if ($appId === '' || $code === '') { + return jsonResponse(null, '参数错误', 400); + } + + try { + $app = $this->getApp($appId, 'h5'); + } catch (\Throwable $e) { + return jsonResponse(null, $e->getMessage(), 400); + } + $secret = (string)($app->app_secret ?? ''); + if ($secret === '') { + return jsonResponse(null, '未配置 app_secret', 400); + } + + $url = 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=' . urlencode($appId) . + '&secret=' . urlencode($secret) . + '&code=' . urlencode($code) . + '&grant_type=authorization_code'; + $res = $this->httpGetJson($url); + $openid = isset($res['openid']) ? trim((string)$res['openid']) : ''; + $unionid = isset($res['unionid']) ? trim((string)$res['unionid']) : ''; + if ($openid === '') { + $msg = $res['errmsg'] ?? '获取 openid 失败'; + return jsonResponse(null, (string)$msg, 400); + } + + $user = $this->resolveUserByWechatIdentity($appId, $openid, $unionid, 'h5'); + if (intval($user->status) !== 1) { + return jsonResponse(null, '账号已禁用', 403); + } + + $this->upsertIdentity($user->id, $appId, $openid, $unionid, 'h5'); + $user->openid = $openid; + $user->save(); + + $token = AuthService::issueUserToken($user); + return jsonResponse([ + 'token' => $token, + 'user' => $user, + 'openid' => $openid, + 'app_id' => $appId, + ], '登录成功'); + } + + public function h5AuthorizeUrl(Request $request) + { + $appId = trim((string)$request->get('app_id', '')); + $redirectUri = trim((string)$request->get('redirect_uri', '')); + $scope = trim((string)$request->get('scope', 'snsapi_base')); + $state = trim((string)$request->get('state', '')); + + if ($appId === '' || $redirectUri === '') { + return jsonResponse(null, '参数错误', 400); + } + if (!in_array($scope, ['snsapi_base', 'snsapi_userinfo'], true)) { + return jsonResponse(null, 'scope 不合法', 400); + } + + try { + $this->getApp($appId, 'h5'); + } catch (\Throwable $e) { + return jsonResponse(null, $e->getMessage(), 400); + } + + $url = 'https://open.weixin.qq.com/connect/oauth2/authorize?appid=' . urlencode($appId) . + '&redirect_uri=' . urlencode($redirectUri) . + '&response_type=code&scope=' . urlencode($scope) . + '&state=' . urlencode($state) . + '#wechat_redirect'; + + return jsonResponse(['url' => $url]); + } + + private function getApp(string $appId, string $type): WechatApp + { + $row = WechatApp::where('app_id', $appId)->where('status', 1)->first(); + if (!$row) { + throw new \RuntimeException('AppID 未配置或已停用'); + } + if ((string)$row->type !== $type) { + throw new \RuntimeException('AppID 类型不匹配'); + } + return $row; + } + + private function httpGetJson(string $url): array + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + $body = curl_exec($ch); + if ($body === false) { + $err = curl_error($ch); + curl_close($ch); + throw new \RuntimeException('微信接口请求失败: ' . $err); + } + curl_close($ch); + return json_decode($body, true) ?: []; + } + + private function resolveUserByWechatIdentity(string $appId, string $openid, string $unionid, string $scene): User + { + if ($unionid !== '') { + $identity = UserWechatIdentity::where('unionid', $unionid)->first(); + if ($identity) { + $user = User::find($identity->user_id); + if ($user) return $user; + } + } + + $identity = UserWechatIdentity::where('app_id', $appId)->where('openid', $openid)->first(); + if ($identity) { + $user = User::find($identity->user_id); + if ($user) return $user; + } + + return User::create([ + 'openid' => $openid, + 'nickname' => $scene === 'mini' ? '小程序用户' : '微信用户', + 'status' => 1, + ]); + } + + private function upsertIdentity(int $userId, string $appId, string $openid, string $unionid, string $scene): void + { + UserWechatIdentity::updateOrCreate( + ['user_id' => $userId, 'app_id' => $appId], + ['openid' => $openid, 'unionid' => $unionid ?: null, 'scene' => $scene] + ); + } +} diff --git a/app/api/middleware/AuthMiddleware.php b/app/api/middleware/AuthMiddleware.php new file mode 100644 index 0000000..744f386 --- /dev/null +++ b/app/api/middleware/AuthMiddleware.php @@ -0,0 +1,35 @@ +getBearerToken($request); + $user = AuthService::getUserByToken($token); + if (!$user) { + return jsonResponse(null, '未登录', 401); + } + $request->user = $user; + $request->token = $token; + return $handler($request); + } + + protected function getBearerToken(Request $request): ?string + { + $authorization = $request->header('authorization'); + if (!$authorization) { + return null; + } + if (stripos($authorization, 'Bearer ') === 0) { + return trim(substr($authorization, 7)); + } + return trim($authorization); + } +} + diff --git a/app/common/exception/BusinessException.php b/app/common/exception/BusinessException.php new file mode 100644 index 0000000..1e49d14 --- /dev/null +++ b/app/common/exception/BusinessException.php @@ -0,0 +1,24 @@ +data = $data; + parent::__construct($message, $code, $previous); + } + + public function getData() + { + return $this->data; + } +} diff --git a/app/common/model/AdminToken.php b/app/common/model/AdminToken.php new file mode 100644 index 0000000..7790b90 --- /dev/null +++ b/app/common/model/AdminToken.php @@ -0,0 +1,15 @@ + 'datetime', + ]; +} + diff --git a/app/common/model/AdminUser.php b/app/common/model/AdminUser.php new file mode 100644 index 0000000..3846de8 --- /dev/null +++ b/app/common/model/AdminUser.php @@ -0,0 +1,17 @@ +belongsToMany(Role::class, 'admin_roles', 'admin_id', 'role_id'); + } +} + diff --git a/app/common/model/Order.php b/app/common/model/Order.php new file mode 100644 index 0000000..52abdbb --- /dev/null +++ b/app/common/model/Order.php @@ -0,0 +1,20 @@ +hasMany(OrderLog::class, 'order_id', 'id')->orderBy('created_at', 'desc'); + } +} diff --git a/app/common/model/OrderLog.php b/app/common/model/OrderLog.php new file mode 100644 index 0000000..c9cd991 --- /dev/null +++ b/app/common/model/OrderLog.php @@ -0,0 +1,11 @@ + 'array', + ]; +} diff --git a/app/common/model/Permission.php b/app/common/model/Permission.php new file mode 100644 index 0000000..6d61a4b --- /dev/null +++ b/app/common/model/Permission.php @@ -0,0 +1,12 @@ + 'array', + 'images_json' => 'array', + ]; + + public function order() + { + return $this->belongsTo(Order::class, 'order_id'); + } + + public function inspector() + { + return $this->belongsTo(AdminUser::class, 'inspector_id'); + } +} diff --git a/app/common/model/Role.php b/app/common/model/Role.php new file mode 100644 index 0000000..b9490bd --- /dev/null +++ b/app/common/model/Role.php @@ -0,0 +1,17 @@ +belongsToMany(Permission::class, 'role_permissions', 'role_id', 'permission_id'); + } +} + diff --git a/app/common/model/User.php b/app/common/model/User.php new file mode 100644 index 0000000..a214808 --- /dev/null +++ b/app/common/model/User.php @@ -0,0 +1,12 @@ + 'datetime', + ]; +} + diff --git a/app/common/model/UserWechatIdentity.php b/app/common/model/UserWechatIdentity.php new file mode 100644 index 0000000..d1378b2 --- /dev/null +++ b/app/common/model/UserWechatIdentity.php @@ -0,0 +1,12 @@ + $user->id, + 'token_hash' => $hash, + 'expired_at' => $ttl > 0 ? Carbon::now()->addSeconds($ttl) : null, + ]); + + return $token; + } + + public static function getUserByToken(?string $token): ?User + { + if (!$token) { + return null; + } + $hash = hashToken($token); + $row = UserToken::where('token_hash', $hash)->first(); + if (!$row) { + return null; + } + if ($row->expired_at && $row->expired_at->lt(Carbon::now())) { + return null; + } + return User::find($row->user_id); + } + + public static function revokeUserToken(?string $token): void + { + if (!$token) { + return; + } + UserToken::where('token_hash', hashToken($token))->delete(); + } + + public static function issueAdminToken(AdminUser $admin): string + { + $ttl = intval(getenv('ADMIN_TOKEN_TTL') ?: 86400); + $token = generateToken(); + $hash = hashToken($token); + + AdminToken::create([ + 'admin_id' => $admin->id, + 'token_hash' => $hash, + 'expired_at' => $ttl > 0 ? Carbon::now()->addSeconds($ttl) : null, + ]); + + return $token; + } + + public static function getAdminByToken(?string $token): ?AdminUser + { + if (!$token) { + return null; + } + $hash = hashToken($token); + $row = AdminToken::where('token_hash', $hash)->first(); + if (!$row) { + return null; + } + if ($row->expired_at && $row->expired_at->lt(Carbon::now())) { + return null; + } + return AdminUser::find($row->admin_id); + } + + public static function revokeAdminToken(?string $token): void + { + if (!$token) { + return; + } + AdminToken::where('token_hash', hashToken($token))->delete(); + } +} + diff --git a/app/common/service/OrderFlowService.php b/app/common/service/OrderFlowService.php new file mode 100644 index 0000000..1534496 --- /dev/null +++ b/app/common/service/OrderFlowService.php @@ -0,0 +1,126 @@ + $orderNo, + 'user_id' => $userId, + 'category' => $params['category'] ?? '奢品包袋', + 'service_type' => $params['service_type'] ?? '真伪鉴定', + 'brand' => $params['brand'] ?? '未知品牌', + 'model' => $params['model'] ?? '', + 'remark' => $params['remark'] ?? '', + 'is_fast' => $params['is_fast'] ?? 0, + 'total_price' => $params['total_price'] ?? 49.00, + 'status' => 'wait_pay', + ]); + + // 2. 写入初始流转日志 + self::addLog($order->id, 'create', '订单已创建', '等待用户支付', 'user', $userId); + + return $order; + } + + /** + * 模拟支付成功 + */ + public static function payOrder(Order $order) + { + if ($order->status !== 'wait_pay') { + return $order; + } + + $order->status = 'shipping'; // 待寄送 + $order->pay_time = date('Y-m-d H:i:s'); + $order->pay_status = 'paid'; + $order->save(); + + self::addLog($order->id, 'pay_success', '支付成功', '请尽快寄出物品', 'user', $order->user_id); + + return $order; + } + + /** + * 用户填写物流并发货 + */ + public static function userShip(Order $order, string $expressCompany, string $expressNo) + { + if ($order->status !== 'shipping') { + throw new Exception("当前状态不允许发货"); + } + + $order->express_company = $expressCompany; + $order->express_no = $expressNo; + $order->status = 'wait_receive'; // 等待平台收件(可复用为在途) + $order->save(); + + self::addLog($order->id, 'user_ship', '物品已寄出', "物流公司: {$expressCompany}, 单号: {$expressNo}", 'user', $order->user_id); + + return $order; + } + + /** + * 平台确认收件并开始鉴定 + */ + public static function adminReceive(Order $order, int $adminId) + { + if ($order->status !== 'wait_receive' && $order->status !== 'shipping') { + throw new Exception("当前状态不允许收件"); + } + + $order->status = 'inspecting'; + $order->save(); + + self::addLog($order->id, 'admin_receive', '平台已收件', '物品已入库,即将开始鉴定', 'admin', $adminId); + + return $order; + } + + public static function adminReturnShip(Order $order, int $adminId, string $expressCompany, string $expressNo) + { + if ($order->status !== 'finished') { + throw new Exception("当前状态不允许回寄"); + } + if ($expressCompany === '' || $expressNo === '') { + throw new Exception("回寄物流信息不完整"); + } + + $order->return_express_company = $expressCompany; + $order->return_express_no = $expressNo; + $order->return_ship_time = date('Y-m-d H:i:s'); + $order->status = 'return_shipping'; + $order->save(); + + self::addLog($order->id, 'return_ship', '已回寄', "物流公司: {$expressCompany}, 单号: {$expressNo}", 'admin', $adminId); + + return $order; + } + + /** + * 写入时间轴日志 + */ + public static function addLog(int $orderId, string $actionType, string $title, string $desc = '', string $operatorType = 'system', int $operatorId = 0) + { + return OrderLog::create([ + 'order_id' => $orderId, + 'action_type' => $actionType, + 'title' => $title, + 'description' => $desc, + 'operator_type' => $operatorType, + 'operator_id' => $operatorId + ]); + } +} diff --git a/app/common/service/PaymentService.php b/app/common/service/PaymentService.php new file mode 100644 index 0000000..3b6f7a0 --- /dev/null +++ b/app/common/service/PaymentService.php @@ -0,0 +1,255 @@ +status !== 'wait_pay') { + throw new \RuntimeException('订单状态不正确'); + } + + $merchant = self::selectWechatMerchantForJsapi($order, $appId); + if (!$merchant) { + throw new \RuntimeException('未配置可用的微信商户号'); + } + + $notifyUrl = (string)($merchant->notify_url ?? ''); + if ($notifyUrl === '') { + $notifyUrl = (string)(getenv('WECHATPAY_NOTIFY_URL') ?: ''); + } + if ($notifyUrl === '') { + throw new \RuntimeException('回调地址未配置'); + } + + $outTradeNo = self::genOutTradeNo($order); + $amountFen = (int)round(((float)$order->total_price) * 100); + $description = '安心验-鉴定订单 ' . $order->order_no; + + $openid = trim($openid); + if ($openid === '') { + $identity = UserWechatIdentity::where('user_id', $order->user_id)->where('app_id', $appId)->first(); + if ($identity) { + $openid = (string)$identity->openid; + } + } + if ($openid === '') { + throw new \RuntimeException('缺少 openid,无法发起 JSAPI 支付'); + } + + DB::beginTransaction(); + try { + $tx = PaymentTransaction::create([ + 'order_id' => $order->id, + 'channel' => 'wechat', + 'merchant_id' => $merchant->id, + 'out_trade_no' => $outTradeNo, + 'amount' => $order->total_price, + 'status' => 'created', + ]); + + $order->pay_channel = 'wechat'; + $order->pay_status = 'paying'; + $order->pay_merchant_id = $merchant->id; + $order->pay_out_trade_no = $outTradeNo; + $order->save(); + + $client = new WechatPayV3Client($merchant); + $resp = $client->createJsapiTransaction($outTradeNo, $description, $amountFen, $notifyUrl, $openid); + + $tx->prepay_id = $resp['prepay_id'] ?? null; + $tx->raw_json = $resp; + $tx->save(); + + if (!$tx->prepay_id) { + throw new \RuntimeException('微信支付下单失败:缺少 prepay_id'); + } + + $payParams = $client->buildJsapiPayParams($appId, $tx->prepay_id); + + DB::commit(); + + return [ + 'channel' => 'wechat', + 'pay_type' => 'jsapi', + 'out_trade_no' => $outTradeNo, + 'merchant' => [ + 'id' => $merchant->id, + 'name' => $merchant->name, + 'mode' => $merchant->mode, + 'mch_id' => $merchant->mch_id, + ], + 'pay_params' => $payParams, + ]; + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + public static function createWechatNativePay(Order $order): array + { + if ($order->status !== 'wait_pay') { + throw new \RuntimeException('订单状态不正确'); + } + + $merchant = self::selectWechatMerchant($order); + if (!$merchant) { + throw new \RuntimeException('未配置可用的微信商户号'); + } + + $notifyUrl = (string)($merchant->notify_url ?? ''); + if ($notifyUrl === '') { + $notifyUrl = (string)(getenv('WECHATPAY_NOTIFY_URL') ?: ''); + } + if ($notifyUrl === '') { + throw new \RuntimeException('回调地址未配置'); + } + + $outTradeNo = self::genOutTradeNo($order); + $amountFen = (int)round(((float)$order->total_price) * 100); + $description = '安心验-鉴定订单 ' . $order->order_no; + + DB::beginTransaction(); + try { + $tx = PaymentTransaction::create([ + 'order_id' => $order->id, + 'channel' => 'wechat', + 'merchant_id' => $merchant->id, + 'out_trade_no' => $outTradeNo, + 'amount' => $order->total_price, + 'status' => 'created', + ]); + + $order->pay_channel = 'wechat'; + $order->pay_status = 'paying'; + $order->pay_merchant_id = $merchant->id; + $order->pay_out_trade_no = $outTradeNo; + $order->save(); + + $client = new WechatPayV3Client($merchant); + $resp = $client->createNativeTransaction($outTradeNo, $description, $amountFen, $notifyUrl); + + $tx->prepay_id = $resp['prepay_id'] ?? null; + $tx->code_url = $resp['code_url'] ?? null; + $tx->raw_json = $resp; + $tx->save(); + + DB::commit(); + + return [ + 'channel' => 'wechat', + 'pay_type' => 'native', + 'out_trade_no' => $outTradeNo, + 'code_url' => $tx->code_url, + 'merchant' => [ + 'id' => $merchant->id, + 'name' => $merchant->name, + 'mode' => $merchant->mode, + 'mch_id' => $merchant->mch_id, + ], + ]; + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + public static function selectWechatMerchant(Order $order): ?WechatMerchant + { + $list = WechatMerchant::where('status', 1) + ->whereIn('mode', ['direct', 'service_provider']) + ->whereNotNull('serial_no')->where('serial_no', '<>', '') + ->whereNotNull('api_v3_key')->where('api_v3_key', '<>', '') + ->where(function ($q) { + $q->where(function ($q2) { + $q2->whereNotNull('private_key_pem')->where('private_key_pem', '<>', ''); + })->orWhere(function ($q2) { + $q2->whereNotNull('apiclient_key_path')->where('apiclient_key_path', '<>', ''); + }); + }) + ->orderByDesc('is_default') + ->orderBy('id') + ->get(); + if ($list->count() === 0) return null; + if ($list->count() === 1) return $list->first(); + + $default = $list->firstWhere('is_default', 1); + if ($default) return $default; + + $idx = abs(crc32((string)$order->order_no)) % $list->count(); + return $list->values()->get($idx); + } + + public static function selectWechatMerchantForJsapi(Order $order, string $appId): ?WechatMerchant + { + $appId = trim($appId); + if ($appId === '') { + throw new \RuntimeException('缺少 app_id'); + } + + $list = WechatMerchant::where('status', 1) + ->whereIn('mode', ['direct', 'service_provider']) + ->whereNotNull('serial_no')->where('serial_no', '<>', '') + ->whereNotNull('api_v3_key')->where('api_v3_key', '<>', '') + ->where(function ($q) { + $q->where(function ($q2) { + $q2->whereNotNull('private_key_pem')->where('private_key_pem', '<>', ''); + })->orWhere(function ($q2) { + $q2->whereNotNull('apiclient_key_path')->where('apiclient_key_path', '<>', ''); + }); + }) + ->get() + ->filter(function ($m) use ($appId) { + $mode = (string)($m->mode ?? ''); + if ($mode === 'direct') { + return (string)($m->app_id ?? '') === $appId; + } + if ($mode === 'service_provider') { + $subAppId = (string)($m->sub_app_id ?? ''); + if ($subAppId !== '') return $subAppId === $appId; + return (string)($m->app_id ?? '') === $appId; + } + return false; + }) + ->values(); + + if ($list->count() === 0) return null; + if ($list->count() === 1) return $list->first(); + + $default = $list->firstWhere('is_default', 1); + if ($default) return $default; + + $idx = abs(crc32((string)$order->order_no)) % $list->count(); + return $list->get($idx); + } + + private static function genOutTradeNo(Order $order): string + { + return 'AXY' . date('YmdHis') . $order->id . random_int(1000, 9999); + } + + private static function resolveJsapiAppId(WechatMerchant $merchant): string + { + $mode = (string)($merchant->mode ?? 'direct'); + $appId = (string)($merchant->app_id ?? ''); + $subAppId = (string)($merchant->sub_app_id ?? ''); + + if ($mode === 'direct') { + if ($appId === '') throw new \RuntimeException('商户 AppID 未配置'); + return $appId; + } + if ($mode === 'service_provider') { + if ($subAppId !== '') return $subAppId; + if ($appId === '') throw new \RuntimeException('服务商 AppID 未配置'); + return $appId; + } + throw new \RuntimeException('该商户类型暂不支持 JSAPI'); + } +} diff --git a/app/common/service/WechatPayV3Client.php b/app/common/service/WechatPayV3Client.php new file mode 100644 index 0000000..f964df2 --- /dev/null +++ b/app/common/service/WechatPayV3Client.php @@ -0,0 +1,276 @@ +merchant = $merchant; + } + + public function createNativeTransaction(string $outTradeNo, string $description, int $amountFen, string $notifyUrl): array + { + $body = $this->buildPayBody($outTradeNo, $description, $amountFen, $notifyUrl); + $resp = $this->request('POST', '/v3/pay/transactions/native', $body); + + $data = json_decode($resp['body'], true) ?: []; + if ($resp['status'] >= 200 && $resp['status'] < 300) { + return $data; + } + $message = $data['message'] ?? ('微信支付下单失败 HTTP ' . $resp['status']); + throw new \RuntimeException($message); + } + + public function createJsapiTransaction(string $outTradeNo, string $description, int $amountFen, string $notifyUrl, string $openid): array + { + $body = $this->buildJsapiPayBody($outTradeNo, $description, $amountFen, $notifyUrl, $openid); + $resp = $this->request('POST', '/v3/pay/transactions/jsapi', $body); + + $data = json_decode($resp['body'], true) ?: []; + if ($resp['status'] >= 200 && $resp['status'] < 300) { + return $data; + } + $message = $data['message'] ?? ('微信支付下单失败 HTTP ' . $resp['status']); + throw new \RuntimeException($message); + } + + public function buildJsapiPayParams(string $appId, string $prepayId): array + { + $mchId = (string)$this->merchant->mch_id; + $serialNo = (string)($this->merchant->serial_no ?? ''); + $privateKeyPem = (string)($this->merchant->private_key_pem ?? ''); + if ($privateKeyPem === '') { + $privateKeyPem = $this->loadKeyPemFromFile(); + } + if ($mchId === '' || $serialNo === '' || $privateKeyPem === '') { + throw new \RuntimeException('微信支付密钥未配置(mch_id/serial_no/private_key_pem)'); + } + + $timeStamp = (string)time(); + $nonceStr = bin2hex(random_bytes(16)); + $package = 'prepay_id=' . $prepayId; + + $signStr = $appId . "\n" . $timeStamp . "\n" . $nonceStr . "\n" . $package . "\n"; + $privateKey = openssl_pkey_get_private($privateKeyPem); + if (!$privateKey) { + throw new \RuntimeException('私钥格式错误'); + } + openssl_sign($signStr, $signature, $privateKey, OPENSSL_ALGO_SHA256); + + return [ + 'appId' => $appId, + 'timeStamp' => $timeStamp, + 'nonceStr' => $nonceStr, + 'package' => $package, + 'signType' => 'RSA', + 'paySign' => base64_encode($signature), + ]; + } + + public function verifyPlatformSignature(string $timestamp, string $nonce, string $body, string $signature): bool + { + $certPath = getenv('WECHATPAY_PLATFORM_CERT_PATH') ?: ''; + if ($certPath === '' || !file_exists($certPath)) { + throw new \RuntimeException('平台证书未配置'); + } + $cert = file_get_contents($certPath); + $publicKey = openssl_pkey_get_public($cert); + if (!$publicKey) { + throw new \RuntimeException('平台证书读取失败'); + } + $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; + $ok = openssl_verify($message, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256); + return $ok === 1; + } + + public function decryptNotifyResource(array $resource, string $apiV3Key): array + { + $ciphertext = (string)($resource['ciphertext'] ?? ''); + $nonce = (string)($resource['nonce'] ?? ''); + $aad = (string)($resource['associated_data'] ?? ''); + if ($ciphertext === '' || $nonce === '') { + throw new \RuntimeException('回调报文不完整'); + } + + $cipherRaw = base64_decode($ciphertext); + $tag = substr($cipherRaw, -16); + $data = substr($cipherRaw, 0, -16); + $plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad); + if ($plain === false) { + throw new \RuntimeException('回调报文解密失败'); + } + return json_decode($plain, true) ?: []; + } + + private function buildPayBody(string $outTradeNo, string $description, int $amountFen, string $notifyUrl): array + { + $mode = (string)($this->merchant->mode ?? 'direct'); + $mchId = (string)$this->merchant->mch_id; + $appId = (string)($this->merchant->app_id ?? ''); + $subMchId = (string)($this->merchant->sub_mch_id ?? ''); + $subAppId = (string)($this->merchant->sub_app_id ?? ''); + + if ($mode === 'direct') { + if ($appId === '') { + throw new \RuntimeException('商户 AppID 未配置'); + } + return [ + 'appid' => $appId, + 'mchid' => $mchId, + 'description' => $description, + 'out_trade_no' => $outTradeNo, + 'notify_url' => $notifyUrl, + 'amount' => ['total' => $amountFen, 'currency' => 'CNY'], + ]; + } + if ($mode === 'service_provider') { + if ($appId === '' || $subMchId === '') { + throw new \RuntimeException('服务商模式配置不完整'); + } + $body = [ + 'sp_appid' => $appId, + 'sp_mchid' => $mchId, + 'sub_mchid' => $subMchId, + 'description' => $description, + 'out_trade_no' => $outTradeNo, + 'notify_url' => $notifyUrl, + 'amount' => ['total' => $amountFen, 'currency' => 'CNY'], + ]; + if ($subAppId !== '') { + $body['sub_appid'] = $subAppId; + } + return $body; + } + throw new \RuntimeException('第三方支付模式未配置下单方式'); + } + + private function buildJsapiPayBody(string $outTradeNo, string $description, int $amountFen, string $notifyUrl, string $openid): array + { + $openid = trim($openid); + if ($openid === '') { + throw new \RuntimeException('openid 不能为空'); + } + + $mode = (string)($this->merchant->mode ?? 'direct'); + $mchId = (string)$this->merchant->mch_id; + $appId = (string)($this->merchant->app_id ?? ''); + $subMchId = (string)($this->merchant->sub_mch_id ?? ''); + $subAppId = (string)($this->merchant->sub_app_id ?? ''); + + if ($mode === 'direct') { + if ($appId === '') { + throw new \RuntimeException('商户 AppID 未配置'); + } + return [ + 'appid' => $appId, + 'mchid' => $mchId, + 'description' => $description, + 'out_trade_no' => $outTradeNo, + 'notify_url' => $notifyUrl, + 'amount' => ['total' => $amountFen, 'currency' => 'CNY'], + 'payer' => ['openid' => $openid], + ]; + } + + if ($mode === 'service_provider') { + if ($appId === '' || $subMchId === '') { + throw new \RuntimeException('服务商模式配置不完整'); + } + $body = [ + 'sp_appid' => $appId, + 'sp_mchid' => $mchId, + 'sub_mchid' => $subMchId, + 'description' => $description, + 'out_trade_no' => $outTradeNo, + 'notify_url' => $notifyUrl, + 'amount' => ['total' => $amountFen, 'currency' => 'CNY'], + 'payer' => [], + ]; + if ($subAppId !== '') { + $body['sub_appid'] = $subAppId; + $body['payer']['sub_openid'] = $openid; + } else { + $body['payer']['sp_openid'] = $openid; + } + return $body; + } + + throw new \RuntimeException('第三方支付模式未配置下单方式'); + } + + private function request(string $method, string $path, array $body): array + { + $mchId = (string)$this->merchant->mch_id; + $serialNo = (string)($this->merchant->serial_no ?? ''); + $privateKeyPem = (string)($this->merchant->private_key_pem ?? ''); + if ($privateKeyPem === '') { + $privateKeyPem = $this->loadKeyPemFromFile(); + } + + if ($mchId === '' || $serialNo === '' || $privateKeyPem === '') { + throw new \RuntimeException('微信支付密钥未配置(mch_id/serial_no/private_key_pem)'); + } + + $bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE); + $timestamp = (string)time(); + $nonceStr = bin2hex(random_bytes(16)); + + $signStr = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonceStr . "\n" . $bodyJson . "\n"; + $privateKey = openssl_pkey_get_private($privateKeyPem); + if (!$privateKey) { + throw new \RuntimeException('私钥格式错误'); + } + openssl_sign($signStr, $signature, $privateKey, OPENSSL_ALGO_SHA256); + $signature = base64_encode($signature); + + $authorization = sprintf( + 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"', + $mchId, + $nonceStr, + $timestamp, + $serialNo, + $signature + ); + + $ch = curl_init('https://api.mch.weixin.qq.com' . $path); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Accept: application/json', + 'Content-Type: application/json', + 'Authorization: ' . $authorization, + 'User-Agent: anxinyan-webman', + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson); + $respBody = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($respBody === false) { + $err = curl_error($ch); + curl_close($ch); + throw new \RuntimeException('微信支付请求失败: ' . $err); + } + curl_close($ch); + + return [ + 'status' => $status, + 'body' => $respBody, + ]; + } + + private function loadKeyPemFromFile(): string + { + $path = (string)($this->merchant->apiclient_key_path ?? ''); + if ($path === '') return ''; + $real = realpath($path); + $base = realpath(runtime_path() . '/wechatpay/merchants'); + if (!$real || !$base) return ''; + if (strpos($real, $base) !== 0) return ''; + if (!is_file($real)) return ''; + $pem = file_get_contents($real); + return is_string($pem) ? $pem : ''; + } +} diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php new file mode 100644 index 0000000..396ea80 --- /dev/null +++ b/app/controller/IndexController.php @@ -0,0 +1,42 @@ + + * { + padding: 0; + margin: 0; + } + iframe { + border: none; + overflow: scroll; + } + + +EOF; + } + + public function view(Request $request) + { + return view('index/view', ['name' => 'webman']); + } + + public function json(Request $request) + { + return json(['code' => 0, 'msg' => 'ok']); + } + +} diff --git a/app/functions.php b/app/functions.php new file mode 100644 index 0000000..5c9c58d --- /dev/null +++ b/app/functions.php @@ -0,0 +1,4 @@ +header('origin', ''); + $allow = trim((string)(getenv('CORS_ALLOW_ORIGINS') ?: '*')); + $allowOrigin = ''; + + if ($allow === '*') { + $allowOrigin = '*'; + } else { + $allowList = array_values(array_filter(array_map('trim', explode(',', $allow)))); + if ($origin !== '' && in_array($origin, $allowList, true)) { + $allowOrigin = $origin; + } + } + + $headers = [ + 'Access-Control-Allow-Methods' => 'GET,POST,PUT,PATCH,DELETE,OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-Requested-With', + 'Access-Control-Max-Age' => '86400', + ]; + + if ($allowOrigin !== '') { + $headers['Access-Control-Allow-Origin'] = $allowOrigin; + if ($allowOrigin !== '*') { + $headers['Access-Control-Allow-Credentials'] = 'true'; + } + } + + if (strtoupper($request->method()) === 'OPTIONS') { + return response('', 204)->withHeaders($headers); + } + + $response = $handler($request); + return $response->withHeaders($headers); + } +} + diff --git a/app/middleware/StaticFile.php b/app/middleware/StaticFile.php new file mode 100644 index 0000000..fa8dbf7 --- /dev/null +++ b/app/middleware/StaticFile.php @@ -0,0 +1,42 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace app\middleware; + +use Webman\MiddlewareInterface; +use Webman\Http\Response; +use Webman\Http\Request; + +/** + * Class StaticFile + * @package app\middleware + */ +class StaticFile implements MiddlewareInterface +{ + public function process(Request $request, callable $handler): Response + { + // Access to files beginning with. Is prohibited + if (strpos($request->path(), '/.') !== false) { + return response('

403 forbidden

', 403); + } + /** @var Response $response */ + $response = $handler($request); + // Add cross domain HTTP header + /*$response->withHeaders([ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Credentials' => 'true', + ]);*/ + return $response; + } +} diff --git a/app/model/Test.php b/app/model/Test.php new file mode 100644 index 0000000..92d70e3 --- /dev/null +++ b/app/model/Test.php @@ -0,0 +1,29 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace app\process; + +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Workerman\Timer; +use Workerman\Worker; + +/** + * Class FileMonitor + * @package process + */ +class Monitor +{ + /** + * @var array + */ + protected array $paths = []; + + /** + * @var array + */ + protected array $extensions = []; + + /** + * @var array + */ + protected array $loadedFiles = []; + + /** + * @var int + */ + protected int $ppid = 0; + + /** + * Pause monitor + * @return void + */ + public static function pause(): void + { + file_put_contents(static::lockFile(), time()); + } + + /** + * Resume monitor + * @return void + */ + public static function resume(): void + { + clearstatcache(); + if (is_file(static::lockFile())) { + unlink(static::lockFile()); + } + } + + /** + * Whether monitor is paused + * @return bool + */ + public static function isPaused(): bool + { + clearstatcache(); + return file_exists(static::lockFile()); + } + + /** + * Lock file + * @return string + */ + protected static function lockFile(): string + { + return runtime_path('monitor.lock'); + } + + /** + * FileMonitor constructor. + * @param $monitorDir + * @param $monitorExtensions + * @param array $options + */ + public function __construct($monitorDir, $monitorExtensions, array $options = []) + { + $this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0; + static::resume(); + $this->paths = (array)$monitorDir; + $this->extensions = $monitorExtensions; + foreach (get_included_files() as $index => $file) { + $this->loadedFiles[$file] = $index; + if (strpos($file, 'webman-framework/src/support/App.php')) { + break; + } + } + if (!Worker::getAllWorkers()) { + return; + } + $disableFunctions = explode(',', ini_get('disable_functions')); + if (in_array('exec', $disableFunctions, true)) { + echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n"; + } else { + if ($options['enable_file_monitor'] ?? true) { + Timer::add(1, function () { + $this->checkAllFilesChange(); + }); + } + } + + $memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null); + if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) { + Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]); + } + } + + /** + * @param $monitorDir + * @return bool + */ + public function checkFilesChange($monitorDir): bool + { + static $lastMtime, $tooManyFilesCheck; + if (!$lastMtime) { + $lastMtime = time(); + } + clearstatcache(); + if (!is_dir($monitorDir)) { + if (!is_file($monitorDir)) { + return false; + } + $iterator = [new SplFileInfo($monitorDir)]; + } else { + // recursive traversal directory + $dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS); + $iterator = new RecursiveIteratorIterator($dirIterator); + } + $count = 0; + foreach ($iterator as $file) { + $count ++; + /** @var SplFileInfo $file */ + if (is_dir($file->getRealPath())) { + continue; + } + // check mtime + if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) { + $lastMtime = $file->getMTime(); + if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) { + echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n"; + continue; + } + $var = 0; + exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var); + if ($var) { + continue; + } + // send SIGUSR1 signal to master process for reload + if (DIRECTORY_SEPARATOR === '/') { + if ($masterPid = $this->getMasterPid()) { + echo $file . " updated and reload\n"; + posix_kill($masterPid, SIGUSR1); + } else { + echo "Master process has gone away and can not reload\n"; + } + return true; + } + echo $file . " updated and reload\n"; + return true; + } + } + if (!$tooManyFilesCheck && $count > 1000) { + echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n"; + $tooManyFilesCheck = 1; + } + return false; + } + + /** + * @return int + */ + public function getMasterPid(): int + { + if ($this->ppid === 0) { + return 0; + } + if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) { + echo "Master process has gone away\n"; + return $this->ppid = 0; + } + if (PHP_OS_FAMILY !== 'Linux') { + return $this->ppid; + } + $cmdline = "/proc/$this->ppid/cmdline"; + if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) { + // Process not exist + $this->ppid = 0; + } + return $this->ppid; + } + + /** + * @return bool + */ + public function checkAllFilesChange(): bool + { + if (static::isPaused()) { + return false; + } + foreach ($this->paths as $path) { + if ($this->checkFilesChange($path)) { + return true; + } + } + return false; + } + + /** + * @param $memoryLimit + * @return void + */ + public function checkMemory($memoryLimit): void + { + if (static::isPaused() || $memoryLimit <= 0) { + return; + } + $masterPid = $this->getMasterPid(); + if ($masterPid <= 0) { + echo "Master process has gone away\n"; + return; + } + + $childrenFile = "/proc/$masterPid/task/$masterPid/children"; + if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) { + return; + } + foreach (explode(' ', $children) as $pid) { + $pid = (int)$pid; + $statusFile = "/proc/$pid/status"; + if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) { + continue; + } + $mem = 0; + if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) { + $mem = $match[1]; + } + $mem = (int)($mem / 1024); + if ($mem >= $memoryLimit) { + posix_kill($pid, SIGINT); + } + } + } + + /** + * Get memory limit + * @param $memoryLimit + * @return int + */ + protected function getMemoryLimit($memoryLimit): int + { + if ($memoryLimit === 0) { + return 0; + } + $usePhpIni = false; + if (!$memoryLimit) { + $memoryLimit = ini_get('memory_limit'); + $usePhpIni = true; + } + + if ($memoryLimit == -1) { + return 0; + } + $unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]); + $memoryLimit = (int)$memoryLimit; + if ($unit === 'g') { + $memoryLimit = 1024 * $memoryLimit; + } else if ($unit === 'k') { + $memoryLimit = ($memoryLimit / 1024); + } else if ($unit === 'm') { + $memoryLimit = (int)($memoryLimit); + } else if ($unit === 't') { + $memoryLimit = (1024 * 1024 * $memoryLimit); + } else { + $memoryLimit = ($memoryLimit / (1024 * 1024)); + } + if ($memoryLimit < 50) { + $memoryLimit = 50; + } + if ($usePhpIni) { + $memoryLimit = (0.8 * $memoryLimit); + } + return (int)$memoryLimit; + } + +} diff --git a/app/view/index/view.html b/app/view/index/view.html new file mode 100644 index 0000000..67ebb26 --- /dev/null +++ b/app/view/index/view.html @@ -0,0 +1,14 @@ + + + + + + + + webman + + + +hello + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..322ca49 --- /dev/null +++ b/composer.json @@ -0,0 +1,70 @@ +{ + "name": "workerman/webman", + "type": "project", + "keywords": [ + "high performance", + "http service" + ], + "homepage": "https://www.workerman.net", + "license": "MIT", + "description": "High performance HTTP Service Framework.", + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], + "support": { + "email": "walkor@workerman.net", + "issues": "https://github.com/walkor/webman/issues", + "forum": "https://wenda.workerman.net/", + "wiki": "https://workerman.net/doc/webman", + "source": "https://github.com/walkor/webman" + }, + "require": { + "php": ">=8.1", + "workerman/webman-framework": "^2.1", + "monolog/monolog": "^2.0", + "illuminate/database": "^10.49", + "illuminate/pagination": "^10.49", + "illuminate/events": "^10.49", + "webman/redis-queue": "^2.1", + "vlucas/phpdotenv": "^5.6" + }, + "suggest": { + "ext-event": "For better performance. " + }, + "autoload": { + "psr-4": { + "": "./", + "app\\": "./app", + "app\\common\\": "./app/common", + "app\\api\\": "./app/api", + "app\\admin\\": "./app/admin" + }, + "files": [ + "./support/helpers.php" + ] + }, + "scripts": { + "post-package-install": [ + "support\\Plugin::install" + ], + "post-package-update": [ + "support\\Plugin::install" + ], + "pre-package-uninstall": [ + "support\\Plugin::uninstall" + ], + "post-create-project-cmd": [ + "support\\Setup::run" + ], + "setup-webman": [ + "support\\Setup::run" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..0cecf19 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1934 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c03d7bd0a8b29126e244b0059de2065f", + "packages": [ + { + "name": "brick/math", + "version": "0.12.3", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/brick/math/0.12.3/brick-math-0.12.3.zip", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.12.3" + }, + "time": "2025-02-28T13:11:00+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "2.1.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/carbonphp/carbon-doctrine-types/2.1.0/carbonphp-carbon-doctrine-types-2.1.0.zip", + "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.7.0 || >=4.0.0" + }, + "require-dev": { + "doctrine/dbal": "^3.7.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/2.1.0" + }, + "time": "2023-12-11T17:09:12+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/doctrine/inflector/2.1.0/doctrine-inflector-2.1.0.zip", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/graham-campbell/result-type/v1.1.4/graham-campbell-result-type-v1.1.4.zip", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "illuminate/bus", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/bus/v10.49.0/illuminate-bus-v10.49.0.zip", + "reference": "053f902d546d719c3f2752f7d3805a466e317312", + "shasum": "" + }, + "require": { + "illuminate/collections": "^10.0", + "illuminate/contracts": "^10.0", + "illuminate/pipeline": "^10.0", + "illuminate/support": "^10.0", + "php": "^8.1" + }, + "suggest": { + "illuminate/queue": "Required to use closures when chaining jobs (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Bus\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Bus package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:47:24+00:00" + }, + { + "name": "illuminate/collections", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/collections/v10.49.0/illuminate-collections-v10.49.0.zip", + "reference": "6ae9c74fa92d4e1824d1b346cd435e8eacdc3232", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^10.0", + "illuminate/contracts": "^10.0", + "illuminate/macroable": "^10.0", + "php": "^8.1" + }, + "suggest": { + "symfony/var-dumper": "Required to use the dump method (^6.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-09-08T19:05:53+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/conditionable/v10.49.0/illuminate-conditionable-v10.49.0.zip", + "reference": "47c700320b7a419f0d188d111f3bbed978fcbd3f", + "shasum": "" + }, + "require": { + "php": "^8.0.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:47:24+00:00" + }, + { + "name": "illuminate/container", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/container/v10.49.0/illuminate-container-v10.49.0.zip", + "reference": "b4956de5de18524c21ef36221a8ffd7fa3b534db", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0", + "php": "^8.1", + "psr/container": "^1.1.1|^2.0.1" + }, + "provide": { + "psr/container-implementation": "1.1|2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Container\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Container package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:47:24+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/contracts/v10.49.0/illuminate-contracts-v10.49.0.zip", + "reference": "2393ef579e020d88e24283913c815c3e2c143323", + "shasum": "" + }, + "require": { + "php": "^8.1", + "psr/container": "^1.1.1|^2.0.1", + "psr/simple-cache": "^1.0|^2.0|^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:47:24+00:00" + }, + { + "name": "illuminate/database", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/database/v10.49.0/illuminate-database-v10.49.0.zip", + "reference": "711519fa4eca9c55d4f3d6680ffca71b28317e7a", + "shasum": "" + }, + "require": { + "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", + "ext-pdo": "*", + "illuminate/collections": "^10.0", + "illuminate/container": "^10.0", + "illuminate/contracts": "^10.0", + "illuminate/macroable": "^10.0", + "illuminate/support": "^10.0", + "php": "^8.1" + }, + "conflict": { + "carbonphp/carbon-doctrine-types": ">=3.0", + "doctrine/dbal": ">=4.0" + }, + "suggest": { + "doctrine/dbal": "Required to rename columns and drop SQLite columns (^3.5.1).", + "ext-filter": "Required to use the Postgres database driver.", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.21).", + "illuminate/console": "Required to use the database commands (^10.0).", + "illuminate/events": "Required to use the observers with Eloquent (^10.0).", + "illuminate/filesystem": "Required to use the migrations (^10.0).", + "illuminate/pagination": "Required to paginate the result set (^10.0).", + "symfony/finder": "Required to use Eloquent model factories (^6.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Database\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Database package.", + "homepage": "https://laravel.com", + "keywords": [ + "database", + "laravel", + "orm", + "sql" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:47:24+00:00" + }, + { + "name": "illuminate/events", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/events/v10.49.0/illuminate-events-v10.49.0.zip", + "reference": "4a8e4fbc95c7e46aa6152fd8c900d56e5ef538cf", + "shasum": "" + }, + "require": { + "illuminate/bus": "^10.0", + "illuminate/collections": "^10.0", + "illuminate/container": "^10.0", + "illuminate/contracts": "^10.0", + "illuminate/macroable": "^10.0", + "illuminate/support": "^10.0", + "php": "^8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php" + ], + "psr-4": { + "Illuminate\\Events\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Events package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:47:24+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/macroable/v10.49.0/illuminate-macroable-v10.49.0.zip", + "reference": "dff667a46ac37b634dcf68909d9d41e94dc97c27", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2023-06-05T12:46:42+00:00" + }, + { + "name": "illuminate/pagination", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/pagination/v10.49.0/illuminate-pagination-v10.49.0.zip", + "reference": "616874b9607ff35925347e1710a8b5151858cdf2", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "illuminate/collections": "^10.0", + "illuminate/contracts": "^10.0", + "illuminate/support": "^10.0", + "php": "^8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pagination\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pagination package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2024-04-11T14:31:05+00:00" + }, + { + "name": "illuminate/pipeline", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/pipeline/v10.49.0/illuminate-pipeline-v10.49.0.zip", + "reference": "c12e4f1d8a1fbecdc1e0fa4dc9fe17b4315832e9", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0", + "illuminate/support": "^10.0", + "php": "^8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pipeline\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pipeline package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:47:24+00:00" + }, + { + "name": "illuminate/support", + "version": "v10.49.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/illuminate/support/v10.49.0/illuminate-support-v10.49.0.zip", + "reference": "28b505e671dbe119e4e32a75c78f87189d046e39", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^10.0", + "illuminate/conditionable": "^10.0", + "illuminate/contracts": "^10.0", + "illuminate/macroable": "^10.0", + "nesbot/carbon": "^2.67", + "php": "^8.1", + "voku/portable-ascii": "^2.0" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "suggest": { + "illuminate/filesystem": "Required to use the composer class (^10.0).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.6).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the composer class (^6.2).", + "symfony/uid": "Required to use Str::ulid() (^6.2).", + "symfony/var-dumper": "Required to use the dd function (^6.2).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.4.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-09-08T19:05:53+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.11.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/monolog/monolog/2.11.0/monolog-monolog-2.11.0.zip", + "reference": "37308608e599f34a1a4845b16440047ec98a172a", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2@dev", + "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "phpspec/prophecy": "^1.15", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.38 || ^9.6.19", + "predis/predis": "^1.1 || ^2.0", + "rollbar/rollbar": "^1.3 || ^2 || ^3", + "ruflin/elastica": "^7", + "swiftmailer/swiftmailer": "^5.3|^6.0", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.11.0" + }, + "time": "2026-01-01T13:05:00+00:00" + }, + { + "name": "nesbot/carbon", + "version": "2.73.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/nesbot/carbon/2.73.0/nesbot-carbon-2.73.0.zip", + "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "*", + "ext-json": "*", + "php": "^7.1.8 || ^8.0", + "psr/clock": "^1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php80": "^1.16", + "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0", + "doctrine/orm": "^2.7 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.0", + "kylekatarnls/multi-tester": "^2.0", + "ondrejmirtes/better-reflection": "<6", + "phpmd/phpmd": "^2.9", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12.99 || ^1.7.14", + "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", + "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", + "squizlabs/php_codesniffer": "^3.4" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/briannesbitt/Carbon/issues", + "source": "https://github.com/briannesbitt/Carbon" + }, + "time": "2025-01-08T20:10:23+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/nikic/fast-route/v1.3.0/nikic-fast-route-v1.3.0.zip", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/phpoption/phpoption/1.9.5/phpoption-phpoption-1.9.5.zip", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/clock/1.0.0/psr-clock-1.0.0.zip", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/container/2.0.2/psr-container-2.0.2.zip", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/log/3.0.2/psr-log-3.0.2.zip", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/simple-cache/3.0.0/psr-simple-cache-3.0.0.zip", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/deprecation-contracts/v3.6.0/symfony-deprecation-contracts-v3.6.0.zip", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.35.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-ctype/v1.35.0/symfony-polyfill-ctype-v1.35.0.zip", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.35.0" + }, + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.34.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-mbstring/v1.34.0/symfony-polyfill-mbstring-v1.34.0.zip", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" + }, + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.34.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-php80/v1.34.0/symfony-polyfill-php80-v1.34.0.zip", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.34.0" + }, + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/translation", + "version": "v6.4.34", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/translation/v6.4.34/symfony-translation-v6.4.34.zip", + "reference": "d07d117db41341511671b0a1a2be48f2772189ce", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<5.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<5.4", + "symfony/yaml": "<5.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.18|^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v6.4.34" + }, + "time": "2026-02-16T20:44:03+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/translation-contracts/v3.6.1/symfony-translation-contracts-v3.6.1.zip", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/vlucas/phpdotenv/v5.6.3/vlucas-phpdotenv-v5.6.3.zip", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/voku/portable-ascii/2.0.3/voku-portable-ascii-2.0.3.zip", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "webman/redis-queue", + "version": "v2.1.1", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/webman/redis-queue/v2.1.1/webman-redis-queue-v2.1.1.zip", + "reference": "ff4791e21f3c324a47e21da7b6f2dae5a7311dcb", + "shasum": "" + }, + "require": { + "ext-redis": "*", + "php": ">=8.1", + "workerman/redis-queue": "^1.2", + "workerman/webman-framework": "^2.1 || dev-master" + }, + "type": "library", + "autoload": { + "psr-4": { + "Webman\\RedisQueue\\": "./src" + } + }, + "description": "Redis message queue plugin for webman.", + "support": { + "issues": "https://github.com/webman-php/redis-queue/issues", + "source": "https://github.com/webman-php/redis-queue/tree/v2.1.1" + }, + "time": "2025-11-14T07:12:52+00:00" + }, + { + "name": "workerman/coroutine", + "version": "v1.1.5", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/coroutine/v1.1.5/workerman-coroutine-v1.1.5.zip", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "workerman/workerman": "^5.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "psr/log": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\": "src", + "Workerman\\Coroutine\\": "src" + } + }, + "license": [ + "MIT" + ], + "description": "Workerman coroutine", + "support": { + "issues": "https://github.com/workerman-php/coroutine/issues", + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.5" + }, + "time": "2026-03-12T02:07:37+00:00" + }, + { + "name": "workerman/redis", + "version": "v2.0.5", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/redis/v2.0.5/workerman-redis-v2.0.5.zip", + "reference": "49627c1809eff1ef7175eb8ee7549234a1d67ec5", + "shasum": "" + }, + "require": { + "php": ">=7", + "workerman/workerman": "^4.1.0||^5.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\Redis\\": "./src" + } + }, + "license": [ + "MIT" + ], + "homepage": "http://www.workerman.net", + "support": { + "issues": "https://github.com/walkor/redis/issues", + "source": "https://github.com/walkor/redis/tree/v2.0.5" + }, + "time": "2025-04-07T01:58:58+00:00" + }, + { + "name": "workerman/redis-queue", + "version": "v1.2.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/redis-queue/v1.2.2/workerman-redis-queue-v1.2.2.zip", + "reference": "f0ba4ea9143ae02f39b998ed908d107354cb43c0", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "workerman/redis": "^1.0||^2.0", + "workerman/workerman": ">=4.0.20" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\RedisQueue\\": "./src" + } + }, + "license": [ + "MIT" + ], + "description": "Message queue system written in PHP based on workerman and backed by Redis.", + "homepage": "http://www.workerman.net", + "support": { + "issues": "https://github.com/walkor/redis-queue/issues", + "source": "https://github.com/walkor/redis-queue/tree/v1.2.2" + }, + "time": "2026-01-20T14:57:09+00:00" + }, + { + "name": "workerman/webman-framework", + "version": "v2.2.1", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/webman-framework/v2.2.1/workerman-webman-framework-v2.2.1.zip", + "reference": "ce54d8f8f4c1f2c336293dbc37df1ea46ec34c92", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/fast-route": "^1.3", + "php": ">=8.1", + "psr/container": ">=1.0", + "psr/log": "^2.0 || ^3.0", + "workerman/workerman": "^5.1 || dev-master" + }, + "suggest": { + "ext-event": "For better performance. " + }, + "type": "library", + "autoload": { + "files": [ + "./src/support/helpers.php" + ], + "psr-4": { + "Webman\\": "./src", + "Support\\": "./src/support", + "support\\": "./src/support", + "Support\\View\\": "./src/support/view", + "Support\\Bootstrap\\": "./src/support/bootstrap", + "Support\\Exception\\": "./src/support/exception" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], + "description": "High performance HTTP Service Framework.", + "homepage": "https://www.workerman.net", + "keywords": [ + "High Performance", + "http service" + ], + "support": { + "email": "walkor@workerman.net", + "forum": "https://wenda.workerman.net/", + "issues": "https://github.com/walkor/webman/issues", + "source": "https://github.com/walkor/webman-framework", + "wiki": "https://doc.workerman.net/" + }, + "time": "2026-03-26T01:51:42+00:00" + }, + { + "name": "workerman/workerman", + "version": "v5.1.10", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/workerman/v5.1.10/workerman-workerman-v5.1.10.zip", + "reference": "6ecda94609c40ade0f1e548535d24d8e09e67409", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "workerman/coroutine": "^1.1 || dev-main" + }, + "conflict": { + "ext-swow": "=8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..f26e358 --- /dev/null +++ b/config/app.php @@ -0,0 +1,26 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\Request; + +return [ + 'debug' => true, + 'error_reporting' => E_ALL, + 'default_timezone' => 'Asia/Shanghai', + 'request_class' => Request::class, + 'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public', + 'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime', + 'controller_suffix' => 'Controller', + 'controller_reuse' => false, +]; diff --git a/config/autoload.php b/config/autoload.php new file mode 100644 index 0000000..69a8135 --- /dev/null +++ b/config/autoload.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'files' => [ + base_path() . '/app/functions.php', + base_path() . '/support/Request.php', + base_path() . '/support/Response.php', + ] +]; diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 0000000..6906868 --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,18 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + support\bootstrap\Database::class, + support\bootstrap\Session::class, +]; diff --git a/config/container.php b/config/container.php new file mode 100644 index 0000000..106b7b4 --- /dev/null +++ b/config/container.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return new Webman\Container; \ No newline at end of file diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..903d043 --- /dev/null +++ b/config/database.php @@ -0,0 +1,23 @@ + 'mysql', + 'connections' => [ + 'mysql' => [ + 'driver' => 'mysql', + 'host' => getenv('DB_HOST') ?: '127.0.0.1', + 'port' => getenv('DB_PORT') ?: '3306', + 'database' => getenv('DB_DATABASE') ?: '', + 'username' => getenv('DB_USERNAME') ?: '', + 'password' => getenv('DB_PASSWORD') ?: '', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => true, + 'engine' => null, + 'options' => [ + \PDO::ATTR_TIMEOUT => 3 + ] + ], + ], +]; diff --git a/config/dependence.php b/config/dependence.php new file mode 100644 index 0000000..8e964ed --- /dev/null +++ b/config/dependence.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return []; \ No newline at end of file diff --git a/config/exception.php b/config/exception.php new file mode 100644 index 0000000..94973a2 --- /dev/null +++ b/config/exception.php @@ -0,0 +1,4 @@ + support\ExceptionHandler::class, +]; \ No newline at end of file diff --git a/config/log.php b/config/log.php new file mode 100644 index 0000000..7f05de5 --- /dev/null +++ b/config/log.php @@ -0,0 +1,32 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => [ + 'handlers' => [ + [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + runtime_path() . '/logs/webman.log', + 7, //$maxFiles + Monolog\Logger::DEBUG, + ], + 'formatter' => [ + 'class' => Monolog\Formatter\LineFormatter::class, + 'constructor' => [null, 'Y-m-d H:i:s', true], + ], + ] + ], + ], +]; diff --git a/config/middleware.php b/config/middleware.php new file mode 100644 index 0000000..4e6692c --- /dev/null +++ b/config/middleware.php @@ -0,0 +1,19 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + '@' => [ + app\middleware\Cors::class, + ], +]; diff --git a/config/plugin/webman/redis-queue/app.php b/config/plugin/webman/redis-queue/app.php new file mode 100644 index 0000000..8f9c426 --- /dev/null +++ b/config/plugin/webman/redis-queue/app.php @@ -0,0 +1,4 @@ + true, +]; \ No newline at end of file diff --git a/config/plugin/webman/redis-queue/command.php b/config/plugin/webman/redis-queue/command.php new file mode 100644 index 0000000..8bfe2a1 --- /dev/null +++ b/config/plugin/webman/redis-queue/command.php @@ -0,0 +1,7 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => [ + 'handlers' => [ + [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + runtime_path() . '/logs/redis-queue/queue.log', + 7, //$maxFiles + Monolog\Logger::DEBUG, + ], + 'formatter' => [ + 'class' => Monolog\Formatter\LineFormatter::class, + 'constructor' => [null, 'Y-m-d H:i:s', true], + ], + ] + ], + ] +]; diff --git a/config/plugin/webman/redis-queue/process.php b/config/plugin/webman/redis-queue/process.php new file mode 100644 index 0000000..c8d4da1 --- /dev/null +++ b/config/plugin/webman/redis-queue/process.php @@ -0,0 +1,11 @@ + [ + 'handler' => Webman\RedisQueue\Process\Consumer::class, + 'count' => 8, // 可以设置多进程同时消费 + 'constructor' => [ + // 消费者类目录 + 'consumer_dir' => app_path() . '/queue/redis' + ] + ] +]; \ No newline at end of file diff --git a/config/plugin/webman/redis-queue/redis.php b/config/plugin/webman/redis-queue/redis.php new file mode 100644 index 0000000..6abf860 --- /dev/null +++ b/config/plugin/webman/redis-queue/redis.php @@ -0,0 +1,21 @@ + [ + 'host' => 'redis://127.0.0.1:6379', + 'options' => [ + 'auth' => null, + 'db' => 0, + 'prefix' => '', + 'max_attempts' => 5, + 'retry_seconds' => 5, + ], + // Connection pool, supports only Swoole or Swow drivers. + 'pool' => [ + 'max_connections' => 5, + 'min_connections' => 1, + 'wait_timeout' => 3, + 'idle_timeout' => 60, + 'heartbeat_interval' => 50, + ] + ], +]; diff --git a/config/process.php b/config/process.php new file mode 100644 index 0000000..6a54cd0 --- /dev/null +++ b/config/process.php @@ -0,0 +1,62 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\Log; +use support\Request; +use app\process\Http; + +global $argv; + +return [ + 'webman' => [ + 'handler' => Http::class, + 'listen' => 'http://0.0.0.0:8787', + 'count' => 4, + 'user' => '', + 'group' => '', + 'reusePort' => false, + 'eventLoop' => '', + 'context' => [], + 'constructor' => [ + 'requestClass' => Request::class, + 'logger' => Log::channel('default'), + 'appPath' => app_path(), + 'publicPath' => public_path() + ] + ], + // File update detection and automatic reload + 'monitor' => [ + 'handler' => app\process\Monitor::class, + 'reloadable' => false, + 'constructor' => [ + // Monitor these directories + 'monitorDir' => array_merge([ + app_path(), + config_path(), + base_path() . '/process', + base_path() . '/support', + base_path() . '/resource', + base_path() . '/.env', + ], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')), + // Files with these suffixes will be monitored + 'monitorExtensions' => [ + 'php', 'html', 'htm', 'env' + ], + 'options' => [ + 'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/', + 'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/', + ] + ] + ] +]; diff --git a/config/route.php b/config/route.php new file mode 100644 index 0000000..a043d09 --- /dev/null +++ b/config/route.php @@ -0,0 +1,109 @@ +name('api.auth.login'); + Route::get('/wechat/app_list', [app\api\controller\WechatAuthController::class, 'appList'])->name('api.wechat.app_list'); + Route::post('/wechat/mini_login', [app\api\controller\WechatAuthController::class, 'miniLogin'])->name('api.wechat.mini_login'); + Route::post('/wechat/h5_login', [app\api\controller\WechatAuthController::class, 'h5Login'])->name('api.wechat.h5_login'); + Route::get('/wechat/h5_authorize_url', [app\api\controller\WechatAuthController::class, 'h5AuthorizeUrl'])->name('api.wechat.h5_authorize_url'); + + // 公开验证防伪接口 + Route::get('/report/verify', [app\api\controller\ReportController::class, 'verify'])->name('api.report.verify'); + Route::post('/pay/wechat/notify', [app\api\controller\PayController::class, 'wechatNotify'])->name('api.pay.wechat.notify'); + + Route::group('', function () { + Route::get('/auth/me', [app\api\controller\AuthController::class, 'me'])->name('api.auth.me'); + Route::post('/auth/logout', [app\api\controller\AuthController::class, 'logout'])->name('api.auth.logout'); + + Route::post('/upload/image', [app\api\controller\UploadController::class, 'image'])->name('api.upload.image'); + + Route::group('/order', function () { + Route::post('/create', [app\api\controller\OrderController::class, 'create']); + Route::post('/pay', [app\api\controller\OrderController::class, 'pay']); + Route::post('/ship', [app\api\controller\OrderController::class, 'ship']); + Route::get('/list', [app\api\controller\OrderController::class, 'list']); + Route::get('/detail/{id}', [app\api\controller\OrderController::class, 'detail']); + }); + + Route::group('/report', function () { + Route::get('/detail', [app\api\controller\ReportController::class, 'detail']); + }); + + Route::group('/user', function () { + Route::get('/stat', [app\api\controller\UserController::class, 'stat']); + Route::post('/update_info', [app\api\controller\UserController::class, 'updateInfo']); + }); + + })->middleware(app\api\middleware\AuthMiddleware::class); +}); + +Route::group('/admin', function () { + Route::post('/auth/login', [app\admin\controller\AuthController::class, 'login'])->name('admin.auth.login'); + Route::get('/auth/me', [app\admin\controller\AuthController::class, 'me']) + ->middleware(app\admin\middleware\AuthMiddleware::class) + ->name('admin.auth.me'); + Route::post('/auth/logout', [app\admin\controller\AuthController::class, 'logout']) + ->middleware(app\admin\middleware\AuthMiddleware::class) + ->name('admin.auth.logout'); + + Route::post('/upload/image', [app\admin\controller\UploadController::class, 'image'])->name('admin.upload.image'); + + Route::group('', function () { + // Dashboard + Route::get('/dashboard/stat', [app\admin\controller\DashboardController::class, 'stat'])->name('admin.dashboard.stat'); + + // Order + Route::get('/order/list', [app\admin\controller\OrderController::class, 'list'])->name('admin.order.list'); + Route::get('/order/detail', [app\admin\controller\OrderController::class, 'detail'])->name('admin.order.detail'); + Route::post('/order/receive', [app\admin\controller\OrderController::class, 'receive'])->name('admin.order.receive'); + Route::post('/order/return_ship', [app\admin\controller\OrderController::class, 'returnShip'])->name('admin.order.return_ship'); + + // Report + Route::get('/report/list', [app\admin\controller\ReportController::class, 'list'])->name('admin.report.list'); + Route::get('/report/detail', [app\admin\controller\ReportController::class, 'detail'])->name('admin.report.detail'); + Route::post('/report/create', [app\admin\controller\ReportController::class, 'create'])->name('admin.report.create'); + + // Admin User + Route::get('/admin_user/list', [app\admin\controller\AdminUserController::class, 'list'])->name('admin.admin_user.list'); + Route::post('/admin_user/create', [app\admin\controller\AdminUserController::class, 'create'])->name('admin.admin_user.create'); + Route::post('/admin_user/update', [app\admin\controller\AdminUserController::class, 'update'])->name('admin.admin_user.update'); + Route::post('/admin_user/delete', [app\admin\controller\AdminUserController::class, 'delete'])->name('admin.admin_user.delete'); + + // C-User + Route::get('/user/list', [app\admin\controller\UserController::class, 'list'])->name('admin.user.list'); + Route::post('/user/update_status', [app\admin\controller\UserController::class, 'updateStatus'])->name('admin.user.update_status'); + + // Role + Route::get('/role/list', [app\admin\controller\RoleController::class, 'list'])->name('admin.role.list'); + Route::get('/role/all', [app\admin\controller\RoleController::class, 'all'])->name('admin.role.all'); + Route::post('/role/create', [app\admin\controller\RoleController::class, 'create'])->name('admin.role.create'); + Route::post('/role/update', [app\admin\controller\RoleController::class, 'update'])->name('admin.role.update'); + Route::post('/role/delete', [app\admin\controller\RoleController::class, 'delete'])->name('admin.role.delete'); + + // Permission + Route::get('/permission/list', [app\admin\controller\PermissionController::class, 'list'])->name('admin.permission.list'); + Route::post('/permission/create', [app\admin\controller\PermissionController::class, 'create'])->name('admin.permission.create'); + Route::post('/permission/update', [app\admin\controller\PermissionController::class, 'update'])->name('admin.permission.update'); + Route::post('/permission/delete', [app\admin\controller\PermissionController::class, 'delete'])->name('admin.permission.delete'); + + // Wechat Merchant + Route::get('/wechat_merchant/list', [app\admin\controller\WechatMerchantController::class, 'list'])->name('admin.wechat_merchant.list'); + Route::post('/wechat_merchant/create', [app\admin\controller\WechatMerchantController::class, 'create'])->name('admin.wechat_merchant.create'); + Route::post('/wechat_merchant/update', [app\admin\controller\WechatMerchantController::class, 'update'])->name('admin.wechat_merchant.update'); + Route::post('/wechat_merchant/delete', [app\admin\controller\WechatMerchantController::class, 'delete'])->name('admin.wechat_merchant.delete'); + Route::post('/wechat_merchant/upload_apiclient_cert', [app\admin\controller\WechatMerchantController::class, 'uploadApiclientCert'])->name('admin.wechat_merchant.upload_apiclient_cert'); + Route::post('/wechat_merchant/upload_apiclient_key', [app\admin\controller\WechatMerchantController::class, 'uploadApiclientKey'])->name('admin.wechat_merchant.upload_apiclient_key'); + Route::post('/wechat_merchant/upload_api_v3_key', [app\admin\controller\WechatMerchantController::class, 'uploadApiV3Key'])->name('admin.wechat_merchant.upload_api_v3_key'); + + // Wechat App + Route::get('/wechat_app/list', [app\admin\controller\WechatAppController::class, 'list'])->name('admin.wechat_app.list'); + Route::post('/wechat_app/create', [app\admin\controller\WechatAppController::class, 'create'])->name('admin.wechat_app.create'); + Route::post('/wechat_app/update', [app\admin\controller\WechatAppController::class, 'update'])->name('admin.wechat_app.update'); + Route::post('/wechat_app/delete', [app\admin\controller\WechatAppController::class, 'delete'])->name('admin.wechat_app.delete'); + + })->middleware([ + app\admin\middleware\AuthMiddleware::class, + app\admin\middleware\PermissionMiddleware::class, + ]); +}); diff --git a/config/server.php b/config/server.php new file mode 100644 index 0000000..238f1aa --- /dev/null +++ b/config/server.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'event_loop' => '', + 'stop_timeout' => 2, + 'pid_file' => runtime_path() . '/webman.pid', + 'status_file' => runtime_path() . '/webman.status', + 'stdout_file' => runtime_path() . '/logs/stdout.log', + 'log_file' => runtime_path() . '/logs/workerman.log', + 'max_package_size' => 10 * 1024 * 1024 +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..043f8c4 --- /dev/null +++ b/config/session.php @@ -0,0 +1,65 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Session\FileSessionHandler; +use Webman\Session\RedisSessionHandler; +use Webman\Session\RedisClusterSessionHandler; + +return [ + + 'type' => 'file', // or redis or redis_cluster + + 'handler' => FileSessionHandler::class, + + 'config' => [ + 'file' => [ + 'save_path' => runtime_path() . '/sessions', + ], + 'redis' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'auth' => '', + 'timeout' => 2, + 'database' => '', + 'prefix' => 'redis_session_', + ], + 'redis_cluster' => [ + 'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'], + 'timeout' => 2, + 'auth' => '', + 'prefix' => 'redis_session_', + ] + ], + + 'session_name' => 'PHPSID', + + 'auto_update_timestamp' => false, + + 'lifetime' => 7*24*60*60, + + 'cookie_lifetime' => 365*24*60*60, + + 'cookie_path' => '/', + + 'domain' => '', + + 'http_only' => true, + + 'secure' => false, + + 'same_site' => '', + + 'gc_probability' => [1, 1000], + +]; diff --git a/config/static.php b/config/static.php new file mode 100644 index 0000000..2f76cf3 --- /dev/null +++ b/config/static.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +/** + * Static file settings + */ +return [ + 'enable' => true, + 'middleware' => [ // Static file Middleware + //app\middleware\StaticFile::class, + ], +]; \ No newline at end of file diff --git a/config/translation.php b/config/translation.php new file mode 100644 index 0000000..96589b2 --- /dev/null +++ b/config/translation.php @@ -0,0 +1,25 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +/** + * Multilingual configuration + */ +return [ + // Default language + 'locale' => 'zh_CN', + // Fallback language + 'fallback_locale' => ['zh_CN', 'en'], + // Folder where language files are stored + 'path' => base_path() . '/resource/translations', +]; \ No newline at end of file diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..e3a7b85 --- /dev/null +++ b/config/view.php @@ -0,0 +1,22 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\view\Raw; +use support\view\Twig; +use support\view\Blade; +use support\view\ThinkPHP; + +return [ + 'handler' => Raw::class +]; diff --git a/database.sqlite b/database.sqlite new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..352871e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3" +services: + webman: + build: . + container_name: docker-webman + restart: unless-stopped + volumes: + - "./:/app" + ports: + - "8787:8787" + command: ["php", "start.php", "start" ] \ No newline at end of file diff --git a/migrate.php b/migrate.php new file mode 100644 index 0000000..5ef6963 --- /dev/null +++ b/migrate.php @@ -0,0 +1,185 @@ +load(); +} + +$capsule = new Capsule; + +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); + +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +// 1. 创建 users 表 +if (!Capsule::schema()->hasTable('users')) { + Capsule::schema()->create('users', function ($table) { + $table->id(); + $table->string('openid', 64)->nullable()->unique(); + $table->string('mobile', 20)->nullable()->unique(); + $table->string('nickname', 50)->nullable(); + $table->string('avatar', 255)->nullable(); + $table->tinyInteger('status')->default(1)->comment('1:正常 0:禁用'); + $table->timestamps(); + }); + echo "Table 'users' created successfully.\n"; +} + +// 2. 创建 orders 表 +if (!Capsule::schema()->hasTable('orders')) { + Capsule::schema()->create('orders', function ($table) { + $table->id(); + $table->string('order_no', 32)->unique(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('category', 32); + $table->string('service_type', 64); + $table->string('brand', 64); + $table->string('model', 128)->nullable(); + $table->string('remark', 500)->nullable(); + $table->tinyInteger('is_fast')->default(0); + $table->decimal('total_price', 10, 2)->default(0.00); + $table->string('status', 20)->default('wait_pay')->index(); + $table->string('express_company', 32)->nullable(); + $table->string('express_no', 64)->nullable(); + $table->timestamp('pay_time')->nullable(); + $table->timestamps(); + }); + echo "Table 'orders' created successfully.\n"; +} + +// 3. 创建 order_logs 表 +if (!Capsule::schema()->hasTable('order_logs')) { + Capsule::schema()->create('order_logs', function ($table) { + $table->id(); + $table->unsignedBigInteger('order_id'); + $table->string('action_type', 32); + $table->string('title', 64); + $table->string('description', 500)->nullable(); + $table->string('operator_type', 20)->default('system'); + $table->bigInteger('operator_id')->default(0); + $table->timestamps(); + + $table->index(['order_id', 'created_at']); + }); + echo "Table 'order_logs' created successfully.\n"; +} + +// 4. 创建 reports 表 +if (!Capsule::schema()->hasTable('reports')) { + Capsule::schema()->create('reports', function ($table) { + $table->id(); + $table->string('report_no', 32)->unique(); + $table->unsignedBigInteger('order_id')->unique(); + $table->string('conclusion', 20); + $table->string('level', 20)->nullable(); + $table->json('flaws_json')->nullable(); + $table->json('images_json'); + $table->bigInteger('inspector_id'); + $table->string('verify_code', 32)->unique(); + $table->timestamps(); + }); + echo "Table 'reports' created successfully.\n"; +} + +if (!Capsule::schema()->hasTable('admin_users')) { + Capsule::schema()->create('admin_users', function ($table) { + $table->id(); + $table->string('username', 50)->unique(); + $table->string('password_hash', 255); + $table->string('nickname', 50)->nullable(); + $table->tinyInteger('is_super')->default(0); + $table->tinyInteger('status')->default(1); + $table->timestamps(); + }); + echo "Table 'admin_users' created successfully.\n"; +} + +if (!Capsule::schema()->hasTable('roles')) { + Capsule::schema()->create('roles', function ($table) { + $table->id(); + $table->string('name', 50); + $table->string('code', 50)->unique(); + $table->timestamps(); + }); + echo "Table 'roles' created successfully.\n"; +} + +if (!Capsule::schema()->hasTable('permissions')) { + Capsule::schema()->create('permissions', function ($table) { + $table->id(); + $table->string('name', 50); + $table->string('code', 80)->unique(); + $table->timestamps(); + }); + echo "Table 'permissions' created successfully.\n"; +} + +if (!Capsule::schema()->hasTable('admin_roles')) { + Capsule::schema()->create('admin_roles', function ($table) { + $table->id(); + $table->unsignedBigInteger('admin_id'); + $table->unsignedBigInteger('role_id'); + $table->timestamps(); + $table->unique(['admin_id', 'role_id']); + $table->index(['admin_id']); + $table->index(['role_id']); + }); + echo "Table 'admin_roles' created successfully.\n"; +} + +if (!Capsule::schema()->hasTable('role_permissions')) { + Capsule::schema()->create('role_permissions', function ($table) { + $table->id(); + $table->unsignedBigInteger('role_id'); + $table->unsignedBigInteger('permission_id'); + $table->timestamps(); + $table->unique(['role_id', 'permission_id']); + $table->index(['role_id']); + $table->index(['permission_id']); + }); + echo "Table 'role_permissions' created successfully.\n"; +} + +if (!Capsule::schema()->hasTable('user_tokens')) { + Capsule::schema()->create('user_tokens', function ($table) { + $table->id(); + $table->unsignedBigInteger('user_id'); + $table->string('token_hash', 64)->unique(); + $table->timestamp('expired_at')->nullable(); + $table->timestamps(); + $table->index(['user_id']); + $table->index(['expired_at']); + }); + echo "Table 'user_tokens' created successfully.\n"; +} + +if (!Capsule::schema()->hasTable('admin_tokens')) { + Capsule::schema()->create('admin_tokens', function ($table) { + $table->id(); + $table->unsignedBigInteger('admin_id'); + $table->string('token_hash', 64)->unique(); + $table->timestamp('expired_at')->nullable(); + $table->timestamps(); + $table->index(['admin_id']); + $table->index(['expired_at']); + }); + echo "Table 'admin_tokens' created successfully.\n"; +} + +echo "All migrations completed.\n"; diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..b9f722e Binary files /dev/null and b/public/favicon.ico differ diff --git a/seed.php b/seed.php new file mode 100644 index 0000000..ba1dcc6 --- /dev/null +++ b/seed.php @@ -0,0 +1,126 @@ +load(); + } else { + Dotenv\Dotenv::createMutable(__DIR__)->load(); + } +} + +$capsule = new Capsule(); +$capsule->addConnection([ + 'driver' => 'mysql', + 'host' => getenv('DB_HOST') ?: '127.0.0.1', + 'port' => getenv('DB_PORT') ?: '3306', + 'database' => getenv('DB_DATABASE') ?: '', + 'username' => getenv('DB_USERNAME') ?: '', + 'password' => getenv('DB_PASSWORD') ?: '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +$permissionNodes = [ + ['code' => 'admin.menu.dashboard', 'name' => '工作台', 'parent_code' => null, 'type' => 1, 'sort' => 10], + ['code' => 'admin.dashboard.stat', 'name' => '工作台统计', 'parent_code' => 'admin.menu.dashboard', 'type' => 1, 'sort' => 10], + + ['code' => 'admin.menu.order', 'name' => '订单管理', 'parent_code' => null, 'type' => 1, 'sort' => 20], + ['code' => 'admin.order.list', 'name' => '订单列表', 'parent_code' => 'admin.menu.order', 'type' => 1, 'sort' => 10], + ['code' => 'admin.order.detail', 'name' => '订单详情', 'parent_code' => 'admin.order.list', 'type' => 2, 'sort' => 20], + ['code' => 'admin.order.receive', 'name' => '确认收件', 'parent_code' => 'admin.order.list', 'type' => 2, 'sort' => 30], + ['code' => 'admin.order.return_ship', 'name' => '回寄物流填写', 'parent_code' => 'admin.order.list', 'type' => 2, 'sort' => 40], + + ['code' => 'admin.menu.report', 'name' => '报告管理', 'parent_code' => null, 'type' => 1, 'sort' => 30], + ['code' => 'admin.report.list', 'name' => '报告列表', 'parent_code' => 'admin.menu.report', 'type' => 1, 'sort' => 10], + ['code' => 'admin.report.detail', 'name' => '报告详情', 'parent_code' => 'admin.report.list', 'type' => 2, 'sort' => 20], + ['code' => 'admin.report.create', 'name' => '出具报告', 'parent_code' => 'admin.report.list', 'type' => 2, 'sort' => 30], + + ['code' => 'admin.menu.user', 'name' => '用户管理', 'parent_code' => null, 'type' => 1, 'sort' => 40], + ['code' => 'admin.user.list', 'name' => 'C端用户列表', 'parent_code' => 'admin.menu.user', 'type' => 1, 'sort' => 10], + ['code' => 'admin.user.update_status', 'name' => '用户状态修改', 'parent_code' => 'admin.user.list', 'type' => 2, 'sort' => 20], + + ['code' => 'admin.menu.system', 'name' => '系统设置', 'parent_code' => null, 'type' => 1, 'sort' => 50], + + ['code' => 'admin.admin_user.list', 'name' => '管理员管理', 'parent_code' => 'admin.menu.system', 'type' => 1, 'sort' => 10], + ['code' => 'admin.admin_user.create', 'name' => '管理员创建', 'parent_code' => 'admin.admin_user.list', 'type' => 2, 'sort' => 20], + ['code' => 'admin.admin_user.update', 'name' => '管理员更新', 'parent_code' => 'admin.admin_user.list', 'type' => 2, 'sort' => 30], + ['code' => 'admin.admin_user.delete', 'name' => '管理员删除', 'parent_code' => 'admin.admin_user.list', 'type' => 2, 'sort' => 40], + + ['code' => 'admin.role.list', 'name' => '角色管理', 'parent_code' => 'admin.menu.system', 'type' => 1, 'sort' => 20], + ['code' => 'admin.role.all', 'name' => '角色全部列表', 'parent_code' => 'admin.role.list', 'type' => 2, 'sort' => 30], + ['code' => 'admin.role.create', 'name' => '角色创建', 'parent_code' => 'admin.role.list', 'type' => 2, 'sort' => 40], + ['code' => 'admin.role.update', 'name' => '角色更新', 'parent_code' => 'admin.role.list', 'type' => 2, 'sort' => 50], + ['code' => 'admin.role.delete', 'name' => '角色删除', 'parent_code' => 'admin.role.list', 'type' => 2, 'sort' => 60], + + ['code' => 'admin.permission.list', 'name' => '权限管理', 'parent_code' => 'admin.menu.system', 'type' => 1, 'sort' => 30], + ['code' => 'admin.permission.create', 'name' => '权限创建', 'parent_code' => 'admin.permission.list', 'type' => 2, 'sort' => 40], + ['code' => 'admin.permission.update', 'name' => '权限更新', 'parent_code' => 'admin.permission.list', 'type' => 2, 'sort' => 50], + ['code' => 'admin.permission.delete', 'name' => '权限删除', 'parent_code' => 'admin.permission.list', 'type' => 2, 'sort' => 60], + + ['code' => 'admin.wechat_merchant.list', 'name' => '微信商户号', 'parent_code' => 'admin.menu.system', 'type' => 1, 'sort' => 40], + ['code' => 'admin.wechat_merchant.create', 'name' => '微信商户号创建', 'parent_code' => 'admin.wechat_merchant.list', 'type' => 2, 'sort' => 20], + ['code' => 'admin.wechat_merchant.update', 'name' => '微信商户号更新', 'parent_code' => 'admin.wechat_merchant.list', 'type' => 2, 'sort' => 30], + ['code' => 'admin.wechat_merchant.delete', 'name' => '微信商户号删除', 'parent_code' => 'admin.wechat_merchant.list', 'type' => 2, 'sort' => 40], + ['code' => 'admin.wechat_merchant.upload_apiclient_cert', 'name' => '商户证书上传', 'parent_code' => 'admin.wechat_merchant.list', 'type' => 2, 'sort' => 50], + ['code' => 'admin.wechat_merchant.upload_apiclient_key', 'name' => '商户私钥上传', 'parent_code' => 'admin.wechat_merchant.list', 'type' => 2, 'sort' => 60], + ['code' => 'admin.wechat_merchant.upload_api_v3_key', 'name' => 'APIv3密钥上传', 'parent_code' => 'admin.wechat_merchant.list', 'type' => 2, 'sort' => 70], + + ['code' => 'admin.wechat_app.list', 'name' => '微信应用', 'parent_code' => 'admin.menu.system', 'type' => 1, 'sort' => 35], + ['code' => 'admin.wechat_app.create', 'name' => '微信应用创建', 'parent_code' => 'admin.wechat_app.list', 'type' => 2, 'sort' => 20], + ['code' => 'admin.wechat_app.update', 'name' => '微信应用更新', 'parent_code' => 'admin.wechat_app.list', 'type' => 2, 'sort' => 30], + ['code' => 'admin.wechat_app.delete', 'name' => '微信应用删除', 'parent_code' => 'admin.wechat_app.list', 'type' => 2, 'sort' => 40], +]; + +$codeToId = []; +foreach ($permissionNodes as $node) { + $permission = Permission::updateOrCreate(['code' => $node['code']], [ + 'name' => $node['name'], + 'parent_id' => 0, + 'type' => $node['type'], + 'sort' => $node['sort'], + ]); + $codeToId[$node['code']] = $permission->id; +} + +foreach ($permissionNodes as $node) { + $parentCode = $node['parent_code']; + $parentId = $parentCode && isset($codeToId[$parentCode]) ? $codeToId[$parentCode] : 0; + Permission::where('code', $node['code'])->update(['parent_id' => $parentId]); +} + +$role = Role::firstOrCreate(['code' => 'super_admin'], ['name' => '超级管理员']); +$permissionCodes = array_map(function ($n) { return $n['code']; }, $permissionNodes); +$permissionIds = Permission::whereIn('code', $permissionCodes)->pluck('id')->toArray(); +$role->permissions()->syncWithoutDetaching($permissionIds); + +$username = getenv('ADMIN_INIT_USERNAME') ?: 'admin'; +$password = getenv('ADMIN_INIT_PASSWORD') ?: ''; +if ($password === '') { + echo "ADMIN_INIT_PASSWORD 为空,请先在 .env 中设置\n"; + exit(1); +} + +$admin = AdminUser::where('username', $username)->first(); +if (!$admin) { + $admin = AdminUser::create([ + 'username' => $username, + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + 'nickname' => '管理员', + 'is_super' => 1, + 'status' => 1, + ]); +} + +$admin->roles()->syncWithoutDetaching([$role->id]); + +echo "seed ok\n"; diff --git a/start.php b/start.php new file mode 100755 index 0000000..4beb62a --- /dev/null +++ b/start.php @@ -0,0 +1,6 @@ +#!/usr/bin/env php +getCode(); + $message = $exception->getMessage(); + $data = null; + + if ($exception instanceof BusinessException) { + $code = $code ?: 400; + $data = $exception->getData(); + } else { + $code = 500; + $message = 'Server internal error'; + if (config('app.debug')) { + $message = $exception->getMessage(); + } + } + + return json([ + 'code' => $code, + 'msg' => $message, + 'data' => $data, + ]); + } +} diff --git a/support/Request.php b/support/Request.php new file mode 100644 index 0000000..e3f6ac3 --- /dev/null +++ b/support/Request.php @@ -0,0 +1,24 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace support; + +/** + * Class Request + * @package support + */ +class Request extends \Webman\Http\Request +{ + +} \ No newline at end of file diff --git a/support/Response.php b/support/Response.php new file mode 100644 index 0000000..9bc4e1e --- /dev/null +++ b/support/Response.php @@ -0,0 +1,24 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace support; + +/** + * Class Response + * @package support + */ +class Response extends \Webman\Http\Response +{ + +} \ No newline at end of file diff --git a/support/Setup.php b/support/Setup.php new file mode 100644 index 0000000..99320e8 --- /dev/null +++ b/support/Setup.php @@ -0,0 +1,1558 @@ + \DateTimeZone::ASIA, + 'Europe' => \DateTimeZone::EUROPE, + 'America' => \DateTimeZone::AMERICA, + 'Africa' => \DateTimeZone::AFRICA, + 'Australia' => \DateTimeZone::AUSTRALIA, + 'Pacific' => \DateTimeZone::PACIFIC, + 'Atlantic' => \DateTimeZone::ATLANTIC, + 'Indian' => \DateTimeZone::INDIAN, + 'Antarctica' => \DateTimeZone::ANTARCTICA, + 'Arctic' => \DateTimeZone::ARCTIC, + 'UTC' => \DateTimeZone::UTC, + ]; + + // --- Locale => default timezone --- + + private const LOCALE_DEFAULT_TIMEZONES = [ + 'zh_CN' => 'Asia/Shanghai', + 'zh_TW' => 'Asia/Taipei', + 'en' => 'UTC', + 'ja' => 'Asia/Tokyo', + 'ko' => 'Asia/Seoul', + 'fr' => 'Europe/Paris', + 'de' => 'Europe/Berlin', + 'es' => 'Europe/Madrid', + 'pt_BR' => 'America/Sao_Paulo', + 'ru' => 'Europe/Moscow', + 'vi' => 'Asia/Ho_Chi_Minh', + 'tr' => 'Europe/Istanbul', + 'id' => 'Asia/Jakarta', + 'th' => 'Asia/Bangkok', + ]; + + // --- Locale options (localized display names) --- + + private const LOCALE_LABELS = [ + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + 'en' => 'English', + 'ja' => '日本語', + 'ko' => '한국어', + 'fr' => 'Français', + 'de' => 'Deutsch', + 'es' => 'Español', + 'pt_BR' => 'Português (Brasil)', + 'ru' => 'Русский', + 'vi' => 'Tiếng Việt', + 'tr' => 'Türkçe', + 'id' => 'Bahasa Indonesia', + 'th' => 'ไทย', + ]; + + // --- Multilingual messages (%s = placeholder) --- + + private const MESSAGES = [ + 'zh_CN' => [ + 'remove_package_question' => '发现以下已安装组件本次未选择,是否将其卸载 ?%s', + 'removing_package' => '- 准备移除组件 %s', + 'removing' => '卸载:', + 'error_remove' => '卸载组件出错,请手动执行:composer remove %s', + 'done_remove' => '已卸载组件。', + 'skip' => '非交互模式,跳过安装向导。', + 'default_choice' => ' (默认 %s)', + 'timezone_prompt' => '时区 (默认 %s,输入可联想补全): ', + 'timezone_title' => '时区设置 (默认 %s)', + 'timezone_help' => '输入关键字Tab自动补全,可↑↓下选择:', + 'timezone_region' => '选择时区区域', + 'timezone_city' => '选择时区', + 'timezone_invalid' => '无效的时区,已使用默认值 %s', + 'timezone_input_prompt' => '输入时区或关键字:', + 'timezone_pick_prompt' => '请输入数字编号或关键字:', + 'timezone_no_match' => '未找到匹配的时区,请重试。', + 'timezone_invalid_index' => '无效的编号,请重新输入。', + 'yes' => '是', + 'no' => '否', + 'adding_package' => '- 添加依赖 %s', + 'console_question' => '安装命令行组件 webman/console', + 'db_question' => '数据库组件', + 'db_none' => '不安装', + 'db_invalid' => '请输入有效选项', + 'redis_question' => '安装 Redis 组件 webman/redis', + 'events_note' => ' (Redis 依赖 illuminate/events,已自动包含)', + 'validation_question' => '安装验证器组件 webman/validation', + 'template_question' => '模板引擎', + 'template_none' => '不安装', + 'no_components' => '未选择额外组件。', + 'installing' => '即将安装:', + 'running' => '执行:', + 'error_install' => '安装可选组件时出错,请手动执行:composer require %s', + 'done' => '可选组件安装完成。', + 'summary_locale' => '语言:%s', + 'summary_timezone' => '时区:%s', + ], + 'zh_TW' => [ + 'skip' => '非交互模式,跳過安裝嚮導。', + 'default_choice' => ' (預設 %s)', + 'timezone_prompt' => '時區 (預設 %s,輸入可聯想補全): ', + 'timezone_title' => '時區設定 (預設 %s)', + 'timezone_help' => '輸入關鍵字Tab自動補全,可↑↓上下選擇:', + 'timezone_region' => '選擇時區區域', + 'timezone_city' => '選擇時區', + 'timezone_invalid' => '無效的時區,已使用預設值 %s', + 'timezone_input_prompt' => '輸入時區或關鍵字:', + 'timezone_pick_prompt' => '請輸入數字編號或關鍵字:', + 'timezone_no_match' => '未找到匹配的時區,請重試。', + 'timezone_invalid_index' => '無效的編號,請重新輸入。', + 'yes' => '是', + 'no' => '否', + 'adding_package' => '- 新增依賴 %s', + 'console_question' => '安裝命令列組件 webman/console', + 'db_question' => '資料庫組件', + 'db_none' => '不安裝', + 'db_invalid' => '請輸入有效選項', + 'redis_question' => '安裝 Redis 組件 webman/redis', + 'events_note' => ' (Redis 依賴 illuminate/events,已自動包含)', + 'validation_question' => '安裝驗證器組件 webman/validation', + 'template_question' => '模板引擎', + 'template_none' => '不安裝', + 'no_components' => '未選擇額外組件。', + 'installing' => '即將安裝:', + 'running' => '執行:', + 'error_install' => '安裝可選組件時出錯,請手動執行:composer require %s', + 'done' => '可選組件安裝完成。', + 'summary_locale' => '語言:%s', + 'summary_timezone' => '時區:%s', + ], + 'en' => [ + 'skip' => 'Non-interactive mode, skipping setup wizard.', + 'default_choice' => ' (default %s)', + 'timezone_prompt' => 'Timezone (default=%s, type to autocomplete): ', + 'timezone_title' => 'Timezone (default=%s)', + 'timezone_help' => 'Type keyword then press Tab to autocomplete, use ↑↓ to choose:', + 'timezone_region' => 'Select timezone region', + 'timezone_city' => 'Select timezone', + 'timezone_invalid' => 'Invalid timezone, using default %s', + 'timezone_input_prompt' => 'Enter timezone or keyword:', + 'timezone_pick_prompt' => 'Enter number or keyword:', + 'timezone_no_match' => 'No matching timezone found, please try again.', + 'timezone_invalid_index' => 'Invalid number, please try again.', + 'yes' => 'yes', + 'no' => 'no', + 'adding_package' => '- Adding package %s', + 'console_question' => 'Install console component webman/console', + 'db_question' => 'Database component', + 'db_none' => 'None', + 'db_invalid' => 'Please enter a valid option', + 'redis_question' => 'Install Redis component webman/redis', + 'events_note' => ' (Redis requires illuminate/events, automatically included)', + 'validation_question' => 'Install validator component webman/validation', + 'template_question' => 'Template engine', + 'template_none' => 'None', + 'no_components' => 'No optional components selected.', + 'installing' => 'Installing:', + 'running' => 'Running:', + 'error_install' => 'Failed to install. Try manually: composer require %s', + 'done' => 'Optional components installed.', + 'summary_locale' => 'Language: %s', + 'summary_timezone' => 'Timezone: %s', + ], + 'ja' => [ + 'skip' => '非対話モードのため、セットアップウィザードをスキップします。', + 'default_choice' => ' (デフォルト %s)', + 'timezone_prompt' => 'タイムゾーン (デフォルト=%s、入力で補完): ', + 'timezone_title' => 'タイムゾーン (デフォルト=%s)', + 'timezone_help' => 'キーワード入力→Tabで補完、↑↓で選択:', + 'timezone_region' => 'タイムゾーンの地域を選択', + 'timezone_city' => 'タイムゾーンを選択', + 'timezone_invalid' => '無効なタイムゾーンです。デフォルト %s を使用します', + 'timezone_input_prompt' => 'タイムゾーンまたはキーワードを入力:', + 'timezone_pick_prompt' => '番号またはキーワードを入力:', + 'timezone_no_match' => '一致するタイムゾーンが見つかりません。再試行してください。', + 'timezone_invalid_index' => '無効な番号です。もう一度入力してください。', + 'yes' => 'はい', + 'no' => 'いいえ', + 'adding_package' => '- パッケージを追加 %s', + 'console_question' => 'コンソールコンポーネント webman/console をインストール', + 'db_question' => 'データベースコンポーネント', + 'db_none' => 'インストールしない', + 'db_invalid' => '有効なオプションを入力してください', + 'redis_question' => 'Redis コンポーネント webman/redis をインストール', + 'events_note' => ' (Redis は illuminate/events が必要です。自動的に含まれます)', + 'validation_question' => 'バリデーションコンポーネント webman/validation をインストール', + 'template_question' => 'テンプレートエンジン', + 'template_none' => 'インストールしない', + 'no_components' => 'オプションコンポーネントが選択されていません。', + 'installing' => 'インストール中:', + 'running' => '実行中:', + 'error_install' => 'インストールに失敗しました。手動で実行してください:composer require %s', + 'done' => 'オプションコンポーネントのインストールが完了しました。', + 'summary_locale' => '言語:%s', + 'summary_timezone' => 'タイムゾーン:%s', + ], + 'ko' => [ + 'skip' => '비대화형 모드입니다. 설치 마법사를 건너뜁니다.', + 'default_choice' => ' (기본값 %s)', + 'timezone_prompt' => '시간대 (기본값=%s, 입력하여 자동완성): ', + 'timezone_title' => '시간대 (기본값=%s)', + 'timezone_help' => '키워드 입력 후 Tab 자동완성, ↑↓로 선택:', + 'timezone_region' => '시간대 지역 선택', + 'timezone_city' => '시간대 선택', + 'timezone_invalid' => '잘못된 시간대입니다. 기본값 %s 을(를) 사용합니다', + 'timezone_input_prompt' => '시간대 또는 키워드 입력:', + 'timezone_pick_prompt' => '번호 또는 키워드 입력:', + 'timezone_no_match' => '일치하는 시간대를 찾을 수 없습니다. 다시 시도하세요.', + 'timezone_invalid_index' => '잘못된 번호입니다. 다시 입력하세요.', + 'yes' => '예', + 'no' => '아니오', + 'adding_package' => '- 패키지 추가 %s', + 'console_question' => '콘솔 컴포넌트 webman/console 설치', + 'db_question' => '데이터베이스 컴포넌트', + 'db_none' => '설치 안 함', + 'db_invalid' => '유효한 옵션을 입력하세요', + 'redis_question' => 'Redis 컴포넌트 webman/redis 설치', + 'events_note' => ' (Redis는 illuminate/events가 필요합니다. 자동으로 포함됩니다)', + 'validation_question' => '검증 컴포넌트 webman/validation 설치', + 'template_question' => '템플릿 엔진', + 'template_none' => '설치 안 함', + 'no_components' => '선택된 추가 컴포넌트가 없습니다.', + 'installing' => '설치 예정:', + 'running' => '실행 중:', + 'error_install' => '설치에 실패했습니다. 수동으로 실행하세요: composer require %s', + 'done' => '선택 컴포넌트 설치가 완료되었습니다.', + 'summary_locale' => '언어: %s', + 'summary_timezone' => '시간대: %s', + ], + 'fr' => [ + 'skip' => 'Mode non interactif, assistant d\'installation ignoré.', + 'default_choice' => ' (défaut %s)', + 'timezone_prompt' => 'Fuseau horaire (défaut=%s, tapez pour compléter) : ', + 'timezone_title' => 'Fuseau horaire (défaut=%s)', + 'timezone_help' => 'Tapez un mot-clé, Tab pour compléter, ↑↓ pour choisir :', + 'timezone_region' => 'Sélectionnez la région du fuseau horaire', + 'timezone_city' => 'Sélectionnez le fuseau horaire', + 'timezone_invalid' => 'Fuseau horaire invalide, utilisation de %s par défaut', + 'timezone_input_prompt' => 'Entrez un fuseau horaire ou un mot-clé :', + 'timezone_pick_prompt' => 'Entrez un numéro ou un mot-clé :', + 'timezone_no_match' => 'Aucun fuseau horaire correspondant, veuillez réessayer.', + 'timezone_invalid_index' => 'Numéro invalide, veuillez réessayer.', + 'yes' => 'oui', + 'no' => 'non', + 'adding_package' => '- Ajout du paquet %s', + 'console_question' => 'Installer le composant console webman/console', + 'db_question' => 'Composant base de données', + 'db_none' => 'Aucun', + 'db_invalid' => 'Veuillez entrer une option valide', + 'redis_question' => 'Installer le composant Redis webman/redis', + 'events_note' => ' (Redis nécessite illuminate/events, inclus automatiquement)', + 'validation_question' => 'Installer le composant de validation webman/validation', + 'template_question' => 'Moteur de templates', + 'template_none' => 'Aucun', + 'no_components' => 'Aucun composant optionnel sélectionné.', + 'installing' => 'Installation en cours :', + 'running' => 'Exécution :', + 'error_install' => 'Échec de l\'installation. Essayez manuellement : composer require %s', + 'done' => 'Composants optionnels installés.', + 'summary_locale' => 'Langue : %s', + 'summary_timezone' => 'Fuseau horaire : %s', + ], + 'de' => [ + 'skip' => 'Nicht-interaktiver Modus, Einrichtungsassistent übersprungen.', + 'default_choice' => ' (Standard %s)', + 'timezone_prompt' => 'Zeitzone (Standard=%s, Eingabe zur Vervollständigung): ', + 'timezone_title' => 'Zeitzone (Standard=%s)', + 'timezone_help' => 'Stichwort tippen, Tab ergänzt, ↑↓ auswählen:', + 'timezone_region' => 'Zeitzone Region auswählen', + 'timezone_city' => 'Zeitzone auswählen', + 'timezone_invalid' => 'Ungültige Zeitzone, Standardwert %s wird verwendet', + 'timezone_input_prompt' => 'Zeitzone oder Stichwort eingeben:', + 'timezone_pick_prompt' => 'Nummer oder Stichwort eingeben:', + 'timezone_no_match' => 'Keine passende Zeitzone gefunden, bitte erneut versuchen.', + 'timezone_invalid_index' => 'Ungültige Nummer, bitte erneut eingeben.', + 'yes' => 'ja', + 'no' => 'nein', + 'adding_package' => '- Paket hinzufügen %s', + 'console_question' => 'Konsolen-Komponente webman/console installieren', + 'db_question' => 'Datenbank-Komponente', + 'db_none' => 'Keine', + 'db_invalid' => 'Bitte geben Sie eine gültige Option ein', + 'redis_question' => 'Redis-Komponente webman/redis installieren', + 'events_note' => ' (Redis benötigt illuminate/events, automatisch eingeschlossen)', + 'validation_question' => 'Validierungs-Komponente webman/validation installieren', + 'template_question' => 'Template-Engine', + 'template_none' => 'Keine', + 'no_components' => 'Keine optionalen Komponenten ausgewählt.', + 'installing' => 'Installation:', + 'running' => 'Ausführung:', + 'error_install' => 'Installation fehlgeschlagen. Manuell ausführen: composer require %s', + 'done' => 'Optionale Komponenten installiert.', + 'summary_locale' => 'Sprache: %s', + 'summary_timezone' => 'Zeitzone: %s', + ], + 'es' => [ + 'skip' => 'Modo no interactivo, asistente de instalación omitido.', + 'default_choice' => ' (predeterminado %s)', + 'timezone_prompt' => 'Zona horaria (predeterminado=%s, escriba para autocompletar): ', + 'timezone_title' => 'Zona horaria (predeterminado=%s)', + 'timezone_help' => 'Escriba una palabra clave, Tab autocompleta, use ↑↓ para elegir:', + 'timezone_region' => 'Seleccione la región de zona horaria', + 'timezone_city' => 'Seleccione la zona horaria', + 'timezone_invalid' => 'Zona horaria inválida, usando valor predeterminado %s', + 'timezone_input_prompt' => 'Ingrese zona horaria o palabra clave:', + 'timezone_pick_prompt' => 'Ingrese número o palabra clave:', + 'timezone_no_match' => 'No se encontró zona horaria coincidente, intente de nuevo.', + 'timezone_invalid_index' => 'Número inválido, intente de nuevo.', + 'yes' => 'sí', + 'no' => 'no', + 'adding_package' => '- Agregando paquete %s', + 'console_question' => 'Instalar componente de consola webman/console', + 'db_question' => 'Componente de base de datos', + 'db_none' => 'Ninguno', + 'db_invalid' => 'Por favor ingrese una opción válida', + 'redis_question' => 'Instalar componente Redis webman/redis', + 'events_note' => ' (Redis requiere illuminate/events, incluido automáticamente)', + 'validation_question' => 'Instalar componente de validación webman/validation', + 'template_question' => 'Motor de plantillas', + 'template_none' => 'Ninguno', + 'no_components' => 'No se seleccionaron componentes opcionales.', + 'installing' => 'Instalando:', + 'running' => 'Ejecutando:', + 'error_install' => 'Error en la instalación. Intente manualmente: composer require %s', + 'done' => 'Componentes opcionales instalados.', + 'summary_locale' => 'Idioma: %s', + 'summary_timezone' => 'Zona horaria: %s', + ], + 'pt_BR' => [ + 'skip' => 'Modo não interativo, assistente de instalação ignorado.', + 'default_choice' => ' (padrão %s)', + 'timezone_prompt' => 'Fuso horário (padrão=%s, digite para autocompletar): ', + 'timezone_title' => 'Fuso horário (padrão=%s)', + 'timezone_help' => 'Digite uma palavra-chave, Tab autocompleta, use ↑↓ para escolher:', + 'timezone_region' => 'Selecione a região do fuso horário', + 'timezone_city' => 'Selecione o fuso horário', + 'timezone_invalid' => 'Fuso horário inválido, usando padrão %s', + 'timezone_input_prompt' => 'Digite fuso horário ou palavra-chave:', + 'timezone_pick_prompt' => 'Digite número ou palavra-chave:', + 'timezone_no_match' => 'Nenhum fuso horário encontrado, tente novamente.', + 'timezone_invalid_index' => 'Número inválido, tente novamente.', + 'yes' => 'sim', + 'no' => 'não', + 'adding_package' => '- Adicionando pacote %s', + 'console_question' => 'Instalar componente de console webman/console', + 'db_question' => 'Componente de banco de dados', + 'db_none' => 'Nenhum', + 'db_invalid' => 'Por favor, digite uma opção válida', + 'redis_question' => 'Instalar componente Redis webman/redis', + 'events_note' => ' (Redis requer illuminate/events, incluído automaticamente)', + 'validation_question' => 'Instalar componente de validação webman/validation', + 'template_question' => 'Motor de templates', + 'template_none' => 'Nenhum', + 'no_components' => 'Nenhum componente opcional selecionado.', + 'installing' => 'Instalando:', + 'running' => 'Executando:', + 'error_install' => 'Falha na instalação. Tente manualmente: composer require %s', + 'done' => 'Componentes opcionais instalados.', + 'summary_locale' => 'Idioma: %s', + 'summary_timezone' => 'Fuso horário: %s', + ], + 'ru' => [ + 'skip' => 'Неинтерактивный режим, мастер установки пропущен.', + 'default_choice' => ' (по умолчанию %s)', + 'timezone_prompt' => 'Часовой пояс (по умолчанию=%s, введите для автодополнения): ', + 'timezone_title' => 'Часовой пояс (по умолчанию=%s)', + 'timezone_help' => 'Введите ключевое слово, Tab для автодополнения, ↑↓ для выбора:', + 'timezone_region' => 'Выберите регион часового пояса', + 'timezone_city' => 'Выберите часовой пояс', + 'timezone_invalid' => 'Неверный часовой пояс, используется значение по умолчанию %s', + 'timezone_input_prompt' => 'Введите часовой пояс или ключевое слово:', + 'timezone_pick_prompt' => 'Введите номер или ключевое слово:', + 'timezone_no_match' => 'Совпадающий часовой пояс не найден, попробуйте снова.', + 'timezone_invalid_index' => 'Неверный номер, попробуйте снова.', + 'yes' => 'да', + 'no' => 'нет', + 'adding_package' => '- Добавление пакета %s', + 'console_question' => 'Установить консольный компонент webman/console', + 'db_question' => 'Компонент базы данных', + 'db_none' => 'Не устанавливать', + 'db_invalid' => 'Пожалуйста, введите допустимый вариант', + 'redis_question' => 'Установить компонент Redis webman/redis', + 'events_note' => ' (Redis требует illuminate/events, автоматически включён)', + 'validation_question' => 'Установить компонент валидации webman/validation', + 'template_question' => 'Шаблонизатор', + 'template_none' => 'Не устанавливать', + 'no_components' => 'Дополнительные компоненты не выбраны.', + 'installing' => 'Установка:', + 'running' => 'Выполнение:', + 'error_install' => 'Ошибка установки. Выполните вручную: composer require %s', + 'done' => 'Дополнительные компоненты установлены.', + 'summary_locale' => 'Язык: %s', + 'summary_timezone' => 'Часовой пояс: %s', + ], + 'vi' => [ + 'skip' => 'Chế độ không tương tác, bỏ qua trình hướng dẫn cài đặt.', + 'default_choice' => ' (mặc định %s)', + 'timezone_prompt' => 'Múi giờ (mặc định=%s, nhập để tự động hoàn thành): ', + 'timezone_title' => 'Múi giờ (mặc định=%s)', + 'timezone_help' => 'Nhập từ khóa, Tab để tự hoàn thành, dùng ↑↓ để chọn:', + 'timezone_region' => 'Chọn khu vực múi giờ', + 'timezone_city' => 'Chọn múi giờ', + 'timezone_invalid' => 'Múi giờ không hợp lệ, sử dụng mặc định %s', + 'timezone_input_prompt' => 'Nhập múi giờ hoặc từ khóa:', + 'timezone_pick_prompt' => 'Nhập số thứ tự hoặc từ khóa:', + 'timezone_no_match' => 'Không tìm thấy múi giờ phù hợp, vui lòng thử lại.', + 'timezone_invalid_index' => 'Số không hợp lệ, vui lòng thử lại.', + 'yes' => 'có', + 'no' => 'không', + 'adding_package' => '- Thêm gói %s', + 'console_question' => 'Cài đặt thành phần console webman/console', + 'db_question' => 'Thành phần cơ sở dữ liệu', + 'db_none' => 'Không cài đặt', + 'db_invalid' => 'Vui lòng nhập tùy chọn hợp lệ', + 'redis_question' => 'Cài đặt thành phần Redis webman/redis', + 'events_note' => ' (Redis cần illuminate/events, đã tự động bao gồm)', + 'validation_question' => 'Cài đặt thành phần xác thực webman/validation', + 'template_question' => 'Công cụ mẫu', + 'template_none' => 'Không cài đặt', + 'no_components' => 'Không có thành phần tùy chọn nào được chọn.', + 'installing' => 'Đang cài đặt:', + 'running' => 'Đang thực thi:', + 'error_install' => 'Cài đặt thất bại. Thử thủ công: composer require %s', + 'done' => 'Các thành phần tùy chọn đã được cài đặt.', + 'summary_locale' => 'Ngôn ngữ: %s', + 'summary_timezone' => 'Múi giờ: %s', + ], + 'tr' => [ + 'skip' => 'Etkileşimsiz mod, kurulum sihirbazı atlanıyor.', + 'default_choice' => ' (varsayılan %s)', + 'timezone_prompt' => 'Saat dilimi (varsayılan=%s, otomatik tamamlama için yazın): ', + 'timezone_title' => 'Saat dilimi (varsayılan=%s)', + 'timezone_help' => 'Anahtar kelime yazın, Tab tamamlar, ↑↓ ile seçin:', + 'timezone_region' => 'Saat dilimi bölgesini seçin', + 'timezone_city' => 'Saat dilimini seçin', + 'timezone_invalid' => 'Geçersiz saat dilimi, varsayılan %s kullanılıyor', + 'timezone_input_prompt' => 'Saat dilimi veya anahtar kelime girin:', + 'timezone_pick_prompt' => 'Numara veya anahtar kelime girin:', + 'timezone_no_match' => 'Eşleşen saat dilimi bulunamadı, tekrar deneyin.', + 'timezone_invalid_index' => 'Geçersiz numara, tekrar deneyin.', + 'yes' => 'evet', + 'no' => 'hayır', + 'adding_package' => '- Paket ekleniyor %s', + 'console_question' => 'Konsol bileşeni webman/console yüklensin mi', + 'db_question' => 'Veritabanı bileşeni', + 'db_none' => 'Yok', + 'db_invalid' => 'Lütfen geçerli bir seçenek girin', + 'redis_question' => 'Redis bileşeni webman/redis yüklensin mi', + 'events_note' => ' (Redis, illuminate/events gerektirir, otomatik olarak dahil edildi)', + 'validation_question' => 'Doğrulama bileşeni webman/validation yüklensin mi', + 'template_question' => 'Şablon motoru', + 'template_none' => 'Yok', + 'no_components' => 'İsteğe bağlı bileşen seçilmedi.', + 'installing' => 'Yükleniyor:', + 'running' => 'Çalıştırılıyor:', + 'error_install' => 'Yükleme başarısız. Manuel olarak deneyin: composer require %s', + 'done' => 'İsteğe bağlı bileşenler yüklendi.', + 'summary_locale' => 'Dil: %s', + 'summary_timezone' => 'Saat dilimi: %s', + ], + 'id' => [ + 'skip' => 'Mode non-interaktif, melewati wizard instalasi.', + 'default_choice' => ' (default %s)', + 'timezone_prompt' => 'Zona waktu (default=%s, ketik untuk melengkapi): ', + 'timezone_title' => 'Zona waktu (default=%s)', + 'timezone_help' => 'Ketik kata kunci, Tab untuk melengkapi, gunakan ↑↓ untuk memilih:', + 'timezone_region' => 'Pilih wilayah zona waktu', + 'timezone_city' => 'Pilih zona waktu', + 'timezone_invalid' => 'Zona waktu tidak valid, menggunakan default %s', + 'timezone_input_prompt' => 'Masukkan zona waktu atau kata kunci:', + 'timezone_pick_prompt' => 'Masukkan nomor atau kata kunci:', + 'timezone_no_match' => 'Zona waktu tidak ditemukan, silakan coba lagi.', + 'timezone_invalid_index' => 'Nomor tidak valid, silakan coba lagi.', + 'yes' => 'ya', + 'no' => 'tidak', + 'adding_package' => '- Menambahkan paket %s', + 'console_question' => 'Instal komponen konsol webman/console', + 'db_question' => 'Komponen database', + 'db_none' => 'Tidak ada', + 'db_invalid' => 'Silakan masukkan opsi yang valid', + 'redis_question' => 'Instal komponen Redis webman/redis', + 'events_note' => ' (Redis memerlukan illuminate/events, otomatis disertakan)', + 'validation_question' => 'Instal komponen validasi webman/validation', + 'template_question' => 'Mesin template', + 'template_none' => 'Tidak ada', + 'no_components' => 'Tidak ada komponen opsional yang dipilih.', + 'installing' => 'Menginstal:', + 'running' => 'Menjalankan:', + 'error_install' => 'Instalasi gagal. Coba manual: composer require %s', + 'done' => 'Komponen opsional terinstal.', + 'summary_locale' => 'Bahasa: %s', + 'summary_timezone' => 'Zona waktu: %s', + ], + 'th' => [ + 'skip' => 'โหมดไม่โต้ตอบ ข้ามตัวช่วยติดตั้ง', + 'default_choice' => ' (ค่าเริ่มต้น %s)', + 'timezone_prompt' => 'เขตเวลา (ค่าเริ่มต้น=%s พิมพ์เพื่อเติมอัตโนมัติ): ', + 'timezone_title' => 'เขตเวลา (ค่าเริ่มต้น=%s)', + 'timezone_help' => 'พิมพ์คีย์เวิร์ดแล้วกด Tab เพื่อเติมอัตโนมัติ ใช้ ↑↓ เพื่อเลือก:', + 'timezone_region' => 'เลือกภูมิภาคเขตเวลา', + 'timezone_city' => 'เลือกเขตเวลา', + 'timezone_invalid' => 'เขตเวลาไม่ถูกต้อง ใช้ค่าเริ่มต้น %s', + 'timezone_input_prompt' => 'ป้อนเขตเวลาหรือคำค้น:', + 'timezone_pick_prompt' => 'ป้อนหมายเลขหรือคำค้น:', + 'timezone_no_match' => 'ไม่พบเขตเวลาที่ตรงกัน กรุณาลองอีกครั้ง', + 'timezone_invalid_index' => 'หมายเลขไม่ถูกต้อง กรุณาลองอีกครั้ง', + 'yes' => 'ใช่', + 'no' => 'ไม่', + 'adding_package' => '- เพิ่มแพ็กเกจ %s', + 'console_question' => 'ติดตั้งคอมโพเนนต์คอนโซล webman/console', + 'db_question' => 'คอมโพเนนต์ฐานข้อมูล', + 'db_none' => 'ไม่ติดตั้ง', + 'db_invalid' => 'กรุณาป้อนตัวเลือกที่ถูกต้อง', + 'redis_question' => 'ติดตั้งคอมโพเนนต์ Redis webman/redis', + 'events_note' => ' (Redis ต้องการ illuminate/events รวมไว้โดยอัตโนมัติ)', + 'validation_question' => 'ติดตั้งคอมโพเนนต์ตรวจสอบ webman/validation', + 'template_question' => 'เทมเพลตเอนจิน', + 'template_none' => 'ไม่ติดตั้ง', + 'no_components' => 'ไม่ได้เลือกคอมโพเนนต์เสริม', + 'installing' => 'กำลังติดตั้ง:', + 'running' => 'กำลังดำเนินการ:', + 'error_install' => 'ติดตั้งล้มเหลว ลองด้วยตนเอง: composer require %s', + 'done' => 'คอมโพเนนต์เสริมติดตั้งเรียบร้อยแล้ว', + 'summary_locale' => 'ภาษา: %s', + 'summary_timezone' => 'เขตเวลา: %s', + ], + ]; + + // --- Interrupt message (Ctrl+C) --- + + private const INTERRUPTED_MESSAGES = [ + 'zh_CN' => '安装中断,可运行 composer setup-webman 可重新设置。', + 'zh_TW' => '安裝中斷,可運行 composer setup-webman 重新設置。', + 'en' => 'Setup interrupted. Run "composer setup-webman" to restart setup.', + 'ja' => 'セットアップが中断されました。composer setup-webman を実行して再設定できます。', + 'ko' => '설치가 중단되었습니다. composer setup-webman 을 실행하여 다시 설정할 수 있습니다.', + 'fr' => 'Installation interrompue. Exécutez « composer setup-webman » pour recommencer.', + 'de' => 'Einrichtung abgebrochen. Führen Sie "composer setup-webman" aus, um neu zu starten.', + 'es' => 'Instalación interrumpida. Ejecute "composer setup-webman" para reiniciar.', + 'pt_BR' => 'Instalação interrompida. Execute "composer setup-webman" para reiniciar.', + 'ru' => 'Установка прервана. Выполните «composer setup-webman» для повторной настройки.', + 'vi' => 'Cài đặt bị gián đoạn. Chạy "composer setup-webman" để cài đặt lại.', + 'tr' => 'Kurulum kesildi. Yeniden kurmak için "composer setup-webman" komutunu çalıştırın.', + 'id' => 'Instalasi terganggu. Jalankan "composer setup-webman" untuk mengatur ulang.', + 'th' => 'การติดตั้งถูกขัดจังหวะ เรียกใช้ "composer setup-webman" เพื่อตั้งค่าใหม่', + ]; + + // --- Signal handling state --- + + /** @var string|null Saved stty mode for terminal restoration on interrupt */ + private static ?string $sttyMode = null; + + /** @var string Current locale for interrupt message */ + private static string $interruptLocale = 'en'; + + // ═══════════════════════════════════════════════════════════════ + // Entry + // ═══════════════════════════════════════════════════════════════ + + public static function run(Event $event): void + { + $io = $event->getIO(); + + // Non-interactive mode: use English for skip message + if (!$io->isInteractive()) { + $io->write('' . self::MESSAGES['en']['skip'] . ''); + return; + } + + try { + self::doRun($event, $io); + } catch (\Throwable $e) { + $io->writeError(''); + $io->writeError('Setup wizard error: ' . $e->getMessage() . ''); + $io->writeError('Run "composer setup-webman" to retry.'); + } + } + + private static function doRun(Event $event, IOInterface $io): void + { + $io->write(''); + + // Register Ctrl+C handler + self::registerInterruptHandler(); + + // Banner title (must be before locale selection) + self::renderTitle(); + + // 1. Locale selection + $locale = self::askLocale($io); + self::$interruptLocale = $locale; + $defaultTimezone = self::LOCALE_DEFAULT_TIMEZONES[$locale] ?? 'UTC'; + $msg = fn(string $key, string ...$args): string => + empty($args) ? self::MESSAGES[$locale][$key] : sprintf(self::MESSAGES[$locale][$key], ...$args); + + // Write locale config (update when not default) + if ($locale !== 'zh_CN') { + self::updateConfig($event, 'config/translation.php', "'locale'", $locale); + } + + $io->write(''); + $io->write(''); + + // 2. Timezone selection (default by locale) + $timezone = self::askTimezone($io, $msg, $defaultTimezone); + if ($timezone !== 'Asia/Shanghai') { + self::updateConfig($event, 'config/app.php', "'default_timezone'", $timezone); + } + + // 3. Optional components + $packages = self::askComponents($io, $msg); + + // 4. Remove unselected components + $removePackages = self::askRemoveComponents($event, $packages, $io, $msg); + + // 5. Summary + $io->write(''); + $io->write('─────────────────────────────────────'); + $io->write('' . $msg('summary_locale', self::LOCALE_LABELS[$locale]) . ''); + $io->write('' . $msg('summary_timezone', $timezone) . ''); + + // Remove unselected packages first to avoid dependency conflicts + if ($removePackages !== []) { + $io->write(''); + $io->write('' . $msg('removing') . ''); + + $secondaryPackages = [ + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + ]; + $displayRemovePackages = array_diff($removePackages, $secondaryPackages); + foreach ($displayRemovePackages as $pkg) { + $io->write(' - ' . $pkg); + } + $io->write(''); + self::runComposerRemove($removePackages, $io, $msg); + } + + // Then install selected packages + if ($packages !== []) { + $io->write(''); + $io->write('' . $msg('installing') . ' ' . implode(', ', $packages)); + $io->write(''); + self::runComposerRequire($packages, $io, $msg); + } elseif ($removePackages === []) { + $io->write('' . $msg('no_components') . ''); + } + } + + private static function renderTitle(): void + { + $output = new ConsoleOutput(); + $terminalWidth = (new Terminal())->getWidth(); + if ($terminalWidth <= 0) { + $terminalWidth = 80; + } + + $text = ' ' . self::SETUP_TITLE . ' '; + $minBoxWidth = 44; + $maxBoxWidth = min($terminalWidth, 96); + $boxWidth = min($maxBoxWidth, max($minBoxWidth, mb_strwidth($text) + 10)); + + $innerWidth = $boxWidth - 2; + $textWidth = mb_strwidth($text); + $pad = max(0, $innerWidth - $textWidth); + $left = intdiv($pad, 2); + $right = $pad - $left; + $line2 = '│' . str_repeat(' ', $left) . $text . str_repeat(' ', $right) . '│'; + $line1 = '┌' . str_repeat('─', $innerWidth) . '┐'; + $line3 = '└' . str_repeat('─', $innerWidth) . '┘'; + + $output->writeln(''); + $output->writeln('' . $line1 . ''); + $output->writeln('' . $line2 . ''); + $output->writeln('' . $line3 . ''); + $output->writeln(''); + } + + // ═══════════════════════════════════════════════════════════════ + // Signal handling (Ctrl+C) + // ═══════════════════════════════════════════════════════════════ + + /** + * Register Ctrl+C (SIGINT) handler to show a friendly message on interrupt. + * Gracefully skipped when the required extensions are unavailable. + */ + private static function registerInterruptHandler(): void + { + // Unix/Linux/Mac: pcntl extension with async signals for immediate delivery + /*if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + pcntl_async_signals(true); + pcntl_signal(\SIGINT, [self::class, 'handleInterrupt']); + return; + }*/ + + // Windows: sapi ctrl handler (PHP >= 7.4) + if (function_exists('sapi_windows_set_ctrl_handler')) { + sapi_windows_set_ctrl_handler(static function (int $event) { + if ($event === \PHP_WINDOWS_EVENT_CTRL_C) { + self::handleInterrupt(); + } + }); + } + } + + /** + * Handle Ctrl+C: restore terminal, show tip, then exit. + */ + private static function handleInterrupt(): void + { + // Restore terminal if in raw mode + if (self::$sttyMode !== null && function_exists('shell_exec')) { + @shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + + $output = new ConsoleOutput(); + $output->writeln(''); + $output->writeln('' . (self::INTERRUPTED_MESSAGES[self::$interruptLocale] ?? self::INTERRUPTED_MESSAGES['en']) . ''); + exit(1); + } + + // ═══════════════════════════════════════════════════════════════ + // Interactive Menu System + // ═══════════════════════════════════════════════════════════════ + + /** + * Check if terminal supports interactive features (arrow keys, ANSI colors). + */ + private static function supportsInteractive(): bool + { + return function_exists('shell_exec') && Terminal::hasSttyAvailable(); + } + + /** + * Display a selection menu with arrow key navigation (if supported) or text input fallback. + * + * @param IOInterface $io Composer IO + * @param string $title Menu title + * @param array $items Indexed array of ['tag' => string, 'label' => string] + * @param int $default Default selected index (0-based) + * @return int Selected index + */ + private static function selectMenu(IOInterface $io, string $title, array $items, int $default = 0): int + { + // Append localized "default" hint to avoid ambiguity + // (Template should contain a single %s placeholder for the default tag.) + $defaultHintTemplate = null; + if (isset(self::MESSAGES[self::$interruptLocale]['default_choice'])) { + $defaultHintTemplate = self::MESSAGES[self::$interruptLocale]['default_choice']; + } + + $defaultTag = $items[$default]['tag'] ?? ''; + if ($defaultHintTemplate && $defaultTag !== '') { + $title .= sprintf($defaultHintTemplate, $defaultTag); + } elseif ($defaultTag !== '') { + // Fallback for early menus (e.g. locale selection) before locale is chosen. + $title .= sprintf(' (default %s)', $defaultTag); + } + + if (self::supportsInteractive()) { + return self::arrowKeySelect($title, $items, $default); + } + + return self::fallbackSelect($io, $title, $items, $default); + } + + /** + * Display a yes/no confirmation as a selection menu. + * + * @param IOInterface $io Composer IO + * @param string $title Menu title + * @param bool $default Default value (true = yes) + * @return bool User's choice + */ + private static function confirmMenu(IOInterface $io, string $title, bool $default = true): bool + { + $locale = self::$interruptLocale; + $yes = self::MESSAGES[$locale]['yes'] ?? self::MESSAGES['en']['yes'] ?? 'yes'; + $no = self::MESSAGES[$locale]['no'] ?? self::MESSAGES['en']['no'] ?? 'no'; + $items = $default + ? [['tag' => 'Y', 'label' => $yes], ['tag' => 'n', 'label' => $no]] + : [['tag' => 'y', 'label' => $yes], ['tag' => 'N', 'label' => $no]]; + $defaultIndex = $default ? 0 : 1; + + return self::selectMenu($io, $title, $items, $defaultIndex) === 0; + } + + /** + * Interactive select with arrow key navigation, manual input and ANSI reverse-video highlighting. + * Input area and option list highlighting are bidirectionally linked. + * Requires stty (Unix-like terminals). + */ + private static function arrowKeySelect(string $title, array $items, int $default): int + { + $output = new ConsoleOutput(); + $count = count($items); + $selected = $default; + + $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); + $defaultTag = $items[$default]['tag']; + $input = $defaultTag; + + // Print title and initial options + $output->writeln(''); + $output->writeln('' . $title . ''); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write('> ' . $input); + + // Enter raw mode + self::$sttyMode = shell_exec('stty -g'); + shell_exec('stty -icanon -echo'); + + try { + while (!feof(STDIN)) { + $c = fread(STDIN, 1); + + if (false === $c || '' === $c) { + break; + } + + // ── Backspace ── + if ("\177" === $c || "\010" === $c) { + if ('' !== $input) { + $input = mb_substr($input, 0, -1); + } + $selected = self::findItemByTag($items, $input); + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + continue; + } + + // ── Escape sequences (arrow keys) ── + if ("\033" === $c) { + $seq = fread(STDIN, 2); + if (isset($seq[1])) { + $changed = false; + if ('A' === $seq[1]) { // Up + $selected = ($selected <= 0 ? $count : $selected) - 1; + $changed = true; + } elseif ('B' === $seq[1]) { // Down + $selected = ($selected + 1) % $count; + $changed = true; + } + if ($changed) { + // Sync input with selected item's tag + $input = $items[$selected]['tag']; + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + } + } + continue; + } + + // ── Enter: confirm selection ── + if ("\n" === $c || "\r" === $c) { + if ($selected < 0) { + $selected = $default; + } + $output->write("\033[2K\r> " . $items[$selected]['tag'] . ' ' . $items[$selected]['label'] . ''); + $output->writeln(''); + break; + } + + // ── Ignore other control characters ── + if (ord($c) < 32) { + continue; + } + + // ── Printable character (with UTF-8 multi-byte support) ── + if ("\x80" <= $c) { + $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; + $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); + } + $input .= $c; + $selected = self::findItemByTag($items, $input); + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + } + } finally { + if (self::$sttyMode !== null) { + shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + } + + return $selected < 0 ? $default : $selected; + } + + /** + * Fallback select for terminals without stty support. Uses plain text input. + */ + private static function fallbackSelect(IOInterface $io, string $title, array $items, int $default): int + { + $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); + $defaultTag = $items[$default]['tag']; + + $io->write(''); + $io->write('' . $title . ''); + foreach ($items as $item) { + $tag = str_pad($item['tag'], $maxTagWidth); + $io->write(" [$tag] " . $item['label']); + } + + while (true) { + $io->write('> ', false); + $line = fgets(STDIN); + if ($line === false) { + return $default; + } + $answer = trim($line); + + if ($answer === '') { + $io->write('> ' . $items[$default]['tag'] . ' ' . $items[$default]['label'] . ''); + return $default; + } + + // Match by tag (case-insensitive) + foreach ($items as $i => $item) { + if (strcasecmp($item['tag'], $answer) === 0) { + $io->write('> ' . $items[$i]['tag'] . ' ' . $items[$i]['label'] . ''); + return $i; + } + } + } + } + + /** + * Render menu items with optional ANSI reverse-video highlighting for the selected item. + * When $selected is -1, no item is highlighted. + */ + private static function drawMenuItems(ConsoleOutput $output, array $items, int $selected, int $maxTagWidth): void + { + foreach ($items as $i => $item) { + $tag = str_pad($item['tag'], $maxTagWidth); + $line = " [$tag] " . $item['label']; + if ($i === $selected) { + $output->writeln("\033[2K\r\033[7m" . $line . "\033[0m"); + } else { + $output->writeln("\033[2K\r" . $line); + } + } + } + + /** + * Find item index by tag (case-insensitive exact match). + * Returns -1 if no match found or input is empty. + */ + private static function findItemByTag(array $items, string $input): int + { + if ($input === '') { + return -1; + } + foreach ($items as $i => $item) { + if (strcasecmp($item['tag'], $input) === 0) { + return $i; + } + } + return -1; + } + + // ═══════════════════════════════════════════════════════════════ + // Locale selection + // ═══════════════════════════════════════════════════════════════ + + private static function askLocale(IOInterface $io): string + { + $locales = array_keys(self::LOCALE_LABELS); + $items = []; + foreach ($locales as $i => $code) { + $items[] = ['tag' => (string) $i, 'label' => self::LOCALE_LABELS[$code] . " ($code)"]; + } + + $selected = self::selectMenu( + $io, + '语言 / Language / 言語 / 언어', + $items, + 0 + ); + + return $locales[$selected]; + } + + // ═══════════════════════════════════════════════════════════════ + // Timezone selection + // ═══════════════════════════════════════════════════════════════ + + private static function askTimezone(IOInterface $io, callable $msg, string $default): string + { + if (self::supportsInteractive()) { + return self::askTimezoneAutocomplete($msg, $default); + } + + return self::askTimezoneSelect($io, $msg, $default); + } + + /** + * Option A: when stty is available, custom character-by-character autocomplete + * (case-insensitive, substring match). Interaction: type to filter, hint on right; + * ↑↓ change candidate, Tab accept, Enter confirm; empty input = use default. + */ + private static function askTimezoneAutocomplete(callable $msg, string $default): string + { + $allTimezones = \DateTimeZone::listIdentifiers(); + $output = new ConsoleOutput(); + $cursor = new Cursor($output); + + $output->writeln(''); + $output->writeln('' . $msg('timezone_title', $default) . ''); + $output->writeln($msg('timezone_help')); + $output->write('> '); + + self::$sttyMode = shell_exec('stty -g'); + shell_exec('stty -icanon -echo'); + + // Auto-fill default timezone in the input area; user can edit it directly. + $input = $default; + $output->write($input); + + $ofs = 0; + $matches = self::filterTimezones($allTimezones, $input); + if (!empty($matches)) { + $hint = $matches[$ofs % count($matches)]; + // Avoid duplicating hint when input already fully matches the only candidate. + if (!(count($matches) === 1 && $hint === $input)) { + $cursor->clearLineAfter(); + $cursor->savePosition(); + $output->write(' ' . $hint . ''); + if (count($matches) > 1) { + $output->write(' (' . count($matches) . ' matches, ↑↓)'); + } + $cursor->restorePosition(); + } + } + + try { + while (!feof(STDIN)) { + $c = fread(STDIN, 1); + + if (false === $c || '' === $c) { + break; + } + + // ── Backspace ── + if ("\177" === $c || "\010" === $c) { + if ('' !== $input) { + $lastChar = mb_substr($input, -1); + $input = mb_substr($input, 0, -1); + $cursor->moveLeft(max(1, mb_strwidth($lastChar))); + } + $ofs = 0; + + // ── Escape sequences (arrows) ── + } elseif ("\033" === $c) { + $seq = fread(STDIN, 2); + if (isset($seq[1]) && !empty($matches)) { + if ('A' === $seq[1]) { + $ofs = ($ofs - 1 + count($matches)) % count($matches); + } elseif ('B' === $seq[1]) { + $ofs = ($ofs + 1) % count($matches); + } + } + + // ── Tab: accept current match ── + } elseif ("\t" === $c) { + if (isset($matches[$ofs])) { + self::replaceInput($output, $cursor, $input, $matches[$ofs]); + $input = $matches[$ofs]; + $matches = []; + } + $cursor->clearLineAfter(); + continue; + + // ── Enter: confirm ── + } elseif ("\n" === $c || "\r" === $c) { + if (isset($matches[$ofs])) { + self::replaceInput($output, $cursor, $input, $matches[$ofs]); + $input = $matches[$ofs]; + } + if ($input === '') { + $input = $default; + } + // Re-render user input with style + $cursor->moveToColumn(1); + $cursor->clearLine(); + $output->write('> ' . $input . ''); + $output->writeln(''); + break; + + // ── Other control chars: ignore ── + } elseif (ord($c) < 32) { + continue; + + // ── Printable character ── + } else { + if ("\x80" <= $c) { + $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; + $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); + } + $output->write($c); + $input .= $c; + $ofs = 0; + } + + // Update match list + $matches = self::filterTimezones($allTimezones, $input); + + // Show autocomplete hint + $cursor->clearLineAfter(); + if (!empty($matches)) { + $hint = $matches[$ofs % count($matches)]; + $cursor->savePosition(); + $output->write(' ' . $hint . ''); + if (count($matches) > 1) { + $output->write(' (' . count($matches) . ' matches, ↑↓)'); + } + $cursor->restorePosition(); + } + } + } finally { + if (self::$sttyMode !== null) { + shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + } + + $result = '' === $input ? $default : $input; + + if (!in_array($result, $allTimezones, true)) { + $output->writeln('' . $msg('timezone_invalid', $default) . ''); + return $default; + } + + return $result; + } + + /** + * Clear current input and replace with new text. + */ + private static function replaceInput(ConsoleOutput $output, Cursor $cursor, string $oldInput, string $newInput): void + { + if ('' !== $oldInput) { + $cursor->moveLeft(mb_strwidth($oldInput)); + } + $cursor->clearLineAfter(); + $output->write($newInput); + } + + /** + * Case-insensitive substring match for timezones. + */ + private static function filterTimezones(array $timezones, string $input): array + { + if ('' === $input) { + return []; + } + $lower = mb_strtolower($input); + return array_values(array_filter( + $timezones, + fn(string $tz) => str_contains(mb_strtolower($tz), $lower) + )); + } + + /** + * Find an exact timezone match (case-insensitive). + * Returns the correctly-cased system timezone name, or null if not found. + */ + private static function findExactTimezone(array $allTimezones, string $input): ?string + { + $lower = mb_strtolower($input); + foreach ($allTimezones as $tz) { + if (mb_strtolower($tz) === $lower) { + return $tz; + } + } + return null; + } + + /** + * Search timezones by keyword (substring) and similarity. + * Returns combined results: substring matches first, then similarity matches (>=50%). + * + * @param string[] $allTimezones All valid timezone identifiers + * @param string $keyword User input to search for + * @param int $limit Maximum number of results + * @return string[] Matched timezone identifiers + */ + private static function searchTimezones(array $allTimezones, string $keyword, int $limit = 15): array + { + // 1. Substring matches (higher priority) + $substringMatches = self::filterTimezones($allTimezones, $keyword); + if (count($substringMatches) >= $limit) { + return array_slice($substringMatches, 0, $limit); + } + + // 2. Similarity matches for remaining slots (normalized: strip _ and /) + $substringSet = array_flip($substringMatches); + $normalizedKeyword = str_replace(['_', '/'], ' ', mb_strtolower($keyword)); + $similarityMatches = []; + + foreach ($allTimezones as $tz) { + if (isset($substringSet[$tz])) { + continue; + } + $parts = explode('/', $tz); + $city = str_replace('_', ' ', mb_strtolower(end($parts))); + $normalizedTz = str_replace(['_', '/'], ' ', mb_strtolower($tz)); + + similar_text($normalizedKeyword, $city, $cityPercent); + similar_text($normalizedKeyword, $normalizedTz, $fullPercent); + + $bestPercent = max($cityPercent, $fullPercent); + if ($bestPercent >= 50.0) { + $similarityMatches[] = ['tz' => $tz, 'score' => $bestPercent]; + } + } + + usort($similarityMatches, fn(array $a, array $b) => $b['score'] <=> $a['score']); + + $results = $substringMatches; + foreach ($similarityMatches as $item) { + $results[] = $item['tz']; + if (count($results) >= $limit) { + break; + } + } + + return $results; + } + + /** + * Option B: when stty is not available (e.g. Windows), keyword search with numbered list. + * Flow: enter timezone/keyword → exact match uses it directly; otherwise show + * numbered results (substring + similarity) → pick by number or refine keyword. + */ + private static function askTimezoneSelect(IOInterface $io, callable $msg, string $default): string + { + $allTimezones = \DateTimeZone::listIdentifiers(); + + $io->write(''); + $io->write('' . $msg('timezone_title', $default) . ''); + $io->write($msg('timezone_input_prompt')); + + /** @var string[]|null Currently displayed search result list */ + $currentList = null; + + while (true) { + $io->write('> ', false); + $line = fgets(STDIN); + if ($line === false) { + return $default; + } + $answer = trim($line); + + // Empty input → use default + if ($answer === '') { + $io->write('> ' . $default . ''); + return $default; + } + + // If a numbered list is displayed and input is a pure number + if ($currentList !== null && ctype_digit($answer)) { + $idx = (int) $answer; + if (isset($currentList[$idx])) { + $io->write('> ' . $currentList[$idx] . ''); + return $currentList[$idx]; + } + $io->write('' . $msg('timezone_invalid_index') . ''); + continue; + } + + // Exact case-insensitive match → return the correctly-cased system value + $exact = self::findExactTimezone($allTimezones, $answer); + if ($exact !== null) { + $io->write('> ' . $exact . ''); + return $exact; + } + + // Keyword + similarity search + $results = self::searchTimezones($allTimezones, $answer); + + if (empty($results)) { + $io->write('' . $msg('timezone_no_match') . ''); + $currentList = null; + continue; + } + + // Single result → use it directly + if (count($results) === 1) { + $io->write('> ' . $results[0] . ''); + return $results[0]; + } + + // Display numbered list + $currentList = $results; + $padWidth = strlen((string) (count($results) - 1)); + foreach ($results as $i => $tz) { + $io->write(' [' . str_pad((string) $i, $padWidth) . '] ' . $tz); + } + $io->write($msg('timezone_pick_prompt')); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Optional component selection + // ═══════════════════════════════════════════════════════════════ + + private static function askComponents(IOInterface $io, callable $msg): array + { + $packages = []; + $addPackage = static function (string $package) use (&$packages, $io, $msg): void { + if (in_array($package, $packages, true)) { + return; + } + $packages[] = $package; + $io->write($msg('adding_package', '' . $package . '')); + }; + + // Console (default: yes) + if (self::confirmMenu($io, $msg('console_question'), true)) { + $addPackage(self::PACKAGE_CONSOLE); + } + + // Database + $dbItems = [ + ['tag' => '0', 'label' => $msg('db_none')], + ['tag' => '1', 'label' => 'webman/database'], + ['tag' => '2', 'label' => 'webman/think-orm'], + ['tag' => '3', 'label' => 'webman/database && webman/think-orm'], + ]; + $dbChoice = self::selectMenu($io, $msg('db_question'), $dbItems, 0); + if ($dbChoice === 1) { + $addPackage(self::PACKAGE_DATABASE); + } elseif ($dbChoice === 2) { + $addPackage(self::PACKAGE_THINK_ORM); + } elseif ($dbChoice === 3) { + $addPackage(self::PACKAGE_DATABASE); + $addPackage(self::PACKAGE_THINK_ORM); + } + + // If webman/database is selected, add required dependencies automatically + if (in_array(self::PACKAGE_DATABASE, $packages, true)) { + $addPackage(self::PACKAGE_ILLUMINATE_PAGINATION); + $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); + $addPackage(self::PACKAGE_SYMFONY_VAR_DUMPER); + } + + // Redis (default: no) + if (self::confirmMenu($io, $msg('redis_question'), false)) { + $addPackage(self::PACKAGE_REDIS); + $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); + } + + // Validation (default: no) + if (self::confirmMenu($io, $msg('validation_question'), false)) { + $addPackage(self::PACKAGE_VALIDATION); + } + + // Template engine + $tplItems = [ + ['tag' => '0', 'label' => $msg('template_none')], + ['tag' => '1', 'label' => 'webman/blade'], + ['tag' => '2', 'label' => 'twig/twig'], + ['tag' => '3', 'label' => 'topthink/think-template'], + ]; + $tplChoice = self::selectMenu($io, $msg('template_question'), $tplItems, 0); + if ($tplChoice === 1) { + $addPackage(self::PACKAGE_BLADE); + } elseif ($tplChoice === 2) { + $addPackage(self::PACKAGE_TWIG); + } elseif ($tplChoice === 3) { + $addPackage(self::PACKAGE_THINK_TEMPLATE); + } + + return $packages; + } + + // ═══════════════════════════════════════════════════════════════ + // Config file update + // ═══════════════════════════════════════════════════════════════ + + /** + * Update a config value like 'key' => 'old_value' in the given file. + */ + private static function updateConfig(Event $event, string $relativePath, string $key, string $newValue): void + { + $root = dirname($event->getComposer()->getConfig()->get('vendor-dir')); + $file = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + if (!is_readable($file)) { + return; + } + $content = file_get_contents($file); + if ($content === false) { + return; + } + $pattern = '/' . preg_quote($key, '/') . "\s*=>\s*'[^']*'/"; + $replacement = $key . " => '" . $newValue . "'"; + $newContent = preg_replace($pattern, $replacement, $content); + if ($newContent !== null && $newContent !== $content) { + file_put_contents($file, $newContent); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Composer require + // ═══════════════════════════════════════════════════════════════ + + private static function runComposerRequire(array $packages, IOInterface $io, callable $msg): void + { + $io->write('' . $msg('running') . ' composer require ' . implode(' ', $packages)); + $io->write(''); + + $code = self::runComposerCommand('require', $packages); + + if ($code !== 0) { + $io->writeError('' . $msg('error_install', implode(' ', $packages)) . ''); + } else { + $io->write('' . $msg('done') . ''); + } + } + + private static function askRemoveComponents(Event $event, array $selectedPackages, IOInterface $io, callable $msg): array + { + $requires = $event->getComposer()->getPackage()->getRequires(); + $allOptionalPackages = [ + self::PACKAGE_CONSOLE, + self::PACKAGE_DATABASE, + self::PACKAGE_THINK_ORM, + self::PACKAGE_REDIS, + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + self::PACKAGE_VALIDATION, + self::PACKAGE_BLADE, + self::PACKAGE_TWIG, + self::PACKAGE_THINK_TEMPLATE, + ]; + + $secondaryPackages = [ + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + ]; + + $installedOptionalPackages = []; + foreach ($allOptionalPackages as $pkg) { + if (isset($requires[$pkg])) { + $installedOptionalPackages[] = $pkg; + } + } + + $allPackagesToRemove = array_diff($installedOptionalPackages, $selectedPackages); + + if (count($allPackagesToRemove) === 0) { + return []; + } + + $displayPackagesToRemove = array_diff($allPackagesToRemove, $secondaryPackages); + + if (count($displayPackagesToRemove) === 0) { + return $allPackagesToRemove; + } + + $pkgListStr = ""; + foreach ($displayPackagesToRemove as $pkg) { + $pkgListStr .= "\n - {$pkg}"; + } + $pkgListStr .= "\n"; + + $title = '' . $msg('remove_package_question', '') . '' . $pkgListStr; + if (self::confirmMenu($io, $title, false)) { + return $allPackagesToRemove; + } + + return []; + } + + private static function runComposerRemove(array $packages, IOInterface $io, callable $msg): void + { + $io->write('' . $msg('running') . ' composer remove ' . implode(' ', $packages)); + $io->write(''); + + $code = self::runComposerCommand('remove', $packages); + + if ($code !== 0) { + $io->writeError('' . $msg('error_remove', implode(' ', $packages)) . ''); + } else { + $io->write('' . $msg('done_remove') . ''); + } + } + + /** + * Run a Composer command (require/remove) in-process via Composer's Application API. + * No shell execution functions needed — works even when passthru/exec/shell_exec are disabled. + */ + private static function runComposerCommand(string $command, array $packages): int + { + try { + // Already inside a user-initiated Composer session — suppress duplicate root/superuser warnings + $_SERVER['COMPOSER_ALLOW_SUPERUSER'] = '1'; + if (function_exists('putenv')) { + putenv('COMPOSER_ALLOW_SUPERUSER=1'); + } + + $application = new ComposerApplication(); + $application->setAutoExit(false); + + return $application->run( + new ArrayInput([ + 'command' => $command, + 'packages' => $packages, + '--no-interaction' => true, + '--update-with-all-dependencies' => true, + ]), + new ConsoleOutput() + ); + } catch (\Throwable) { + return 1; + } + } +} diff --git a/support/bootstrap.php b/support/bootstrap.php new file mode 100644 index 0000000..9495666 --- /dev/null +++ b/support/bootstrap.php @@ -0,0 +1,3 @@ +addConnection($connection); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + } +} + diff --git a/support/helpers.php b/support/helpers.php new file mode 100644 index 0000000..2a25d3e --- /dev/null +++ b/support/helpers.php @@ -0,0 +1,25 @@ + $code, + 'msg' => $msg, + 'data' => $data + ]); + } +} + +if (!function_exists('generateToken')) { + function generateToken(): string + { + return bin2hex(random_bytes(32)); + } +} + +if (!function_exists('hashToken')) { + function hashToken(string $token): string + { + return hash('sha256', $token); + } +} diff --git a/windows.bat b/windows.bat new file mode 100644 index 0000000..f07ce53 --- /dev/null +++ b/windows.bat @@ -0,0 +1,3 @@ +CHCP 65001 +php windows.php +pause \ No newline at end of file diff --git a/windows.php b/windows.php new file mode 100644 index 0000000..f37a72c --- /dev/null +++ b/windows.php @@ -0,0 +1,136 @@ +load(); + } else { + Dotenv::createMutable(base_path())->load(); + } +} + +App::loadAllConfig(['route']); + +$errorReporting = config('app.error_reporting'); +if (isset($errorReporting)) { + error_reporting($errorReporting); +} + +$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows'; +$paths = [ + $runtimeProcessPath, + runtime_path('logs'), + runtime_path('views') +]; +foreach ($paths as $path) { + if (!is_dir($path)) { + mkdir($path, 0777, true); + } +} + +$processFiles = []; +if (config('server.listen')) { + $processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php'; +} +foreach (config('process', []) as $processName => $config) { + $processFiles[] = write_process_file($runtimeProcessPath, $processName, ''); +} + +foreach (config('plugin', []) as $firm => $projects) { + foreach ($projects as $name => $project) { + if (!is_array($project)) { + continue; + } + foreach ($project['process'] ?? [] as $processName => $config) { + $processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name"); + } + } + foreach ($projects['process'] ?? [] as $processName => $config) { + $processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm); + } +} + +function write_process_file($runtimeProcessPath, $processName, $firm): string +{ + $processParam = $firm ? "plugin.$firm.$processName" : $processName; + $configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']"; + $fileContent = << true]); + if (!$resource) { + exit("Can not execute $cmd\r\n"); + } + return $resource; +} + +$resource = popen_processes($processFiles); +echo "\r\n"; +while (1) { + sleep(1); + if (!empty($monitor) && $monitor->checkAllFilesChange()) { + $status = proc_get_status($resource); + $pid = $status['pid']; + shell_exec("taskkill /F /T /PID $pid"); + proc_close($resource); + $resource = popen_processes($processFiles); + } +}