chore: init saasshop repo + sql migrations runner + gitee go
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
65
.env.example
Normal file
65
.env.example
Normal file
@@ -0,0 +1,65 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
57
.gitee/go.yml
Normal file
57
.gitee/go.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
# Gitee Go 流水线配置(模板)
|
||||
# 目标:代码推送后自动执行“结构变更 SQL 脚本”与基础回归测试。
|
||||
#
|
||||
# 注意:该文件为模板,具体 DB/ENV 需要在 Gitee Go 的「变量/密钥」中配置。
|
||||
# 不要把任何密码/令牌写进仓库。
|
||||
|
||||
version: 1.0
|
||||
name: SaaSShop CI
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
jobs:
|
||||
test:
|
||||
stage: test
|
||||
image: php:8.2
|
||||
steps:
|
||||
- name: 安装系统依赖
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git unzip libzip-dev default-mysql-client libsqlite3-dev
|
||||
docker-php-ext-install pdo pdo_mysql pdo_sqlite
|
||||
|
||||
- name: 安装 Composer
|
||||
run: |
|
||||
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
|
||||
php composer-setup.php --install-dir=/usr/local/bin --filename=composer
|
||||
composer --version
|
||||
|
||||
- name: 安装 PHP 依赖
|
||||
run: |
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: 准备环境文件(CI 默认使用 sqlite,避免依赖外部 DB)
|
||||
run: |
|
||||
[ -f .env ] || cp .env.example .env
|
||||
# 覆盖为 sqlite(可按需在 Gitee Go 变量中替换为 mysql)
|
||||
sed -i 's/^DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
|
||||
sed -i 's%^DB_DATABASE=.*%DB_DATABASE='"$PWD"'/database/database.sqlite%' .env
|
||||
grep -q '^DB_DATABASE=' .env || echo "DB_DATABASE=$PWD/database/database.sqlite" >> .env
|
||||
mkdir -p database
|
||||
touch database/database.sqlite
|
||||
php artisan key:generate
|
||||
|
||||
- name: 运行 Laravel migrations
|
||||
run: |
|
||||
php artisan migrate --force
|
||||
php artisan migrate:status
|
||||
|
||||
- name: 运行 SQL 结构迁移脚本(V1__xxx.sql)
|
||||
run: |
|
||||
composer run db:sql-migrate
|
||||
|
||||
- name: 运行测试(先跑关键链路,再全量)
|
||||
run: |
|
||||
php artisan test --filter='AdminPlanTest|AdminSiteSubscriptionTest|AdminPlatformOrderTest'
|
||||
php artisan test
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
# local sqlite database (dev/test)
|
||||
/database/database.sqlite
|
||||
|
||||
# SQL schema migrations (V1__xxx.sql)
|
||||
# 需要提交到仓库,供新环境与 CI 执行,因此不要忽略。
|
||||
|
||||
# helper scripts (optional)
|
||||
/scripts/sql_migrate_check.php
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
53
README_LOCAL.md
Normal file
53
README_LOCAL.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 本地部署说明(当前环境)
|
||||
|
||||
## 访问地址
|
||||
- 应用首页:`http://192.168.10.199:9001/`
|
||||
- 总台管理:`http://192.168.10.199:9001/admin/login`
|
||||
- 站点后台:`http://192.168.10.199:9001/site-admin/login`
|
||||
- 商家后台:`http://192.168.10.199:9001/merchant-admin/login`
|
||||
- phpMyAdmin:`http://192.168.10.199:888/`
|
||||
|
||||
## 应用目录
|
||||
- Laravel 项目:`/var/www/sites/app`
|
||||
|
||||
## 技术栈
|
||||
- Nginx 1.22
|
||||
- PHP 8.2 FPM
|
||||
- MariaDB 10.11
|
||||
- Redis 7
|
||||
- Laravel 12
|
||||
|
||||
## 已配置内容
|
||||
- Laravel 已接入 MySQL 数据库 `appdb`
|
||||
- Laravel 已接入 Redis(缓存 / 队列)
|
||||
- `9001` 已指向 Laravel `public/`
|
||||
- `888` 已指向 phpMyAdmin
|
||||
- 当前项目已完成总台管理、站点后台、商家后台三层后台基础骨架
|
||||
- 当前数据库与代码基线已统一使用 `merchant / merchants / merchant_id` 语义
|
||||
|
||||
## 数据库连接
|
||||
- DB_HOST=`127.0.0.1`
|
||||
- DB_PORT=`3306`
|
||||
- DB_DATABASE=`appdb`
|
||||
- DB_USERNAME=`appuser`
|
||||
- 密码存放于:`/app/working.secret/appdb.env`
|
||||
|
||||
## phpMyAdmin
|
||||
- 使用 MySQL 账号登录(推荐直接用 `appuser`)
|
||||
- 地址:`http://192.168.10.199:888/`
|
||||
|
||||
## 商品导入失败明细文件
|
||||
- 商品批量导入失败明细 CSV 通过 Laravel `local` 磁盘保存
|
||||
- 当前 `local` 磁盘根目录为:`/var/www/sites/app/storage/app/private`
|
||||
- 总台管理失败文件目录:`/var/www/sites/app/storage/app/private/imports/product-failures/platform/`
|
||||
- 商家后台失败文件目录:`/var/www/sites/app/storage/app/private/imports/product-failures/merchant_<merchant_id>/`
|
||||
- 页面下载入口:
|
||||
- 总台:`/admin/products/import-failures/{file}`
|
||||
- 商家:`/merchant-admin/products/import-failures/{file}`
|
||||
- 文件内容包含:`row_number + 原始导入字段 + error`
|
||||
- 已补定时清理脚本:`/usr/local/bin/saasshop_import_failures_cleanup.sh`
|
||||
- 当前保留策略:默认保留 30 天,每天东八区 `04:30` 自动清理一次过期 failure CSV
|
||||
|
||||
## 注意
|
||||
- 敏感凭证不要写进公开文档或长期记忆
|
||||
- 当前项目已进入 SaaS 电商基础框架阶段,后续优先继续补经营能力、配置能力与筛选能力
|
||||
54
app/Http/Controllers/Admin/AuthController.php
Normal file
54
app/Http/Controllers/Admin/AuthController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('admin.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->where('email', $data['email'])->first();
|
||||
if (! $admin || ! Hash::check($data['password'], $admin->password)) {
|
||||
return back()->withErrors(['email' => '账号或密码错误'])->withInput();
|
||||
}
|
||||
|
||||
if (! $admin->isPlatformAdmin()) {
|
||||
return back()->withErrors(['email' => '当前账号是商家管理员,请从商家后台入口登录'])->withInput();
|
||||
}
|
||||
|
||||
$request->session()->put('admin_id', $admin->id);
|
||||
$request->session()->put('admin_name', $admin->name);
|
||||
$request->session()->put('admin_email', $admin->email);
|
||||
$request->session()->put('admin_role', $admin->role);
|
||||
$request->session()->put('admin_merchant_id', null);
|
||||
$request->session()->put('admin_scope', $admin->platformLabel());
|
||||
|
||||
$admin->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return redirect('/admin');
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->forget(['admin_id', 'admin_name', 'admin_email', 'admin_role', 'admin_merchant_id', 'admin_scope']);
|
||||
return redirect('/admin/login');
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Admin/DashboardController.php
Normal file
57
app/Http/Controllers/Admin/DashboardController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Models\Merchant;
|
||||
use App\Models\User;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$admin = $this->ensurePlatformAdmin($request);
|
||||
|
||||
$stats = Cache::remember(
|
||||
CacheKeys::platformDashboardStats(),
|
||||
now()->addMinutes(10),
|
||||
fn () => [
|
||||
'merchants' => Merchant::count(),
|
||||
'admins' => Admin::count(),
|
||||
'users' => User::count(),
|
||||
'products' => Product::count(),
|
||||
'orders' => Order::count(),
|
||||
'active_merchants' => Merchant::query()->where('status', 'active')->count(),
|
||||
'pending_orders' => Order::query()->where('status', 'pending')->count(),
|
||||
]
|
||||
);
|
||||
|
||||
return view('admin.dashboard', [
|
||||
'adminName' => $admin->name,
|
||||
'stats' => $stats,
|
||||
'platformAdmin' => $admin,
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
'platformOverview' => [
|
||||
'system_role' => '总台管理',
|
||||
'current_scope' => '总台运营方视角',
|
||||
'merchant_mode' => '统一管理多个站点',
|
||||
'channel_count' => 5,
|
||||
'active_merchants' => $stats['active_merchants'],
|
||||
'pending_orders' => $stats['pending_orders'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
app/Http/Controllers/Admin/MerchantController.php
Normal file
73
app/Http/Controllers/Admin/MerchantController.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Merchant;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MerchantController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
$page = max((int) $request->integer('page', 1), 1);
|
||||
|
||||
return view('admin.merchants.index', [
|
||||
'merchants' => Cache::remember(
|
||||
CacheKeys::platformMerchantsList($page),
|
||||
now()->addMinutes(10),
|
||||
fn () => Merchant::query()->latest()->paginate(10)->withQueryString()
|
||||
),
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string'],
|
||||
'slug' => ['required', 'string'],
|
||||
'plan' => ['nullable', 'string'],
|
||||
'status' => ['nullable', 'string'],
|
||||
'contact_name' => ['nullable', 'string'],
|
||||
'contact_phone' => ['nullable', 'string'],
|
||||
'contact_email' => ['nullable', 'email'],
|
||||
]);
|
||||
|
||||
Merchant::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'],
|
||||
'plan' => $data['plan'] ?? 'basic',
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'contact_name' => $data['contact_name'] ?? null,
|
||||
'contact_phone' => $data['contact_phone'] ?? null,
|
||||
'contact_email' => $data['contact_email'] ?? null,
|
||||
'activated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->flushPlatformCaches();
|
||||
|
||||
return redirect('/admin/merchants')->with('success', '商家创建成功');
|
||||
}
|
||||
|
||||
protected function flushPlatformCaches(): void
|
||||
{
|
||||
for ($page = 1; $page <= 5; $page++) {
|
||||
Cache::forget(CacheKeys::platformMerchantsList($page));
|
||||
}
|
||||
|
||||
Cache::forget(CacheKeys::platformDashboardStats());
|
||||
Cache::forget(CacheKeys::platformChannelsOverview());
|
||||
}
|
||||
}
|
||||
867
app/Http/Controllers/Admin/OrderController.php
Normal file
867
app/Http/Controllers/Admin/OrderController.php
Normal file
@@ -0,0 +1,867 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
protected array $statuses = ['pending', 'paid', 'shipped', 'completed', 'cancelled'];
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
$page = max((int) $request->integer('page', 1), 1);
|
||||
|
||||
$filters = $this->filters($request);
|
||||
$statusStatsFilters = $filters;
|
||||
$statusStatsFilters['status'] = '';
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return view('admin.orders.index', [
|
||||
'orders' => Order::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(),
|
||||
'statusStats' => $this->emptyStatusStats(),
|
||||
'summaryStats' => $this->emptySummaryStats(),
|
||||
'trendStats' => $this->emptyTrendStats(),
|
||||
'operationsFocus' => $this->buildOperationsFocus($this->emptySummaryStats(), $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'filters' => $filters,
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statuses,
|
||||
'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'],
|
||||
'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'],
|
||||
'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'],
|
||||
'paymentChannels' => ['wechat_pay', 'alipay'],
|
||||
'timeRanges' => [
|
||||
'all' => '全部时间',
|
||||
'today' => '今天',
|
||||
'last_7_days' => '近7天',
|
||||
],
|
||||
'sortOptions' => [
|
||||
'latest' => '创建时间倒序',
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
'product_amount_desc' => '商品金额从高到低',
|
||||
'product_amount_asc' => '商品金额从低到高',
|
||||
],
|
||||
],
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
'platformLabels' => $this->platformLabels(),
|
||||
'deviceTypeLabels' => $this->deviceTypeLabels(),
|
||||
'paymentChannelLabels' => $this->paymentChannelLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
$summaryStats = Cache::remember(
|
||||
CacheKeys::platformOrdersSummary($statusStatsFilters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->buildSummaryStats($this->applyFilters(Order::query(), $statusStatsFilters))
|
||||
);
|
||||
|
||||
return view('admin.orders.index', [
|
||||
'orders' => Cache::remember(
|
||||
CacheKeys::platformOrdersList($page, $filters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->applySorting($this->applyFilters(Order::query()->with('merchant'), $filters), $filters)
|
||||
->paginate(10)
|
||||
->withQueryString()
|
||||
),
|
||||
'statusStats' => Cache::remember(
|
||||
CacheKeys::platformOrdersStatusStats($statusStatsFilters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->buildStatusStats($this->applyFilters(Order::query(), $statusStatsFilters))
|
||||
),
|
||||
'summaryStats' => $summaryStats,
|
||||
'operationsFocus' => $this->buildOperationsFocus($summaryStats, $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'trendStats' => Cache::remember(
|
||||
CacheKeys::platformOrdersTrendSummary($statusStatsFilters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->buildTrendStats($this->applyFilters(Order::query(), $statusStatsFilters))
|
||||
),
|
||||
'filters' => $filters,
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statuses,
|
||||
'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'],
|
||||
'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'],
|
||||
'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'],
|
||||
'paymentChannels' => ['wechat_pay', 'alipay'],
|
||||
'timeRanges' => [
|
||||
'all' => '全部时间',
|
||||
'today' => '今天',
|
||||
'last_7_days' => '近7天',
|
||||
],
|
||||
'sortOptions' => [
|
||||
'latest' => '创建时间倒序',
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
'product_amount_desc' => '商品金额从高到低',
|
||||
'product_amount_asc' => '商品金额从低到高',
|
||||
],
|
||||
],
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
'platformLabels' => $this->platformLabels(),
|
||||
'deviceTypeLabels' => $this->deviceTypeLabels(),
|
||||
'paymentChannelLabels' => $this->paymentChannelLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
return view('admin.orders.show', [
|
||||
'order' => Order::query()->with(['merchant', 'items.product', 'user'])->findOrFail($id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$filters = $this->filters($request);
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return redirect('/admin/orders?' . http_build_query($this->exportableFilters($filters)))
|
||||
->withErrors($filters['validation_errors'] ?? ['订单筛选条件不合法,请先修正后再导出。']);
|
||||
}
|
||||
|
||||
$fileName = 'platform_orders_' . now()->format('Ymd_His') . '.csv';
|
||||
$exportSummary = $this->buildSummaryStats(
|
||||
$this->applyFilters(Order::query(), $filters)
|
||||
);
|
||||
|
||||
return response()->streamDownload(function () use ($filters, $exportSummary) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
foreach ($this->exportSummaryRows($filters, 'platform') as $summaryRow) {
|
||||
fputcsv($handle, $summaryRow);
|
||||
}
|
||||
|
||||
fputcsv($handle, ['导出订单数', $exportSummary['total_orders'] ?? 0]);
|
||||
fputcsv($handle, ['导出实付总额', number_format((float) ($exportSummary['total_pay_amount'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, ['导出平均客单价', number_format((float) ($exportSummary['average_order_amount'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, ['导出已支付订单数', $exportSummary['paid_orders'] ?? 0]);
|
||||
fputcsv($handle, ['导出支付失败订单', $exportSummary['failed_payment_orders'] ?? 0]);
|
||||
fputcsv($handle, []);
|
||||
|
||||
fputcsv($handle, [
|
||||
'ID',
|
||||
'商家ID',
|
||||
'商家名称',
|
||||
'用户ID',
|
||||
'订单号',
|
||||
'订单状态',
|
||||
'支付状态',
|
||||
'平台',
|
||||
'设备类型',
|
||||
'支付渠道',
|
||||
'买家姓名',
|
||||
'买家手机',
|
||||
'买家邮箱',
|
||||
'商品金额',
|
||||
'优惠金额',
|
||||
'运费',
|
||||
'实付金额',
|
||||
'商品行数',
|
||||
'商品件数',
|
||||
'商品摘要',
|
||||
'创建时间',
|
||||
'支付时间',
|
||||
'发货时间',
|
||||
'完成时间',
|
||||
'备注',
|
||||
]);
|
||||
|
||||
foreach ($this->applySorting($this->applyFilters(Order::query()->with(['merchant', 'items']), $filters), $filters)->cursor() as $order) {
|
||||
$itemCount = $order->items->count();
|
||||
$totalQuantity = (int) $order->items->sum('quantity');
|
||||
$itemSummary = $order->items
|
||||
->map(fn ($item) => trim(($item->product_title ?? '商品') . ' x' . ((int) $item->quantity)))
|
||||
->implode(' | ');
|
||||
|
||||
fputcsv($handle, [
|
||||
$order->id,
|
||||
$order->merchant_id,
|
||||
$order->merchant?->name ?? '',
|
||||
$order->user_id,
|
||||
$order->order_no,
|
||||
$this->statusLabel((string) $order->status),
|
||||
$this->paymentStatusLabel((string) $order->payment_status),
|
||||
$this->platformLabel((string) $order->platform),
|
||||
$this->deviceTypeLabel((string) $order->device_type),
|
||||
$this->paymentChannelLabel((string) $order->payment_channel),
|
||||
$order->buyer_name,
|
||||
$order->buyer_phone,
|
||||
$order->buyer_email,
|
||||
number_format((float) $order->product_amount, 2, '.', ''),
|
||||
number_format((float) $order->discount_amount, 2, '.', ''),
|
||||
number_format((float) $order->shipping_amount, 2, '.', ''),
|
||||
number_format((float) $order->pay_amount, 2, '.', ''),
|
||||
$itemCount,
|
||||
$totalQuantity,
|
||||
$itemSummary,
|
||||
optional($order->created_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->paid_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->shipped_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->completed_at)?->format('Y-m-d H:i:s'),
|
||||
$order->remark,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$order = Order::query()->findOrFail($id);
|
||||
$order->update(['status' => $data['status']]);
|
||||
|
||||
Cache::add(CacheKeys::platformOrdersVersion(), 1, now()->addDays(30));
|
||||
Cache::increment(CacheKeys::platformOrdersVersion());
|
||||
Cache::forget(CacheKeys::platformDashboardStats());
|
||||
|
||||
return redirect('/admin/orders')->with('success', '订单状态更新成功');
|
||||
}
|
||||
|
||||
protected function filters(Request $request): array
|
||||
{
|
||||
$timeRange = trim((string) $request->string('time_range', 'all'));
|
||||
$rawStartDate = trim((string) $request->string('start_date'));
|
||||
$rawEndDate = trim((string) $request->string('end_date'));
|
||||
$minPayAmount = trim((string) $request->string('min_pay_amount'));
|
||||
$maxPayAmount = trim((string) $request->string('max_pay_amount'));
|
||||
$validationErrors = [];
|
||||
|
||||
if ($timeRange === 'today') {
|
||||
$startDate = now()->toDateString();
|
||||
$endDate = now()->toDateString();
|
||||
} elseif ($timeRange === 'last_7_days') {
|
||||
$startDate = now()->subDays(6)->toDateString();
|
||||
$endDate = now()->toDateString();
|
||||
} else {
|
||||
$timeRange = 'all';
|
||||
$startDate = $rawStartDate;
|
||||
$endDate = $rawEndDate;
|
||||
}
|
||||
|
||||
if ($rawStartDate !== '' && ! $this->isValidDate($rawStartDate)) {
|
||||
$validationErrors[] = '开始日期格式不正确,请使用 YYYY-MM-DD。';
|
||||
}
|
||||
|
||||
if ($rawEndDate !== '' && ! $this->isValidDate($rawEndDate)) {
|
||||
$validationErrors[] = '结束日期格式不正确,请使用 YYYY-MM-DD。';
|
||||
}
|
||||
|
||||
if ($rawStartDate !== '' && $rawEndDate !== '' && $this->isValidDate($rawStartDate) && $this->isValidDate($rawEndDate) && $rawStartDate > $rawEndDate) {
|
||||
$validationErrors[] = '开始日期不能晚于结束日期。';
|
||||
}
|
||||
|
||||
if ($minPayAmount !== '' && ! is_numeric($minPayAmount)) {
|
||||
$validationErrors[] = '最低实付金额必须为数字。';
|
||||
}
|
||||
|
||||
if ($maxPayAmount !== '' && ! is_numeric($maxPayAmount)) {
|
||||
$validationErrors[] = '最高实付金额必须为数字。';
|
||||
}
|
||||
|
||||
if ($minPayAmount !== '' && $maxPayAmount !== '' && is_numeric($minPayAmount) && is_numeric($maxPayAmount) && (float) $minPayAmount > (float) $maxPayAmount) {
|
||||
$validationErrors[] = '最低实付金额不能大于最高实付金额。';
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => trim((string) $request->string('status')),
|
||||
'payment_status' => trim((string) $request->string('payment_status')),
|
||||
'platform' => trim((string) $request->string('platform')),
|
||||
'device_type' => trim((string) $request->string('device_type')),
|
||||
'payment_channel' => trim((string) $request->string('payment_channel')),
|
||||
'keyword' => trim((string) $request->string('keyword')),
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'min_pay_amount' => $minPayAmount,
|
||||
'max_pay_amount' => $maxPayAmount,
|
||||
'time_range' => $timeRange,
|
||||
'sort' => trim((string) $request->string('sort', 'latest')),
|
||||
'validation_errors' => $validationErrors,
|
||||
'has_validation_error' => ! empty($validationErrors),
|
||||
];
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status']))
|
||||
->when(($filters['payment_status'] ?? '') !== '', fn ($builder) => $builder->where('payment_status', $filters['payment_status']))
|
||||
->when(($filters['platform'] ?? '') !== '', fn ($builder) => $builder->where('platform', $filters['platform']))
|
||||
->when(($filters['device_type'] ?? '') !== '', fn ($builder) => $builder->where('device_type', $filters['device_type']))
|
||||
->when(($filters['payment_channel'] ?? '') !== '', fn ($builder) => $builder->where('payment_channel', $filters['payment_channel']))
|
||||
->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) {
|
||||
$subQuery->where('order_no', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_name', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_phone', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_email', 'like', '%' . $filters['keyword'] . '%');
|
||||
}))
|
||||
->when(($filters['start_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '>=', $filters['start_date']))
|
||||
->when(($filters['end_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '<=', $filters['end_date']))
|
||||
->when(($filters['min_pay_amount'] ?? '') !== '' && is_numeric($filters['min_pay_amount']), fn ($builder) => $builder->where('pay_amount', '>=', $filters['min_pay_amount']))
|
||||
->when(($filters['max_pay_amount'] ?? '') !== '' && is_numeric($filters['max_pay_amount']), fn ($builder) => $builder->where('pay_amount', '<=', $filters['max_pay_amount']));
|
||||
}
|
||||
|
||||
protected function buildStatusStats(Builder $query): array
|
||||
{
|
||||
$counts = (clone $query)
|
||||
->selectRaw('status, COUNT(*) as aggregate')
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status');
|
||||
|
||||
$stats = ['all' => (int) $counts->sum()];
|
||||
|
||||
foreach ($this->statuses as $status) {
|
||||
$stats[$status] = (int) ($counts[$status] ?? 0);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function applySorting(Builder $query, array $filters): Builder
|
||||
{
|
||||
return match ($filters['sort'] ?? 'latest') {
|
||||
'oldest' => $query->orderBy('created_at')->orderBy('id'),
|
||||
'pay_amount_desc' => $query->orderByDesc('pay_amount')->orderByDesc('id'),
|
||||
'pay_amount_asc' => $query->orderBy('pay_amount')->orderByDesc('id'),
|
||||
'product_amount_desc' => $query->orderByDesc('product_amount')->orderByDesc('id'),
|
||||
'product_amount_asc' => $query->orderBy('product_amount')->orderByDesc('id'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildSummaryStats(Builder $query): array
|
||||
{
|
||||
$summary = (clone $query)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(pay_amount), 0) as total_pay_amount')
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'unpaid' THEN pay_amount ELSE 0 END) as unpaid_pay_amount")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN pay_amount ELSE 0 END) as paid_pay_amount")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN 1 ELSE 0 END) as paid_orders")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'refunded' THEN 1 ELSE 0 END) as refunded_orders")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) as failed_payment_orders")
|
||||
->selectRaw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as pending_shipment_orders")
|
||||
->selectRaw("SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders")
|
||||
->selectRaw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_orders")
|
||||
->first();
|
||||
|
||||
$totalOrders = (int) ($summary->total_orders ?? 0);
|
||||
$totalPayAmount = (float) ($summary->total_pay_amount ?? 0);
|
||||
$paidOrders = (int) ($summary->paid_orders ?? 0);
|
||||
$refundedOrders = (int) ($summary->refunded_orders ?? 0);
|
||||
$completedOrders = (int) ($summary->completed_orders ?? 0);
|
||||
$cancelledOrders = (int) ($summary->cancelled_orders ?? 0);
|
||||
|
||||
return [
|
||||
'total_orders' => $totalOrders,
|
||||
'total_pay_amount' => $totalPayAmount,
|
||||
'unpaid_pay_amount' => (float) ($summary->unpaid_pay_amount ?? 0),
|
||||
'paid_pay_amount' => (float) ($summary->paid_pay_amount ?? 0),
|
||||
'paid_orders' => $paidOrders,
|
||||
'refunded_orders' => $refundedOrders,
|
||||
'failed_payment_orders' => (int) ($summary->failed_payment_orders ?? 0),
|
||||
'pending_shipment_orders' => (int) ($summary->pending_shipment_orders ?? 0),
|
||||
'completed_orders' => $completedOrders,
|
||||
'cancelled_orders' => $cancelledOrders,
|
||||
'average_order_amount' => $totalOrders > 0 ? round($totalPayAmount / $totalOrders, 2) : 0,
|
||||
'payment_rate' => $totalOrders > 0 ? round(($paidOrders / $totalOrders) * 100, 2) : 0,
|
||||
'refund_rate' => $paidOrders > 0 ? round(($refundedOrders / $paidOrders) * 100, 2) : 0,
|
||||
'completion_rate' => $totalOrders > 0 ? round(($completedOrders / $totalOrders) * 100, 2) : 0,
|
||||
'cancellation_rate' => $totalOrders > 0 ? round(($cancelledOrders / $totalOrders) * 100, 2) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildTrendStats(Builder $query): array
|
||||
{
|
||||
$todayStart = Carbon::today();
|
||||
$tomorrowStart = (clone $todayStart)->copy()->addDay();
|
||||
$last7DaysStart = (clone $todayStart)->copy()->subDays(6)->startOfDay();
|
||||
|
||||
$today = (clone $query)
|
||||
->where('created_at', '>=', $todayStart)
|
||||
->where('created_at', '<', $tomorrowStart)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount')
|
||||
->first();
|
||||
|
||||
$last7Days = (clone $query)
|
||||
->where('created_at', '>=', $last7DaysStart)
|
||||
->where('created_at', '<', $tomorrowStart)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'today_orders' => (int) ($today->total_orders ?? 0),
|
||||
'today_pay_amount' => (float) ($today->total_pay_amount ?? 0),
|
||||
'last_7_days_orders' => (int) ($last7Days->total_orders ?? 0),
|
||||
'last_7_days_pay_amount' => (float) ($last7Days->total_pay_amount ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function emptyStatusStats(): array
|
||||
{
|
||||
$stats = ['all' => 0];
|
||||
|
||||
foreach ($this->statuses as $status) {
|
||||
$stats[$status] = 0;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function emptySummaryStats(): array
|
||||
{
|
||||
return [
|
||||
'total_orders' => 0,
|
||||
'total_pay_amount' => 0,
|
||||
'unpaid_pay_amount' => 0,
|
||||
'paid_pay_amount' => 0,
|
||||
'paid_orders' => 0,
|
||||
'refunded_orders' => 0,
|
||||
'failed_payment_orders' => 0,
|
||||
'pending_shipment_orders' => 0,
|
||||
'completed_orders' => 0,
|
||||
'cancelled_orders' => 0,
|
||||
'average_order_amount' => 0,
|
||||
'payment_rate' => 0,
|
||||
'refund_rate' => 0,
|
||||
'completion_rate' => 0,
|
||||
'cancellation_rate' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function emptyTrendStats(): array
|
||||
{
|
||||
return [
|
||||
'today_orders' => 0,
|
||||
'today_pay_amount' => 0,
|
||||
'last_7_days_orders' => 0,
|
||||
'last_7_days_pay_amount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function isValidDate(string $value): bool
|
||||
{
|
||||
try {
|
||||
$date = Carbon::createFromFormat('Y-m-d', $value);
|
||||
} catch (\Throwable $exception) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $date && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
|
||||
protected function exportableFilters(array $filters): array
|
||||
{
|
||||
return array_filter([
|
||||
'status' => $filters['status'] ?? '',
|
||||
'payment_status' => $filters['payment_status'] ?? '',
|
||||
'platform' => $filters['platform'] ?? '',
|
||||
'device_type' => $filters['device_type'] ?? '',
|
||||
'payment_channel' => $filters['payment_channel'] ?? '',
|
||||
'keyword' => $filters['keyword'] ?? '',
|
||||
'start_date' => $filters['start_date'] ?? '',
|
||||
'end_date' => $filters['end_date'] ?? '',
|
||||
'min_pay_amount' => $filters['min_pay_amount'] ?? '',
|
||||
'max_pay_amount' => $filters['max_pay_amount'] ?? '',
|
||||
'time_range' => $filters['time_range'] ?? '',
|
||||
'sort' => $filters['sort'] ?? '',
|
||||
], fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest');
|
||||
}
|
||||
|
||||
protected function exportSummaryRows(array $filters, string $scope, ?int $merchantId = null): array
|
||||
{
|
||||
return [
|
||||
['导出信息', $scope === 'platform' ? '总台订单导出' : '商家订单导出'],
|
||||
['导出时间', now()->format('Y-m-d H:i:s')],
|
||||
['商家ID', $merchantId ? (string) $merchantId : '全部商家'],
|
||||
['订单状态', $this->statusLabel($filters['status'] ?? '')],
|
||||
['支付状态', $this->paymentStatusLabel($filters['payment_status'] ?? '')],
|
||||
['平台', $this->platformLabel($filters['platform'] ?? '')],
|
||||
['设备类型', $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels())],
|
||||
['支付渠道', $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels())],
|
||||
['关键词', $this->displayTextValue($filters['keyword'] ?? '')],
|
||||
['快捷时间范围', $this->displayFilterValue($filters['time_range'] ?? 'all', [
|
||||
'all' => '全部时间',
|
||||
'today' => '今天',
|
||||
'last_7_days' => '近7天',
|
||||
])],
|
||||
['开始日期', $this->displayTextValue($filters['start_date'] ?? '')],
|
||||
['结束日期', $this->displayTextValue($filters['end_date'] ?? '')],
|
||||
['最低实付金额', $this->displayMoneyValue($filters['min_pay_amount'] ?? '')],
|
||||
['最高实付金额', $this->displayMoneyValue($filters['max_pay_amount'] ?? '')],
|
||||
['排序', $this->sortLabel($filters['sort'] ?? 'latest')],
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildActiveFilterSummary(array $filters): array
|
||||
{
|
||||
return [
|
||||
'关键词' => $this->displayTextValue($filters['keyword'] ?? '', '全部'),
|
||||
'订单状态' => $this->statusLabel($filters['status'] ?? ''),
|
||||
'支付状态' => $this->paymentStatusLabel($filters['payment_status'] ?? ''),
|
||||
'平台' => $this->platformLabel($filters['platform'] ?? ''),
|
||||
'设备类型' => $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels()),
|
||||
'支付渠道' => $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels()),
|
||||
'实付金额区间' => $this->formatMoneyRange($filters['min_pay_amount'] ?? '', $filters['max_pay_amount'] ?? ''),
|
||||
'排序' => $this->sortLabel($filters['sort'] ?? 'latest'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'pending' => '待处理',
|
||||
'paid' => '已支付',
|
||||
'shipped' => '已发货',
|
||||
'completed' => '已完成',
|
||||
'cancelled' => '已取消',
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabel(string $status): string
|
||||
{
|
||||
return $this->statusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function paymentStatusLabels(): array
|
||||
{
|
||||
return [
|
||||
'unpaid' => '未支付',
|
||||
'paid' => '已支付',
|
||||
'refunded' => '已退款',
|
||||
'failed' => '支付失败',
|
||||
];
|
||||
}
|
||||
|
||||
protected function paymentStatusLabel(string $status): string
|
||||
{
|
||||
return $this->paymentStatusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function platformLabels(): array
|
||||
{
|
||||
return [
|
||||
'pc' => 'PC 端',
|
||||
'h5' => 'H5',
|
||||
'wechat_mp' => '微信公众号',
|
||||
'wechat_mini' => '微信小程序',
|
||||
'app' => 'APP 接口预留',
|
||||
];
|
||||
}
|
||||
|
||||
protected function platformLabel(string $platform): string
|
||||
{
|
||||
return $this->platformLabels()[$platform] ?? '全部';
|
||||
}
|
||||
|
||||
protected function deviceTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
'desktop' => '桌面浏览器',
|
||||
'mobile' => '移动浏览器',
|
||||
'mini-program' => '小程序环境',
|
||||
'mobile-webview' => '微信内网页',
|
||||
'app-api' => 'APP 接口',
|
||||
];
|
||||
}
|
||||
|
||||
protected function deviceTypeLabel(string $deviceType): string
|
||||
{
|
||||
return $this->deviceTypeLabels()[$deviceType] ?? ($deviceType === '' ? '未设置' : $deviceType);
|
||||
}
|
||||
|
||||
protected function paymentChannelLabels(): array
|
||||
{
|
||||
return [
|
||||
'wechat_pay' => '微信支付',
|
||||
'alipay' => '支付宝',
|
||||
];
|
||||
}
|
||||
|
||||
protected function paymentChannelLabel(string $paymentChannel): string
|
||||
{
|
||||
return $this->paymentChannelLabels()[$paymentChannel] ?? ($paymentChannel === '' ? '未设置' : $paymentChannel);
|
||||
}
|
||||
|
||||
protected function sortLabel(string $sort): string
|
||||
{
|
||||
return match ($sort) {
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
'product_amount_desc' => '商品金额从高到低',
|
||||
'product_amount_asc' => '商品金额从低到高',
|
||||
default => '创建时间倒序',
|
||||
};
|
||||
}
|
||||
|
||||
protected function formatMoneyRange(string $min, string $max): string
|
||||
{
|
||||
if ($min === '' && $max === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
$minLabel = $min !== '' && is_numeric($min) ? ('¥' . number_format((float) $min, 2, '.', '')) : '不限';
|
||||
$maxLabel = $max !== '' && is_numeric($max) ? ('¥' . number_format((float) $max, 2, '.', '')) : '不限';
|
||||
|
||||
return $minLabel . ' ~ ' . $maxLabel;
|
||||
}
|
||||
|
||||
protected function displayFilterValue(string $value, array $options): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
return (string) ($options[$value] ?? $value);
|
||||
}
|
||||
|
||||
protected function displayTextValue(string $value, string $default = '未设置'): string
|
||||
{
|
||||
return $value === '' ? $default : $value;
|
||||
}
|
||||
|
||||
protected function displayMoneyValue(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value;
|
||||
}
|
||||
|
||||
protected function workbenchLinks(): array
|
||||
{
|
||||
return [
|
||||
'paid_high_amount' => '/admin/orders?sort=pay_amount_desc&payment_status=paid',
|
||||
'pending_latest' => '/admin/orders?sort=latest&payment_status=unpaid',
|
||||
'failed_latest' => '/admin/orders?sort=latest&payment_status=failed',
|
||||
'completed_latest' => '/admin/orders?sort=latest&status=completed',
|
||||
'current' => '/admin/orders',
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildOperationsFocus(array $summaryStats, array $filters): array
|
||||
{
|
||||
$pendingCount = (int) Order::query()->where('payment_status', 'unpaid')->count();
|
||||
$failedCount = (int) Order::query()->where('payment_status', 'failed')->count();
|
||||
$completedCount = (int) Order::query()->where('status', 'completed')->count();
|
||||
$links = $this->workbenchLinks();
|
||||
$currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest'));
|
||||
$currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : '');
|
||||
$workbench = [
|
||||
'高金额已支付' => $links['paid_high_amount'],
|
||||
'待支付跟进' => $links['pending_latest'],
|
||||
'支付失败排查' => $links['failed_latest'],
|
||||
'最近完成订单' => $links['completed_latest'],
|
||||
'返回当前筛选视图' => $currentUrl,
|
||||
];
|
||||
$signals = [
|
||||
'待支付订单' => $pendingCount,
|
||||
'支付失败订单' => $failedCount,
|
||||
'已完成订单' => $completedCount,
|
||||
];
|
||||
|
||||
if (($filters['platform'] ?? '') === 'wechat_mini') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信小程序订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_channel'] ?? '') === 'wechat_pay') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看支付失败订单', 'url' => $links['failed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mini-program') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。',
|
||||
'actions' => [
|
||||
['label' => '继续查看小程序环境订单', 'url' => $currentUrl],
|
||||
['label' => '去看微信支付订单', 'url' => $links['failed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mobile-webview') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信内网页订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mobile') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。',
|
||||
'actions' => [
|
||||
['label' => '继续查看移动浏览器订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'desktop') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。',
|
||||
'actions' => [
|
||||
['label' => '继续查看桌面浏览器订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'failed') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。',
|
||||
'actions' => [
|
||||
['label' => '继续查看支付失败订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'unpaid') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦待支付订单,建议优先跟进下单未支付用户,并观察支付转化时效。',
|
||||
'actions' => [
|
||||
['label' => '继续查看待支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'paid') {
|
||||
return [
|
||||
'headline' => '当前正在查看已支付订单,建议优先关注高金额订单履约进度与异常售后风险。',
|
||||
'actions' => [
|
||||
['label' => '继续查看已支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看最近完成订单', 'url' => $links['completed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['status'] ?? '') === 'completed') {
|
||||
return [
|
||||
'headline' => '当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。',
|
||||
'actions' => [
|
||||
['label' => '继续查看已完成订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_orders'] ?? 0) <= 0) {
|
||||
return [
|
||||
'headline' => '当前总台视角下暂无订单,建议先确认交易链路、支付链路与站点回写链路是否都已打通。',
|
||||
'actions' => [
|
||||
['label' => '先看订单整体情况', 'url' => $links['paid_high_amount']],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_orders'] ?? 0) < 5) {
|
||||
return [
|
||||
'headline' => '当前总台订单仍较少,建议优先关注待支付订单,并同步查看已支付订单质量。',
|
||||
'actions' => [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'headline' => $failedCount > 0
|
||||
? '当前总台订单已形成基础规模,建议优先关注待支付、支付失败与高金额已支付订单。'
|
||||
: '当前总台订单已形成基础规模,建议优先关注待支付与高金额已支付订单,保持交易闭环稳定。',
|
||||
'actions' => $failedCount > 0
|
||||
? [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看支付失败订单', 'url' => $links['failed_latest']],
|
||||
]
|
||||
: [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
}
|
||||
249
app/Http/Controllers/Admin/PlanController.php
Normal file
249
app/Http/Controllers/Admin/PlanController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PlanController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->query('status', '')),
|
||||
'billing_cycle' => trim((string) $request->query('billing_cycle', '')),
|
||||
'keyword' => trim((string) $request->query('keyword', '')),
|
||||
'published' => trim((string) $request->query('published', '')),
|
||||
];
|
||||
|
||||
$query = $this->applyFilters(Plan::query(), $filters)
|
||||
->orderBy('sort')
|
||||
->orderByDesc('id');
|
||||
|
||||
$filename = 'plans_' . now()->format('Ymd_His') . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($query) {
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// UTF-8 BOM,避免 Excel 打开中文乱码
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($out, [
|
||||
'ID',
|
||||
'套餐名称',
|
||||
'编码',
|
||||
'计费周期',
|
||||
'售价',
|
||||
'划线价',
|
||||
'状态',
|
||||
'排序',
|
||||
'发布时间',
|
||||
'描述',
|
||||
]);
|
||||
|
||||
$query->chunkById(500, function ($plans) use ($out) {
|
||||
foreach ($plans as $plan) {
|
||||
fputcsv($out, [
|
||||
$plan->id,
|
||||
$plan->name,
|
||||
$plan->code,
|
||||
$plan->billing_cycle,
|
||||
(float) $plan->price,
|
||||
(float) $plan->list_price,
|
||||
$plan->status,
|
||||
(int) $plan->sort,
|
||||
optional($plan->published_at)->format('Y-m-d H:i:s') ?: '',
|
||||
$plan->description ?: '',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($out);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->query('status', '')),
|
||||
'billing_cycle' => trim((string) $request->query('billing_cycle', '')),
|
||||
'keyword' => trim((string) $request->query('keyword', '')),
|
||||
// 发布状态筛选(按 published_at 是否为空)
|
||||
// - published:已发布(published_at not null)
|
||||
// - unpublished:未发布(published_at is null)
|
||||
'published' => trim((string) $request->query('published', '')),
|
||||
];
|
||||
|
||||
$plansQuery = $this->applyFilters(Plan::query(), $filters);
|
||||
|
||||
$plans = (clone $plansQuery)
|
||||
->orderBy('sort')
|
||||
->orderByDesc('id')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.plans.index', [
|
||||
'plans' => $plans,
|
||||
'filters' => $filters,
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statusLabels(),
|
||||
'billingCycles' => $this->billingCycleLabels(),
|
||||
],
|
||||
'summaryStats' => [
|
||||
'total_plans' => (clone $plansQuery)->count(),
|
||||
'active_plans' => (clone $plansQuery)->where('status', 'active')->count(),
|
||||
'monthly_plans' => (clone $plansQuery)->where('billing_cycle', 'monthly')->count(),
|
||||
'yearly_plans' => (clone $plansQuery)->where('billing_cycle', 'yearly')->count(),
|
||||
'published_plans' => (clone $plansQuery)->whereNotNull('published_at')->count(),
|
||||
'unpublished_plans' => (clone $plansQuery)->whereNull('published_at')->count(),
|
||||
],
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'billingCycleLabels' => $this->billingCycleLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
return view('admin.plans.form', [
|
||||
'plan' => new Plan(),
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'billingCycleLabels' => $this->billingCycleLabels(),
|
||||
'formAction' => '/admin/plans',
|
||||
'method' => 'post',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$data = $this->validatePlan($request);
|
||||
$plan = Plan::query()->create($data);
|
||||
|
||||
return redirect('/admin/plans')->with('success', '套餐已创建:' . $plan->name);
|
||||
}
|
||||
|
||||
public function edit(Request $request, Plan $plan): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
return view('admin.plans.form', [
|
||||
'plan' => $plan,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'billingCycleLabels' => $this->billingCycleLabels(),
|
||||
'formAction' => '/admin/plans/' . $plan->id,
|
||||
'method' => 'post',
|
||||
]);
|
||||
}
|
||||
|
||||
public function setStatus(Request $request, Plan $plan): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'status' => ['required', Rule::in(array_keys($this->statusLabels()))],
|
||||
]);
|
||||
|
||||
$plan->status = (string) $data['status'];
|
||||
|
||||
// 最小治理:当启用且未设置发布时间时,自动补一个发布时间(便于运营口径)
|
||||
if ($plan->status === 'active' && $plan->published_at === null) {
|
||||
$plan->published_at = now();
|
||||
}
|
||||
|
||||
$plan->save();
|
||||
|
||||
return redirect()->back()->with('success', '套餐状态已更新:' . ($this->statusLabels()[$plan->status] ?? $plan->status));
|
||||
}
|
||||
|
||||
public function update(Request $request, Plan $plan): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$data = $this->validatePlan($request, $plan->id);
|
||||
$plan->update($data);
|
||||
|
||||
return redirect('/admin/plans')->with('success', '套餐已更新:' . $plan->name);
|
||||
}
|
||||
|
||||
protected function validatePlan(Request $request, ?int $planId = null): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z0-9-_]+$/', Rule::unique('plans', 'code')->ignore($planId)],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'billing_cycle' => ['required', Rule::in(array_keys($this->billingCycleLabels()))],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
'list_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'status' => ['required', Rule::in(array_keys($this->statusLabels()))],
|
||||
'sort' => ['nullable', 'integer', 'min:0'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
], [
|
||||
'code.regex' => '套餐编码仅支持字母、数字、短横线与下划线。',
|
||||
]);
|
||||
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status']))
|
||||
->when($filters['billing_cycle'] !== '', fn (Builder $builder) => $builder->where('billing_cycle', $filters['billing_cycle']))
|
||||
->when(($filters['published'] ?? '') !== '', function (Builder $builder) use ($filters) {
|
||||
$published = (string) ($filters['published'] ?? '');
|
||||
if ($published === 'published') {
|
||||
$builder->whereNotNull('published_at');
|
||||
} elseif ($published === 'unpublished') {
|
||||
$builder->whereNull('published_at');
|
||||
}
|
||||
})
|
||||
->when($filters['keyword'] !== '', function (Builder $builder) use ($filters) {
|
||||
$keyword = $filters['keyword'];
|
||||
|
||||
$builder->where(function (Builder $subQuery) use ($keyword) {
|
||||
$subQuery->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('description', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'active' => '启用中',
|
||||
'draft' => '草稿中',
|
||||
'inactive' => '未启用',
|
||||
];
|
||||
}
|
||||
|
||||
protected function billingCycleLabels(): array
|
||||
{
|
||||
return [
|
||||
'monthly' => '月付',
|
||||
'quarterly' => '季付',
|
||||
'yearly' => '年付',
|
||||
'one_time' => '一次性',
|
||||
];
|
||||
}
|
||||
}
|
||||
556
app/Http/Controllers/Admin/PlatformOrderController.php
Normal file
556
app/Http/Controllers/Admin/PlatformOrderController.php
Normal file
@@ -0,0 +1,556 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PlatformOrder;
|
||||
use App\Support\SubscriptionActivationService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PlatformOrderController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->query('status', '')),
|
||||
'payment_status' => trim((string) $request->query('payment_status', '')),
|
||||
'merchant_id' => trim((string) $request->query('merchant_id', '')),
|
||||
'plan_id' => trim((string) $request->query('plan_id', '')),
|
||||
'fail_only' => (string) $request->query('fail_only', ''),
|
||||
'synced_only' => (string) $request->query('synced_only', ''),
|
||||
'sync_status' => trim((string) $request->query('sync_status', '')),
|
||||
// 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理)
|
||||
'syncable_only' => (string) $request->query('syncable_only', ''),
|
||||
// 只看最近 24 小时批量同步过的订单(可治理追踪)
|
||||
'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''),
|
||||
];
|
||||
|
||||
$orders = $this->applyFilters(PlatformOrder::query()->with(['merchant', 'plan', 'siteSubscription']), $filters)
|
||||
->latest('id')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
$baseQuery = $this->applyFilters(PlatformOrder::query(), $filters);
|
||||
|
||||
// 同步失败原因聚合(Top 5):用于运营快速判断“常见失败原因”
|
||||
// 注意:这里用 JSON_EXTRACT 做 group by,MySQL 会返回带引号的 JSON 字符串,展示时做一次 trim 处理。
|
||||
$failedReasonRows = (clone $baseQuery)
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL")
|
||||
->selectRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') as reason, count(*) as cnt")
|
||||
->groupBy('reason')
|
||||
->orderByDesc('cnt')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$failedReasonStats = $failedReasonRows->map(function ($row) {
|
||||
$reason = (string) ($row->reason ?? '');
|
||||
$reason = trim($reason, "\" ");
|
||||
|
||||
return [
|
||||
'reason' => $reason !== '' ? $reason : '(空)',
|
||||
'count' => (int) ($row->cnt ?? 0),
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return view('admin.platform_orders.index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $filters,
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statusLabels(),
|
||||
'paymentStatuses' => $this->paymentStatusLabels(),
|
||||
],
|
||||
'merchants' => PlatformOrder::query()->with('merchant')
|
||||
->select('merchant_id')
|
||||
->whereNotNull('merchant_id')
|
||||
->distinct()
|
||||
->get()
|
||||
->pluck('merchant')
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values(),
|
||||
'plans' => PlatformOrder::query()->with('plan')
|
||||
->select('plan_id')
|
||||
->whereNotNull('plan_id')
|
||||
->distinct()
|
||||
->get()
|
||||
->pluck('plan')
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values(),
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
'summaryStats' => [
|
||||
'total_orders' => (clone $baseQuery)->count(),
|
||||
'paid_orders' => (clone $baseQuery)->where('payment_status', 'paid')->count(),
|
||||
'activated_orders' => (clone $baseQuery)->where('status', 'activated')->count(),
|
||||
'synced_orders' => (clone $baseQuery)
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL")
|
||||
->count(),
|
||||
'failed_sync_orders' => (clone $baseQuery)
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL")
|
||||
->count(),
|
||||
'unsynced_orders' => (clone $baseQuery)
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL")
|
||||
->count(),
|
||||
'total_payable_amount' => (float) ((clone $baseQuery)->sum('payable_amount') ?: 0),
|
||||
'total_paid_amount' => (float) ((clone $baseQuery)->sum('paid_amount') ?: 0),
|
||||
],
|
||||
'failedReasonStats' => $failedReasonStats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, PlatformOrder $order): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$order->loadMissing(['merchant', 'plan', 'siteSubscription']);
|
||||
|
||||
return view('admin.platform_orders.show', [
|
||||
'order' => $order,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function activateSubscription(Request $request, PlatformOrder $order, SubscriptionActivationService $service): RedirectResponse
|
||||
{
|
||||
$admin = $this->ensurePlatformAdmin($request);
|
||||
|
||||
try {
|
||||
$subscription = $service->activateOrder($order->id, $admin->id);
|
||||
|
||||
// 同步成功:清理失败记录(若存在)
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
data_forget($meta, 'subscription_activation_error');
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
// 同步失败:写入错误信息,便于运营排查(可治理)
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
data_set($meta, 'subscription_activation_error', [
|
||||
'message' => $e->getMessage(),
|
||||
'at' => now()->toDateTimeString(),
|
||||
'admin_id' => $admin->id,
|
||||
]);
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
|
||||
return redirect()->back()->with('error', '订阅同步失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '订阅已同步:' . $subscription->subscription_no);
|
||||
}
|
||||
|
||||
public function markPaidAndActivate(Request $request, PlatformOrder $order, SubscriptionActivationService $service): RedirectResponse
|
||||
{
|
||||
$admin = $this->ensurePlatformAdmin($request);
|
||||
|
||||
// 最小状态推进:将订单标记为已支付 + 已生效,并补齐时间与金额字段
|
||||
$now = now();
|
||||
$order->payment_status = 'paid';
|
||||
$order->status = 'activated';
|
||||
$order->paid_at = $order->paid_at ?: $now;
|
||||
$order->activated_at = $order->activated_at ?: $now;
|
||||
$order->paid_amount = $order->paid_amount > 0 ? $order->paid_amount : $order->payable_amount;
|
||||
$order->save();
|
||||
|
||||
// 立刻同步订阅
|
||||
try {
|
||||
$subscription = $service->activateOrder($order->id, $admin->id);
|
||||
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
data_forget($meta, 'subscription_activation_error');
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
data_set($meta, 'subscription_activation_error', [
|
||||
'message' => $e->getMessage(),
|
||||
'at' => now()->toDateTimeString(),
|
||||
'admin_id' => $admin->id,
|
||||
]);
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
|
||||
return redirect()->back()->with('error', '订单已标记为已支付/已生效,但订阅同步失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '订单已标记支付并生效,订阅已同步:' . $subscription->subscription_no);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->query('status', '')),
|
||||
'payment_status' => trim((string) $request->query('payment_status', '')),
|
||||
'merchant_id' => trim((string) $request->query('merchant_id', '')),
|
||||
'plan_id' => trim((string) $request->query('plan_id', '')),
|
||||
'fail_only' => (string) $request->query('fail_only', ''),
|
||||
'synced_only' => (string) $request->query('synced_only', ''),
|
||||
'sync_status' => trim((string) $request->query('sync_status', '')),
|
||||
// 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理)
|
||||
'syncable_only' => (string) $request->query('syncable_only', ''),
|
||||
// 只看最近 24 小时批量同步过的订单(可治理追踪)
|
||||
'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''),
|
||||
];
|
||||
|
||||
$includeMeta = (string) $request->query('include_meta', '') === '1';
|
||||
|
||||
$query = $this->applyFilters(
|
||||
PlatformOrder::query()->with(['merchant', 'plan', 'siteSubscription']),
|
||||
$filters
|
||||
)->orderBy('id');
|
||||
|
||||
$filename = 'platform_orders_' . now()->format('Ymd_His') . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($query, $includeMeta) {
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// UTF-8 BOM,避免 Excel 打开中文乱码
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
$headers = [
|
||||
'ID',
|
||||
'订单号',
|
||||
'站点',
|
||||
'套餐',
|
||||
'订单类型',
|
||||
'订单状态',
|
||||
'支付状态',
|
||||
'应付金额',
|
||||
'已付金额',
|
||||
'下单时间',
|
||||
'支付时间',
|
||||
'生效时间',
|
||||
'同步状态',
|
||||
'订阅号',
|
||||
'订阅到期',
|
||||
'同步时间',
|
||||
'同步失败原因',
|
||||
'同步失败时间',
|
||||
];
|
||||
|
||||
if ($includeMeta) {
|
||||
$headers[] = '原始meta(JSON)';
|
||||
}
|
||||
|
||||
fputcsv($out, $headers);
|
||||
|
||||
$query->chunkById(500, function ($orders) use ($out, $includeMeta) {
|
||||
foreach ($orders as $order) {
|
||||
$syncedId = (int) data_get($order->meta, 'subscription_activation.subscription_id', 0);
|
||||
$syncErr = (string) (data_get($order->meta, 'subscription_activation_error.message') ?? '');
|
||||
|
||||
if ($syncedId > 0) {
|
||||
$syncStatus = '已同步';
|
||||
} elseif ($syncErr !== '') {
|
||||
$syncStatus = '同步失败';
|
||||
} else {
|
||||
$syncStatus = '未同步';
|
||||
}
|
||||
|
||||
$row = [
|
||||
$order->id,
|
||||
$order->order_no,
|
||||
$order->merchant?->name ?? '',
|
||||
$order->plan_name ?: ($order->plan?->name ?? ''),
|
||||
$order->order_type,
|
||||
$order->status,
|
||||
$order->payment_status,
|
||||
(float) $order->payable_amount,
|
||||
(float) $order->paid_amount,
|
||||
optional($order->placed_at)->format('Y-m-d H:i:s') ?: '',
|
||||
optional($order->paid_at)->format('Y-m-d H:i:s') ?: '',
|
||||
optional($order->activated_at)->format('Y-m-d H:i:s') ?: '',
|
||||
$syncStatus,
|
||||
$order->siteSubscription?->subscription_no ?: '',
|
||||
optional($order->siteSubscription?->ends_at)->format('Y-m-d H:i:s') ?: '',
|
||||
(string) (data_get($order->meta, 'subscription_activation.synced_at') ?? ''),
|
||||
$syncErr,
|
||||
(string) (data_get($order->meta, 'subscription_activation_error.at') ?? ''),
|
||||
];
|
||||
|
||||
if ($includeMeta) {
|
||||
$row[] = json_encode($order->meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($out);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
public function batchActivateSubscriptions(Request $request, SubscriptionActivationService $service): RedirectResponse
|
||||
{
|
||||
$admin = $this->ensurePlatformAdmin($request);
|
||||
|
||||
// 支持两种 scope:
|
||||
// - scope=filtered:只处理当前筛选范围内的订单(更安全,默认)
|
||||
// - scope=all:处理全部订单(谨慎)
|
||||
$scope = (string) $request->input('scope', 'filtered');
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->input('status', '')),
|
||||
'payment_status' => trim((string) $request->input('payment_status', '')),
|
||||
'merchant_id' => trim((string) $request->input('merchant_id', '')),
|
||||
'plan_id' => trim((string) $request->input('plan_id', '')),
|
||||
'fail_only' => (string) $request->input('fail_only', ''),
|
||||
'synced_only' => (string) $request->input('synced_only', ''),
|
||||
'sync_status' => trim((string) $request->input('sync_status', '')),
|
||||
'syncable_only' => (string) $request->input('syncable_only', ''),
|
||||
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
||||
];
|
||||
|
||||
// 防误操作:批量同步默认要求先勾选“只看可同步”,避免无意识扩大处理范围
|
||||
if ($scope === 'filtered' && ($filters['syncable_only'] ?? '') !== '1') {
|
||||
return redirect()->back()->with('warning', '为避免误操作,请先在筛选条件中勾选「只看可同步」,再执行批量同步订阅。');
|
||||
}
|
||||
|
||||
// 防误操作:scope=all 需要二次确认
|
||||
if ($scope === 'all' && (string) $request->input('confirm', '') !== 'YES') {
|
||||
return redirect()->back()->with('warning', '为避免误操作,执行全量批量同步前请在确认框输入 YES。');
|
||||
}
|
||||
|
||||
$query = PlatformOrder::query();
|
||||
|
||||
if ($scope === 'filtered') {
|
||||
$query = $this->applyFilters($query, $filters);
|
||||
}
|
||||
|
||||
// 只处理“可同步”的订单(双保险,避免误操作)
|
||||
$query = $query
|
||||
->where('payment_status', 'paid')
|
||||
->where('status', 'activated')
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL");
|
||||
|
||||
$limit = (int) $request->input('limit', 50);
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
$matchedTotal = (clone $query)->count();
|
||||
|
||||
// 默认按最新订单优先处理:避免 seed/demo 数据干扰测试,同时也更符合“先处理新问题”的运营直觉
|
||||
$orders = $query->orderByDesc('id')->limit($limit)->get(['id']);
|
||||
$processed = $orders->count();
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$failedReasonCounts = [];
|
||||
|
||||
foreach ($orders as $orderRow) {
|
||||
try {
|
||||
$service->activateOrder($orderRow->id, $admin->id);
|
||||
|
||||
// 轻量审计:记录批量同步动作(方便追溯)
|
||||
$order = PlatformOrder::query()->find($orderRow->id);
|
||||
if ($order) {
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
$audit = (array) (data_get($meta, 'audit', []) ?? []);
|
||||
$nowStr = now()->toDateTimeString();
|
||||
$audit[] = [
|
||||
'action' => 'batch_activate_subscription',
|
||||
'scope' => $scope,
|
||||
'at' => $nowStr,
|
||||
'admin_id' => $admin->id,
|
||||
];
|
||||
data_set($meta, 'audit', $audit);
|
||||
|
||||
// 便于筛选/统计:记录最近一次批量同步信息(扁平字段)
|
||||
data_set($meta, 'batch_activation', [
|
||||
'at' => $nowStr,
|
||||
'admin_id' => $admin->id,
|
||||
'scope' => $scope,
|
||||
]);
|
||||
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
}
|
||||
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
|
||||
$reason = trim((string) $e->getMessage());
|
||||
$reason = $reason !== '' ? $reason : '未知错误';
|
||||
$failedReasonCounts[$reason] = ($failedReasonCounts[$reason] ?? 0) + 1;
|
||||
|
||||
// 批量同步失败也需要可治理:写入失败原因到订单 meta,便于后续筛选/导出/清理
|
||||
$order = PlatformOrder::query()->find($orderRow->id);
|
||||
if ($order) {
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
data_set($meta, 'subscription_activation_error', [
|
||||
'message' => $reason,
|
||||
'at' => now()->toDateTimeString(),
|
||||
'admin_id' => $admin->id,
|
||||
]);
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$msg = '批量同步订阅完成:成功 ' . $success . ' 条,失败 ' . $failed . ' 条(命中 ' . $matchedTotal . ' 条,本次处理 ' . $processed . ' 条,limit=' . $limit . ')';
|
||||
|
||||
if ($failed > 0 && count($failedReasonCounts) > 0) {
|
||||
arsort($failedReasonCounts);
|
||||
$top = array_slice($failedReasonCounts, 0, 3, true);
|
||||
$topText = collect($top)->map(function ($cnt, $reason) {
|
||||
$reason = mb_substr((string) $reason, 0, 60);
|
||||
return $reason . '(' . $cnt . ')';
|
||||
})->implode(';');
|
||||
|
||||
$msg .= ';失败原因Top:' . $topText;
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', $msg);
|
||||
}
|
||||
|
||||
public function clearSyncErrors(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
// 支持两种模式:
|
||||
// - scope=all(默认):清理所有订单的失败标记
|
||||
// - scope=filtered:仅清理当前筛选结果命中的订单(更安全)
|
||||
$scope = (string) $request->input('scope', 'all');
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->input('status', '')),
|
||||
'payment_status' => trim((string) $request->input('payment_status', '')),
|
||||
'merchant_id' => trim((string) $request->input('merchant_id', '')),
|
||||
'plan_id' => trim((string) $request->input('plan_id', '')),
|
||||
'fail_only' => (string) $request->input('fail_only', ''),
|
||||
'synced_only' => (string) $request->input('synced_only', ''),
|
||||
'sync_status' => trim((string) $request->input('sync_status', '')),
|
||||
'syncable_only' => (string) $request->input('syncable_only', ''),
|
||||
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
||||
];
|
||||
|
||||
$query = PlatformOrder::query()
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL");
|
||||
|
||||
if ($scope === 'filtered') {
|
||||
$query = $this->applyFilters($query, $filters);
|
||||
}
|
||||
|
||||
$orders = $query->get(['id', 'meta']);
|
||||
$matched = $orders->count();
|
||||
|
||||
$cleared = 0;
|
||||
foreach ($orders as $order) {
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
if (! data_get($meta, 'subscription_activation_error')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
data_forget($meta, 'subscription_activation_error');
|
||||
|
||||
// 轻量审计:记录清理动作(不做独立表,先落 meta,便于排查)
|
||||
$audit = (array) (data_get($meta, 'audit', []) ?? []);
|
||||
$audit[] = [
|
||||
'action' => 'clear_sync_error',
|
||||
'scope' => $scope,
|
||||
'at' => now()->toDateTimeString(),
|
||||
'admin_id' => $this->platformAdminId($request),
|
||||
];
|
||||
data_set($meta, 'audit', $audit);
|
||||
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
$msg = $scope === 'filtered'
|
||||
? '已清除当前筛选范围内的同步失败标记:'
|
||||
: '已清除全部订单的同步失败标记:';
|
||||
|
||||
return redirect()->back()->with('success', $msg . $cleared . ' 条(命中 ' . $matched . ' 条)');
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status']))
|
||||
->when($filters['payment_status'] !== '', fn (Builder $builder) => $builder->where('payment_status', $filters['payment_status']))
|
||||
->when(($filters['merchant_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('merchant_id', (int) $filters['merchant_id']))
|
||||
->when(($filters['plan_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('plan_id', (int) $filters['plan_id']))
|
||||
->when(($filters['fail_only'] ?? '') !== '', function (Builder $builder) {
|
||||
// 只看同步失败:meta.subscription_activation_error.message 存在即视为失败
|
||||
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL");
|
||||
})
|
||||
->when(($filters['synced_only'] ?? '') !== '', function (Builder $builder) {
|
||||
// 只看已同步:meta.subscription_activation.subscription_id 存在即视为已同步
|
||||
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL");
|
||||
})
|
||||
->when(($filters['sync_status'] ?? '') !== '', function (Builder $builder) use ($filters) {
|
||||
// 同步状态筛选:unsynced / synced / failed
|
||||
if (($filters['sync_status'] ?? '') === 'synced') {
|
||||
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL")
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL");
|
||||
} elseif (($filters['sync_status'] ?? '') === 'failed') {
|
||||
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL");
|
||||
} elseif (($filters['sync_status'] ?? '') === 'unsynced') {
|
||||
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL");
|
||||
}
|
||||
})
|
||||
->when(($filters['syncable_only'] ?? '') !== '', function (Builder $builder) {
|
||||
// 只看可同步:已支付 + 已生效 + 尚未写入 subscription_activation.subscription_id
|
||||
$builder->where('payment_status', 'paid')
|
||||
->where('status', 'activated')
|
||||
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL");
|
||||
})
|
||||
->when(($filters['batch_synced_24h'] ?? '') !== '', function (Builder $builder) {
|
||||
// 只看最近 24 小时批量同步过的订单(基于 meta.batch_activation.at)
|
||||
$since = now()->subHours(24)->format('Y-m-d H:i:s');
|
||||
|
||||
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') IS NOT NULL");
|
||||
|
||||
// sqlite 测试库没有 JSON_UNQUOTE(),需要做兼容
|
||||
$driver = $builder->getQuery()->getConnection()->getDriverName();
|
||||
if ($driver === 'sqlite') {
|
||||
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') >= ?", [$since]);
|
||||
} else {
|
||||
$builder->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_activation.at')) >= ?", [$since]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'pending' => '待处理',
|
||||
'paid' => '已支付',
|
||||
'activated' => '已生效',
|
||||
'cancelled' => '已取消',
|
||||
'refunded' => '已退款',
|
||||
];
|
||||
}
|
||||
|
||||
protected function paymentStatusLabels(): array
|
||||
{
|
||||
return [
|
||||
'unpaid' => '未支付',
|
||||
'paid' => '已支付',
|
||||
'partially_refunded' => '部分退款',
|
||||
'refunded' => '已退款',
|
||||
'failed' => '支付失败',
|
||||
];
|
||||
}
|
||||
}
|
||||
184
app/Http/Controllers/Admin/PlatformSettingController.php
Normal file
184
app/Http/Controllers/Admin/PlatformSettingController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ChannelConfig;
|
||||
use App\Models\PaymentConfig;
|
||||
use App\Models\SystemConfig;
|
||||
use App\Models\Merchant;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PlatformSettingController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
public function system(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$configs = Cache::remember(
|
||||
CacheKeys::platformSystemConfigs(),
|
||||
now()->addMinutes(10),
|
||||
fn () => SystemConfig::query()
|
||||
->orderBy('group')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
);
|
||||
|
||||
return view('admin.settings.system', [
|
||||
'systemSettings' => $configs,
|
||||
'groupedCount' => $configs->groupBy('group')->count(),
|
||||
'valueTypeOptions' => ['string', 'boolean', 'number', 'json'],
|
||||
'editingConfigId' => (int) session('editing_config_id', 0),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSystem(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$config = SystemConfig::query()->findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'config_name' => ['required', 'string'],
|
||||
'config_value' => ['nullable', 'string'],
|
||||
'group' => ['required', 'string'],
|
||||
'value_type' => ['required', 'string'],
|
||||
'autoload' => ['nullable', 'boolean'],
|
||||
'remark' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$normalizedValue = match ($data['value_type']) {
|
||||
'boolean' => in_array(strtolower((string) ($data['config_value'] ?? '')), ['1', 'true', 'yes', 'on'], true) ? '1' : '0',
|
||||
'number' => (string) (is_numeric($data['config_value'] ?? null) ? $data['config_value'] : 0),
|
||||
'json' => $this->normalizeJsonConfigValue($data['config_value'] ?? null),
|
||||
default => $data['config_value'] ?? null,
|
||||
};
|
||||
} catch (ValidationException $exception) {
|
||||
return redirect('/admin/settings/system')
|
||||
->withErrors($exception->errors())
|
||||
->withInput()
|
||||
->with('editing_config_id', $config->id);
|
||||
}
|
||||
|
||||
$config->update([
|
||||
'config_name' => $data['config_name'],
|
||||
'config_value' => $normalizedValue,
|
||||
'group' => $data['group'],
|
||||
'value_type' => $data['value_type'],
|
||||
'autoload' => (bool) ($data['autoload'] ?? false),
|
||||
'remark' => $data['remark'] ?? null,
|
||||
]);
|
||||
|
||||
Cache::forget(CacheKeys::platformSystemConfigs());
|
||||
|
||||
return redirect('/admin/settings/system')->with('success', '系统配置更新成功');
|
||||
}
|
||||
|
||||
public function channels(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$payload = Cache::remember(
|
||||
CacheKeys::platformChannelsOverview(),
|
||||
now()->addMinutes(10),
|
||||
fn () => [
|
||||
'channels' => ChannelConfig::query()
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get(),
|
||||
'paymentConfigs' => PaymentConfig::query()->orderBy('id')->get(),
|
||||
'merchantCount' => Merchant::count(),
|
||||
]
|
||||
);
|
||||
|
||||
return view('admin.settings.channels', $payload);
|
||||
}
|
||||
|
||||
public function updateChannel(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$channel = ChannelConfig::query()->findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'channel_name' => ['required', 'string'],
|
||||
'channel_type' => ['required', 'string'],
|
||||
'status' => ['required', 'string'],
|
||||
'entry_path' => ['nullable', 'string'],
|
||||
'supports_login' => ['nullable', 'boolean'],
|
||||
'supports_payment' => ['nullable', 'boolean'],
|
||||
'supports_share' => ['nullable', 'boolean'],
|
||||
'remark' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$channel->update([
|
||||
'channel_name' => $data['channel_name'],
|
||||
'channel_type' => $data['channel_type'],
|
||||
'status' => $data['status'],
|
||||
'entry_path' => $data['entry_path'] ?? null,
|
||||
'supports_login' => (bool) ($data['supports_login'] ?? false),
|
||||
'supports_payment' => (bool) ($data['supports_payment'] ?? false),
|
||||
'supports_share' => (bool) ($data['supports_share'] ?? false),
|
||||
'remark' => $data['remark'] ?? null,
|
||||
]);
|
||||
|
||||
Cache::forget(CacheKeys::platformChannelsOverview());
|
||||
|
||||
return redirect('/admin/settings/channels')->with('success', '渠道配置更新成功');
|
||||
}
|
||||
|
||||
public function updatePayment(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$payment = PaymentConfig::query()->findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'payment_name' => ['required', 'string'],
|
||||
'provider' => ['required', 'string'],
|
||||
'status' => ['required', 'string'],
|
||||
'is_sandbox' => ['nullable', 'boolean'],
|
||||
'supports_refund' => ['nullable', 'boolean'],
|
||||
'remark' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$payment->update([
|
||||
'payment_name' => $data['payment_name'],
|
||||
'provider' => $data['provider'],
|
||||
'status' => $data['status'],
|
||||
'is_sandbox' => (bool) ($data['is_sandbox'] ?? false),
|
||||
'supports_refund' => (bool) ($data['supports_refund'] ?? false),
|
||||
'remark' => $data['remark'] ?? null,
|
||||
]);
|
||||
|
||||
Cache::forget(CacheKeys::platformChannelsOverview());
|
||||
|
||||
return redirect('/admin/settings/channels')->with('success', '支付配置更新成功');
|
||||
}
|
||||
|
||||
protected function normalizeJsonConfigValue(?string $value): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw ValidationException::withMessages([
|
||||
'config_value' => 'JSON 配置值格式不正确,请检查后重试。',
|
||||
]);
|
||||
}
|
||||
|
||||
return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
}
|
||||
124
app/Http/Controllers/Admin/ProductCategoryController.php
Normal file
124
app/Http/Controllers/Admin/ProductCategoryController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ProductCategory;
|
||||
use App\Models\Merchant;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$page = max((int) $request->integer('page', 1), 1);
|
||||
|
||||
return view('admin.product_categories.index', [
|
||||
'categories' => Cache::remember(
|
||||
CacheKeys::platformCategoriesList($page),
|
||||
now()->addMinutes(10),
|
||||
fn () => ProductCategory::query()->with('merchant')->orderBy('merchant_id')->orderBy('sort')->orderBy('id')->paginate(10)->withQueryString()
|
||||
),
|
||||
'merchants' => Merchant::query()->orderBy('id')->get(),
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'merchant_id' => ['required', 'integer'],
|
||||
'name' => ['required', 'string'],
|
||||
'slug' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $data['merchant_id'] ?? $request->input('merchant_id'))),
|
||||
],
|
||||
'status' => ['nullable', 'string'],
|
||||
'sort' => ['nullable', 'integer'],
|
||||
'description' => ['nullable', 'string'],
|
||||
], [
|
||||
'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。',
|
||||
]);
|
||||
|
||||
ProductCategory::query()->create([
|
||||
'merchant_id' => $data['merchant_id'],
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'],
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'sort' => $data['sort'] ?? 0,
|
||||
'description' => $data['description'] ?? null,
|
||||
]);
|
||||
|
||||
$this->flushPlatformCaches();
|
||||
|
||||
return redirect('/admin/product-categories')->with('success', '商品分类创建成功');
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$category = ProductCategory::query()->findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string'],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $category->merchant_id))->ignore($category->id),
|
||||
],
|
||||
'status' => ['required', 'string'],
|
||||
'sort' => ['nullable', 'integer'],
|
||||
'description' => ['nullable', 'string'],
|
||||
], [
|
||||
'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。',
|
||||
]);
|
||||
$category->update([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? $category->slug,
|
||||
'status' => $data['status'],
|
||||
'sort' => $data['sort'] ?? 0,
|
||||
'description' => $data['description'] ?? null,
|
||||
]);
|
||||
|
||||
$this->flushPlatformCaches();
|
||||
|
||||
return redirect('/admin/product-categories')->with('success', '商品分类更新成功');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$category = ProductCategory::query()->findOrFail($id);
|
||||
$category->delete();
|
||||
|
||||
$this->flushPlatformCaches();
|
||||
|
||||
return redirect('/admin/product-categories')->with('success', '商品分类删除成功');
|
||||
}
|
||||
|
||||
protected function flushPlatformCaches(): void
|
||||
{
|
||||
for ($page = 1; $page <= 5; $page++) {
|
||||
Cache::forget(CacheKeys::platformProductsList($page));
|
||||
Cache::forget(CacheKeys::platformCategoriesList($page));
|
||||
}
|
||||
}
|
||||
}
|
||||
1366
app/Http/Controllers/Admin/ProductController.php
Normal file
1366
app/Http/Controllers/Admin/ProductController.php
Normal file
File diff suppressed because it is too large
Load Diff
196
app/Http/Controllers/Admin/SiteSubscriptionController.php
Normal file
196
app/Http/Controllers/Admin/SiteSubscriptionController.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSubscription;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SiteSubscriptionController extends Controller
|
||||
{
|
||||
use ResolvesPlatformAdminContext;
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->query('status', '')),
|
||||
'keyword' => trim((string) $request->query('keyword', '')),
|
||||
'merchant_id' => trim((string) $request->query('merchant_id', '')),
|
||||
'plan_id' => trim((string) $request->query('plan_id', '')),
|
||||
'expiry' => trim((string) $request->query('expiry', '')),
|
||||
];
|
||||
|
||||
$query = $this->applyFilters(
|
||||
SiteSubscription::query()->with(['merchant', 'plan']),
|
||||
$filters
|
||||
)->orderBy('id');
|
||||
|
||||
$filename = 'site_subscriptions_' . now()->format('Ymd_His') . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($query) {
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// UTF-8 BOM,避免 Excel 打开中文乱码
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($out, [
|
||||
'ID',
|
||||
'订阅号',
|
||||
'站点',
|
||||
'套餐',
|
||||
'状态',
|
||||
'计费周期',
|
||||
'周期(月)',
|
||||
'金额',
|
||||
'开始时间',
|
||||
'到期时间',
|
||||
'到期状态',
|
||||
'试用到期',
|
||||
'生效时间',
|
||||
'取消时间',
|
||||
]);
|
||||
|
||||
$statusLabels = $this->statusLabels();
|
||||
|
||||
$query->chunkById(500, function ($subs) use ($out, $statusLabels) {
|
||||
foreach ($subs as $sub) {
|
||||
$endsAt = $sub->ends_at;
|
||||
$expiryLabel = '无到期';
|
||||
if ($endsAt) {
|
||||
if ($endsAt->lt(now())) {
|
||||
$expiryLabel = '已过期';
|
||||
} elseif ($endsAt->lt(now()->addDays(7))) {
|
||||
$expiryLabel = '7天内到期';
|
||||
} else {
|
||||
$expiryLabel = '未到期';
|
||||
}
|
||||
}
|
||||
|
||||
$status = (string) ($sub->status ?? '');
|
||||
$statusText = ($statusLabels[$status] ?? $status);
|
||||
$statusText = $statusText . ' (' . $status . ')';
|
||||
|
||||
fputcsv($out, [
|
||||
$sub->id,
|
||||
$sub->subscription_no,
|
||||
$sub->merchant?->name ?? '',
|
||||
$sub->plan_name ?: ($sub->plan?->name ?? ''),
|
||||
$statusText,
|
||||
$sub->billing_cycle ?: '',
|
||||
(int) $sub->period_months,
|
||||
(float) $sub->amount,
|
||||
optional($sub->starts_at)->format('Y-m-d H:i:s') ?: '',
|
||||
optional($sub->ends_at)->format('Y-m-d H:i:s') ?: '',
|
||||
$expiryLabel,
|
||||
optional($sub->trial_ends_at)->format('Y-m-d H:i:s') ?: '',
|
||||
optional($sub->activated_at)->format('Y-m-d H:i:s') ?: '',
|
||||
optional($sub->cancelled_at)->format('Y-m-d H:i:s') ?: '',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($out);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensurePlatformAdmin($request);
|
||||
|
||||
$filters = [
|
||||
'status' => trim((string) $request->query('status', '')),
|
||||
'keyword' => trim((string) $request->query('keyword', '')),
|
||||
'merchant_id' => trim((string) $request->query('merchant_id', '')),
|
||||
'plan_id' => trim((string) $request->query('plan_id', '')),
|
||||
// 到期辅助筛选(不改变 status 字段,仅按 ends_at 计算)
|
||||
// - expired:已过期(ends_at < now)
|
||||
// - expiring_7d:7 天内到期(now <= ends_at < now+7d)
|
||||
'expiry' => trim((string) $request->query('expiry', '')),
|
||||
];
|
||||
|
||||
$query = $this->applyFilters(
|
||||
SiteSubscription::query()->with(['merchant', 'plan']),
|
||||
$filters
|
||||
);
|
||||
|
||||
$subscriptions = (clone $query)
|
||||
->latest('id')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
$baseQuery = $this->applyFilters(SiteSubscription::query(), $filters);
|
||||
|
||||
return view('admin.site_subscriptions.index', [
|
||||
'subscriptions' => $subscriptions,
|
||||
'filters' => $filters,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statusLabels(),
|
||||
],
|
||||
'merchants' => SiteSubscription::query()->with('merchant')->select('merchant_id')->distinct()->get()->pluck('merchant')->filter()->unique('id')->values(),
|
||||
'plans' => SiteSubscription::query()->with('plan')->select('plan_id')->whereNotNull('plan_id')->distinct()->get()->pluck('plan')->filter()->unique('id')->values(),
|
||||
'summaryStats' => [
|
||||
'total_subscriptions' => (clone $baseQuery)->count(),
|
||||
'activated_subscriptions' => (clone $baseQuery)->where('status', 'activated')->count(),
|
||||
'pending_subscriptions' => (clone $baseQuery)->where('status', 'pending')->count(),
|
||||
'cancelled_subscriptions' => (clone $baseQuery)->where('status', 'cancelled')->count(),
|
||||
// 可治理辅助指标:按 ends_at 计算
|
||||
'expired_subscriptions' => (clone $baseQuery)
|
||||
->whereNotNull('ends_at')
|
||||
->where('ends_at', '<', now())
|
||||
->count(),
|
||||
'expiring_7d_subscriptions' => (clone $baseQuery)
|
||||
->whereNotNull('ends_at')
|
||||
->where('ends_at', '>=', now())
|
||||
->where('ends_at', '<', now()->addDays(7))
|
||||
->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'pending' => '待生效',
|
||||
'activated' => '已生效',
|
||||
'cancelled' => '已取消',
|
||||
'expired' => '已过期',
|
||||
];
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status']))
|
||||
->when(($filters['merchant_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('merchant_id', (int) $filters['merchant_id']))
|
||||
->when(($filters['plan_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('plan_id', (int) $filters['plan_id']))
|
||||
->when(($filters['expiry'] ?? '') !== '', function (Builder $builder) use ($filters) {
|
||||
$expiry = (string) ($filters['expiry'] ?? '');
|
||||
if ($expiry === 'expired') {
|
||||
$builder->whereNotNull('ends_at')->where('ends_at', '<', now());
|
||||
} elseif ($expiry === 'expiring_7d') {
|
||||
$builder->whereNotNull('ends_at')
|
||||
->where('ends_at', '>=', now())
|
||||
->where('ends_at', '<', now()->addDays(7));
|
||||
}
|
||||
})
|
||||
->when($filters['keyword'] !== '', function (Builder $builder) use ($filters) {
|
||||
$keyword = $filters['keyword'];
|
||||
|
||||
$builder->where(function (Builder $subQuery) use ($keyword) {
|
||||
$subQuery->where('subscription_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('plan_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('billing_cycle', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
79
app/Http/Controllers/Api/V1/AuthController.php
Normal file
79
app/Http/Controllers/Api/V1/AuthController.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OauthAccount;
|
||||
use App\Models\Merchant;
|
||||
use App\Models\User;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'source' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$user = User::query()->where('email', $data['email'])->first();
|
||||
if (! $user || ! Hash::check($data['password'], $user->password)) {
|
||||
return ApiResponse::error('账号或密码错误', 1001, null, 422);
|
||||
}
|
||||
|
||||
$source = $data['source'] ?? 'pc';
|
||||
$user->forceFill(['last_login_source' => $source])->save();
|
||||
|
||||
return ApiResponse::success([
|
||||
'token' => base64_encode($user->id . '|' . now()->timestamp . '|demo'),
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'source' => $source,
|
||||
],
|
||||
], '登录成功');
|
||||
}
|
||||
|
||||
public function wechatPlaceholder(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'platform' => ['required', 'in:wechat_mp,wechat_mini,app'],
|
||||
'openid' => ['nullable', 'string'],
|
||||
'unionid' => ['nullable', 'string'],
|
||||
'nickname' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$merchant = Merchant::query()->first();
|
||||
$user = User::query()->first();
|
||||
|
||||
if ($user && ($data['openid'] ?? null)) {
|
||||
OauthAccount::query()->updateOrCreate(
|
||||
[
|
||||
'provider' => $data['platform'],
|
||||
'openid' => $data['openid'],
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'merchant_id' => $merchant?->id,
|
||||
'platform' => $data['platform'],
|
||||
'provider' => $data['platform'],
|
||||
'unionid' => $data['unionid'] ?? null,
|
||||
'nickname' => $data['nickname'] ?? null,
|
||||
'raw_payload' => $data,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse::success([
|
||||
'platform' => $data['platform'],
|
||||
'next_step' => '待接入真实微信 / App 登录流程',
|
||||
'received' => $data,
|
||||
], '渠道登录占位已预留');
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Api/V1/OrderController.php
Normal file
57
app/Http/Controllers/Api/V1/OrderController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$orders = Order::query()->latest()->get();
|
||||
return ApiResponse::success($orders, '订单列表获取成功');
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'merchant_id' => ['required', 'integer'],
|
||||
'user_id' => ['nullable', 'integer'],
|
||||
'buyer_name' => ['nullable', 'string'],
|
||||
'buyer_phone' => ['nullable', 'string'],
|
||||
'buyer_email' => ['nullable', 'email'],
|
||||
'platform' => ['nullable', 'string'],
|
||||
'payment_channel' => ['nullable', 'string'],
|
||||
'product_amount' => ['required', 'numeric'],
|
||||
'discount_amount' => ['nullable', 'numeric'],
|
||||
'shipping_amount' => ['nullable', 'numeric'],
|
||||
'pay_amount' => ['required', 'numeric'],
|
||||
'remark' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$order = Order::query()->create([
|
||||
'merchant_id' => $data['merchant_id'],
|
||||
'user_id' => $data['user_id'] ?? null,
|
||||
'order_no' => 'ORD' . now()->format('YmdHis') . random_int(1000, 9999),
|
||||
'status' => 'pending',
|
||||
'platform' => $data['platform'] ?? 'h5',
|
||||
'payment_channel' => $data['payment_channel'] ?? 'wechat_pay',
|
||||
'payment_status' => 'unpaid',
|
||||
'device_type' => $data['platform'] ?? 'h5',
|
||||
'product_amount' => $data['product_amount'],
|
||||
'discount_amount' => $data['discount_amount'] ?? 0,
|
||||
'shipping_amount' => $data['shipping_amount'] ?? 0,
|
||||
'pay_amount' => $data['pay_amount'],
|
||||
'buyer_name' => $data['buyer_name'] ?? null,
|
||||
'buyer_phone' => $data['buyer_phone'] ?? null,
|
||||
'buyer_email' => $data['buyer_email'] ?? null,
|
||||
'remark' => $data['remark'] ?? null,
|
||||
]);
|
||||
|
||||
return ApiResponse::success($order, '订单创建成功');
|
||||
}
|
||||
}
|
||||
31
app/Http/Controllers/Api/V1/ProductController.php
Normal file
31
app/Http/Controllers/Api/V1/ProductController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$products = Product::query()
|
||||
->select(['id', 'merchant_id', 'title', 'slug', 'sku', 'summary', 'price', 'original_price', 'stock', 'status'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return ApiResponse::success($products, '商品列表获取成功');
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$product = Product::query()->find($id);
|
||||
if (! $product) {
|
||||
return ApiResponse::error('商品不存在', 404, null, 404);
|
||||
}
|
||||
|
||||
return ApiResponse::success($product, '商品详情获取成功');
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Api/V1/SystemController.php
Normal file
34
app/Http/Controllers/Api/V1/SystemController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SystemController extends Controller
|
||||
{
|
||||
public function ping(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'SaaSShop API is alive',
|
||||
'time' => now()->toDateTimeString(),
|
||||
'version' => 'v1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function platforms(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'platforms' => [
|
||||
['key' => 'pc', 'name' => 'PC 端', 'status' => 'ready'],
|
||||
['key' => 'h5', 'name' => 'H5', 'status' => 'ready'],
|
||||
['key' => 'wechat_mp', 'name' => '微信公众号', 'status' => 'reserved'],
|
||||
['key' => 'wechat_mini', 'name' => '微信小程序', 'status' => 'reserved'],
|
||||
['key' => 'app', 'name' => 'APP', 'status' => 'reserved'],
|
||||
],
|
||||
'api_prefix' => '/api/v1',
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/Concerns/ResolvesMerchantContext.php
Normal file
25
app/Http/Controllers/Concerns/ResolvesMerchantContext.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Merchant;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesMerchantContext
|
||||
{
|
||||
protected function merchantId(Request $request): int
|
||||
{
|
||||
return (int) $request->session()->get('admin_merchant_id');
|
||||
}
|
||||
|
||||
protected function merchant(Request $request): Merchant
|
||||
{
|
||||
return Merchant::query()->findOrFail($this->merchantId($request));
|
||||
}
|
||||
|
||||
protected function merchantAdmin(Request $request): Admin
|
||||
{
|
||||
return Admin::query()->findOrFail((int) $request->session()->get('admin_id'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesPlatformAdminContext
|
||||
{
|
||||
protected function platformAdmin(Request $request): ?Admin
|
||||
{
|
||||
$adminId = $request->session()->get('admin_id');
|
||||
|
||||
if (! $adminId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$admin = Admin::query()->find($adminId);
|
||||
|
||||
return $admin && $admin->isPlatformAdmin() ? $admin : null;
|
||||
}
|
||||
|
||||
protected function platformAdminId(Request $request): ?int
|
||||
{
|
||||
return $this->platformAdmin($request)?->id;
|
||||
}
|
||||
|
||||
protected function ensurePlatformAdmin(Request $request): Admin
|
||||
{
|
||||
$admin = $this->platformAdmin($request);
|
||||
|
||||
abort_unless($admin, 403, '当前账号没有总台管理访问权限');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/Concerns/ResolvesSiteContext.php
Normal file
25
app/Http/Controllers/Concerns/ResolvesSiteContext.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Merchant;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesSiteContext
|
||||
{
|
||||
protected function siteId(Request $request): int
|
||||
{
|
||||
return (int) ($request->session()->get('admin_site_id') ?: $request->session()->get('admin_merchant_id'));
|
||||
}
|
||||
|
||||
protected function site(Request $request): Merchant
|
||||
{
|
||||
return Merchant::query()->findOrFail($this->siteId($request));
|
||||
}
|
||||
|
||||
protected function siteAdmin(Request $request): Admin
|
||||
{
|
||||
return Admin::query()->findOrFail((int) $request->session()->get('admin_id'));
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
17
app/Http/Controllers/Front/H5Controller.php
Normal file
17
app/Http/Controllers/Front/H5Controller.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Front;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class H5Controller extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('front.h5.index', [
|
||||
'products' => Product::query()->latest()->limit(8)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
app/Http/Controllers/Front/PcController.php
Normal file
17
app/Http/Controllers/Front/PcController.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Front;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PcController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('front.pc.index', [
|
||||
'products' => Product::query()->latest()->limit(8)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/HomeController.php
Normal file
27
app/Http/Controllers/HomeController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Models\Merchant;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('home', [
|
||||
'merchantCount' => Merchant::count(),
|
||||
'productCount' => Product::count(),
|
||||
'orderCount' => Order::count(),
|
||||
'platforms' => [
|
||||
['name' => 'PC 端', 'path' => '/pc', 'status' => 'ready'],
|
||||
['name' => 'H5', 'path' => '/h5', 'status' => 'ready'],
|
||||
['name' => '微信公众号', 'path' => '/wechat/mp', 'status' => 'reserved'],
|
||||
['name' => '微信小程序', 'path' => '/wechat/mini', 'status' => 'reserved'],
|
||||
['name' => 'APP 接口层', 'path' => '/api/v1/platforms', 'status' => 'reserved'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/MerchantAdmin/AuthController.php
Normal file
58
app/Http/Controllers/MerchantAdmin/AuthController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\MerchantAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesMerchantContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use ResolvesMerchantContext;
|
||||
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('merchant_admin.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->where('email', $data['email'])->first();
|
||||
if (! $admin || ! Hash::check($data['password'], $admin->password)) {
|
||||
return back()->withErrors(['email' => '账号或密码错误'])->withInput();
|
||||
}
|
||||
|
||||
if (! $admin->isMerchantAdmin()) {
|
||||
return back()->withErrors(['email' => '当前账号不是商家管理员,不能登录商家后台'])->withInput();
|
||||
}
|
||||
|
||||
$merchantId = $admin->merchantId();
|
||||
|
||||
$request->session()->put('admin_id', $admin->id);
|
||||
$request->session()->put('admin_name', $admin->name);
|
||||
$request->session()->put('admin_email', $admin->email);
|
||||
$request->session()->put('admin_role', $admin->role);
|
||||
$request->session()->put('admin_merchant_id', $merchantId);
|
||||
$request->session()->put('admin_scope', 'merchant');
|
||||
$request->session()->put('merchant_name', $admin->merchant?->name);
|
||||
|
||||
$admin->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return redirect('/merchant-admin');
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->forget(['admin_id', 'admin_name', 'admin_email', 'admin_role', 'admin_merchant_id', 'admin_scope', 'merchant_name']);
|
||||
return redirect('/merchant-admin/login');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/MerchantAdmin/DashboardController.php
Normal file
44
app/Http/Controllers/MerchantAdmin/DashboardController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\MerchantAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesMerchantContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ResolvesMerchantContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
$merchant = $this->merchant($request);
|
||||
|
||||
$stats = Cache::remember(
|
||||
CacheKeys::merchantDashboardStats($merchantId),
|
||||
now()->addMinutes(10),
|
||||
fn () => [
|
||||
'users' => User::query()->forMerchant($merchantId)->count(),
|
||||
'products' => Product::query()->forMerchant($merchantId)->count(),
|
||||
'orders' => Order::query()->forMerchant($merchantId)->count(),
|
||||
'pending_orders' => Order::query()->forMerchant($merchantId)->where('status', 'pending')->count(),
|
||||
]
|
||||
);
|
||||
|
||||
return view('merchant_admin.dashboard', [
|
||||
'merchant' => $merchant,
|
||||
'stats' => $stats,
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
864
app/Http/Controllers/MerchantAdmin/OrderController.php
Normal file
864
app/Http/Controllers/MerchantAdmin/OrderController.php
Normal file
@@ -0,0 +1,864 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\MerchantAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesMerchantContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
use ResolvesMerchantContext;
|
||||
|
||||
protected array $statuses = ['pending', 'paid', 'shipped', 'completed', 'cancelled'];
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
$page = max((int) $request->integer('page', 1), 1);
|
||||
|
||||
$filters = $this->filters($request);
|
||||
$statusStatsFilters = $filters;
|
||||
$statusStatsFilters['status'] = '';
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return view('merchant_admin.orders.index', [
|
||||
'orders' => Order::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(),
|
||||
'statusStats' => $this->emptyStatusStats(),
|
||||
'summaryStats' => $this->emptySummaryStats(),
|
||||
'trendStats' => $this->emptyTrendStats(),
|
||||
'operationsFocus' => $this->buildOperationsFocus($merchantId, $this->emptySummaryStats(), $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'filters' => $filters,
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statuses,
|
||||
'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'],
|
||||
'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'],
|
||||
'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'],
|
||||
'paymentChannels' => ['wechat_pay', 'alipay'],
|
||||
'timeRanges' => [
|
||||
'all' => '全部时间',
|
||||
'today' => '今天',
|
||||
'last_7_days' => '近7天',
|
||||
],
|
||||
'sortOptions' => [
|
||||
'latest' => '创建时间倒序',
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
'product_amount_desc' => '商品金额从高到低',
|
||||
'product_amount_asc' => '商品金额从低到高',
|
||||
],
|
||||
],
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
'platformLabels' => $this->platformLabels(),
|
||||
'deviceTypeLabels' => $this->deviceTypeLabels(),
|
||||
'paymentChannelLabels' => $this->paymentChannelLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
$summaryStats = Cache::remember(
|
||||
CacheKeys::merchantOrdersSummary($merchantId, $statusStatsFilters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->buildSummaryStats($this->applyFilters(Order::query()->forMerchant($merchantId), $statusStatsFilters))
|
||||
);
|
||||
|
||||
return view('merchant_admin.orders.index', [
|
||||
'orders' => Cache::remember(
|
||||
CacheKeys::merchantOrdersList($merchantId, $page, $filters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->applySorting($this->applyFilters(Order::query()->forMerchant($merchantId), $filters), $filters)
|
||||
->paginate(10)
|
||||
->withQueryString()
|
||||
),
|
||||
'statusStats' => Cache::remember(
|
||||
CacheKeys::merchantOrdersStatusStats($merchantId, $statusStatsFilters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->buildStatusStats($this->applyFilters(Order::query()->forMerchant($merchantId), $statusStatsFilters))
|
||||
),
|
||||
'summaryStats' => $summaryStats,
|
||||
'operationsFocus' => $this->buildOperationsFocus($merchantId, $summaryStats, $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'trendStats' => Cache::remember(
|
||||
CacheKeys::merchantOrdersTrendSummary($merchantId, $statusStatsFilters),
|
||||
now()->addMinutes(10),
|
||||
fn () => $this->buildTrendStats($this->applyFilters(Order::query()->forMerchant($merchantId), $statusStatsFilters))
|
||||
),
|
||||
'filters' => $filters,
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statuses,
|
||||
'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'],
|
||||
'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'],
|
||||
'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'],
|
||||
'paymentChannels' => ['wechat_pay', 'alipay'],
|
||||
'timeRanges' => [
|
||||
'all' => '全部时间',
|
||||
'today' => '今天',
|
||||
'last_7_days' => '近7天',
|
||||
],
|
||||
'sortOptions' => [
|
||||
'latest' => '创建时间倒序',
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
'product_amount_desc' => '商品金额从高到低',
|
||||
'product_amount_asc' => '商品金额从低到高',
|
||||
],
|
||||
],
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
'platformLabels' => $this->platformLabels(),
|
||||
'deviceTypeLabels' => $this->deviceTypeLabels(),
|
||||
'paymentChannelLabels' => $this->paymentChannelLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): View
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
|
||||
return view('merchant_admin.orders.show', [
|
||||
'order' => Order::query()->with(['items.product', 'user'])->forMerchant($merchantId)->findOrFail($id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
$filters = $this->filters($request);
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return redirect('/merchant-admin/orders?' . http_build_query($this->exportableFilters($filters)))
|
||||
->withErrors($filters['validation_errors'] ?? ['订单筛选条件不合法,请先修正后再导出。']);
|
||||
}
|
||||
|
||||
$fileName = 'merchant_' . $merchantId . '_orders_' . now()->format('Ymd_His') . '.csv';
|
||||
$exportSummary = $this->buildSummaryStats(
|
||||
$this->applyFilters(Order::query()->forMerchant($merchantId), $filters)
|
||||
);
|
||||
|
||||
return response()->streamDownload(function () use ($merchantId, $filters, $exportSummary) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
foreach ($this->exportSummaryRows($filters, 'merchant', $merchantId) as $summaryRow) {
|
||||
fputcsv($handle, $summaryRow);
|
||||
}
|
||||
|
||||
fputcsv($handle, ['导出订单数', $exportSummary['total_orders'] ?? 0]);
|
||||
fputcsv($handle, ['导出实付总额', number_format((float) ($exportSummary['total_pay_amount'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, ['导出平均客单价', number_format((float) ($exportSummary['average_order_amount'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, ['导出已支付订单数', $exportSummary['paid_orders'] ?? 0]);
|
||||
fputcsv($handle, ['导出支付失败订单', $exportSummary['failed_payment_orders'] ?? 0]);
|
||||
fputcsv($handle, []);
|
||||
|
||||
fputcsv($handle, [
|
||||
'ID',
|
||||
'用户ID',
|
||||
'订单号',
|
||||
'订单状态',
|
||||
'支付状态',
|
||||
'平台',
|
||||
'设备类型',
|
||||
'支付渠道',
|
||||
'买家姓名',
|
||||
'买家手机',
|
||||
'买家邮箱',
|
||||
'商品金额',
|
||||
'优惠金额',
|
||||
'运费',
|
||||
'实付金额',
|
||||
'商品行数',
|
||||
'商品件数',
|
||||
'商品摘要',
|
||||
'创建时间',
|
||||
'支付时间',
|
||||
'发货时间',
|
||||
'完成时间',
|
||||
'备注',
|
||||
]);
|
||||
|
||||
foreach ($this->applySorting($this->applyFilters(Order::query()->with('items')->forMerchant($merchantId), $filters), $filters)->cursor() as $order) {
|
||||
$itemCount = $order->items->count();
|
||||
$totalQuantity = (int) $order->items->sum('quantity');
|
||||
$itemSummary = $order->items
|
||||
->map(fn ($item) => trim(($item->product_title ?? '商品') . ' x' . ((int) $item->quantity)))
|
||||
->implode(' | ');
|
||||
|
||||
fputcsv($handle, [
|
||||
$order->id,
|
||||
$order->user_id,
|
||||
$order->order_no,
|
||||
$this->statusLabel((string) $order->status),
|
||||
$this->paymentStatusLabel((string) $order->payment_status),
|
||||
$this->platformLabel((string) $order->platform),
|
||||
$this->deviceTypeLabel((string) $order->device_type),
|
||||
$this->paymentChannelLabel((string) $order->payment_channel),
|
||||
$order->buyer_name,
|
||||
$order->buyer_phone,
|
||||
$order->buyer_email,
|
||||
number_format((float) $order->product_amount, 2, '.', ''),
|
||||
number_format((float) $order->discount_amount, 2, '.', ''),
|
||||
number_format((float) $order->shipping_amount, 2, '.', ''),
|
||||
number_format((float) $order->pay_amount, 2, '.', ''),
|
||||
$itemCount,
|
||||
$totalQuantity,
|
||||
$itemSummary,
|
||||
optional($order->created_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->paid_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->shipped_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->completed_at)?->format('Y-m-d H:i:s'),
|
||||
$order->remark,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'status' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$order = Order::query()->forMerchant($merchantId)->findOrFail($id);
|
||||
$order->update(['status' => $data['status']]);
|
||||
|
||||
Cache::add(CacheKeys::merchantOrdersVersion($merchantId), 1, now()->addDays(30));
|
||||
Cache::increment(CacheKeys::merchantOrdersVersion($merchantId));
|
||||
Cache::forget(CacheKeys::merchantDashboardStats($merchantId));
|
||||
|
||||
return redirect('/merchant-admin/orders')->with('success', '订单状态更新成功');
|
||||
}
|
||||
|
||||
protected function filters(Request $request): array
|
||||
{
|
||||
$timeRange = trim((string) $request->string('time_range', 'all'));
|
||||
$rawStartDate = trim((string) $request->string('start_date'));
|
||||
$rawEndDate = trim((string) $request->string('end_date'));
|
||||
$minPayAmount = trim((string) $request->string('min_pay_amount'));
|
||||
$maxPayAmount = trim((string) $request->string('max_pay_amount'));
|
||||
$validationErrors = [];
|
||||
|
||||
if ($timeRange === 'today') {
|
||||
$startDate = now()->toDateString();
|
||||
$endDate = now()->toDateString();
|
||||
} elseif ($timeRange === 'last_7_days') {
|
||||
$startDate = now()->subDays(6)->toDateString();
|
||||
$endDate = now()->toDateString();
|
||||
} else {
|
||||
$timeRange = 'all';
|
||||
$startDate = $rawStartDate;
|
||||
$endDate = $rawEndDate;
|
||||
}
|
||||
|
||||
if ($rawStartDate !== '' && ! $this->isValidDate($rawStartDate)) {
|
||||
$validationErrors[] = '开始日期格式不正确,请使用 YYYY-MM-DD。';
|
||||
}
|
||||
|
||||
if ($rawEndDate !== '' && ! $this->isValidDate($rawEndDate)) {
|
||||
$validationErrors[] = '结束日期格式不正确,请使用 YYYY-MM-DD。';
|
||||
}
|
||||
|
||||
if ($rawStartDate !== '' && $rawEndDate !== '' && $this->isValidDate($rawStartDate) && $this->isValidDate($rawEndDate) && $rawStartDate > $rawEndDate) {
|
||||
$validationErrors[] = '开始日期不能晚于结束日期。';
|
||||
}
|
||||
|
||||
if ($minPayAmount !== '' && ! is_numeric($minPayAmount)) {
|
||||
$validationErrors[] = '最低实付金额必须为数字。';
|
||||
}
|
||||
|
||||
if ($maxPayAmount !== '' && ! is_numeric($maxPayAmount)) {
|
||||
$validationErrors[] = '最高实付金额必须为数字。';
|
||||
}
|
||||
|
||||
if ($minPayAmount !== '' && $maxPayAmount !== '' && is_numeric($minPayAmount) && is_numeric($maxPayAmount) && (float) $minPayAmount > (float) $maxPayAmount) {
|
||||
$validationErrors[] = '最低实付金额不能大于最高实付金额。';
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => trim((string) $request->string('status')),
|
||||
'payment_status' => trim((string) $request->string('payment_status')),
|
||||
'platform' => trim((string) $request->string('platform')),
|
||||
'device_type' => trim((string) $request->string('device_type')),
|
||||
'payment_channel' => trim((string) $request->string('payment_channel')),
|
||||
'keyword' => trim((string) $request->string('keyword')),
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'min_pay_amount' => $minPayAmount,
|
||||
'max_pay_amount' => $maxPayAmount,
|
||||
'time_range' => $timeRange,
|
||||
'sort' => trim((string) $request->string('sort', 'latest')),
|
||||
'validation_errors' => $validationErrors,
|
||||
'has_validation_error' => ! empty($validationErrors),
|
||||
];
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status']))
|
||||
->when(($filters['payment_status'] ?? '') !== '', fn ($builder) => $builder->where('payment_status', $filters['payment_status']))
|
||||
->when(($filters['platform'] ?? '') !== '', fn ($builder) => $builder->where('platform', $filters['platform']))
|
||||
->when(($filters['device_type'] ?? '') !== '', fn ($builder) => $builder->where('device_type', $filters['device_type']))
|
||||
->when(($filters['payment_channel'] ?? '') !== '', fn ($builder) => $builder->where('payment_channel', $filters['payment_channel']))
|
||||
->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) {
|
||||
$subQuery->where('order_no', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_name', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_phone', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_email', 'like', '%' . $filters['keyword'] . '%');
|
||||
}))
|
||||
->when(($filters['start_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '>=', $filters['start_date']))
|
||||
->when(($filters['end_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '<=', $filters['end_date']))
|
||||
->when(($filters['min_pay_amount'] ?? '') !== '' && is_numeric($filters['min_pay_amount']), fn ($builder) => $builder->where('pay_amount', '>=', $filters['min_pay_amount']))
|
||||
->when(($filters['max_pay_amount'] ?? '') !== '' && is_numeric($filters['max_pay_amount']), fn ($builder) => $builder->where('pay_amount', '<=', $filters['max_pay_amount']));
|
||||
}
|
||||
|
||||
protected function buildStatusStats(Builder $query): array
|
||||
{
|
||||
$counts = (clone $query)
|
||||
->selectRaw('status, COUNT(*) as aggregate')
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status');
|
||||
|
||||
$stats = ['all' => (int) $counts->sum()];
|
||||
|
||||
foreach ($this->statuses as $status) {
|
||||
$stats[$status] = (int) ($counts[$status] ?? 0);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function applySorting(Builder $query, array $filters): Builder
|
||||
{
|
||||
return match ($filters['sort'] ?? 'latest') {
|
||||
'oldest' => $query->orderBy('created_at')->orderBy('id'),
|
||||
'pay_amount_desc' => $query->orderByDesc('pay_amount')->orderByDesc('id'),
|
||||
'pay_amount_asc' => $query->orderBy('pay_amount')->orderByDesc('id'),
|
||||
'product_amount_desc' => $query->orderByDesc('product_amount')->orderByDesc('id'),
|
||||
'product_amount_asc' => $query->orderBy('product_amount')->orderByDesc('id'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildSummaryStats(Builder $query): array
|
||||
{
|
||||
$summary = (clone $query)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(pay_amount), 0) as total_pay_amount')
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'unpaid' THEN pay_amount ELSE 0 END) as unpaid_pay_amount")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN pay_amount ELSE 0 END) as paid_pay_amount")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN 1 ELSE 0 END) as paid_orders")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'refunded' THEN 1 ELSE 0 END) as refunded_orders")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) as failed_payment_orders")
|
||||
->selectRaw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as pending_shipment_orders")
|
||||
->selectRaw("SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders")
|
||||
->selectRaw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_orders")
|
||||
->first();
|
||||
|
||||
$totalOrders = (int) ($summary->total_orders ?? 0);
|
||||
$totalPayAmount = (float) ($summary->total_pay_amount ?? 0);
|
||||
$paidOrders = (int) ($summary->paid_orders ?? 0);
|
||||
$refundedOrders = (int) ($summary->refunded_orders ?? 0);
|
||||
$completedOrders = (int) ($summary->completed_orders ?? 0);
|
||||
$cancelledOrders = (int) ($summary->cancelled_orders ?? 0);
|
||||
|
||||
return [
|
||||
'total_orders' => $totalOrders,
|
||||
'total_pay_amount' => $totalPayAmount,
|
||||
'unpaid_pay_amount' => (float) ($summary->unpaid_pay_amount ?? 0),
|
||||
'paid_pay_amount' => (float) ($summary->paid_pay_amount ?? 0),
|
||||
'paid_orders' => $paidOrders,
|
||||
'refunded_orders' => $refundedOrders,
|
||||
'failed_payment_orders' => (int) ($summary->failed_payment_orders ?? 0),
|
||||
'pending_shipment_orders' => (int) ($summary->pending_shipment_orders ?? 0),
|
||||
'completed_orders' => $completedOrders,
|
||||
'cancelled_orders' => $cancelledOrders,
|
||||
'average_order_amount' => $totalOrders > 0 ? round($totalPayAmount / $totalOrders, 2) : 0,
|
||||
'payment_rate' => $totalOrders > 0 ? round(($paidOrders / $totalOrders) * 100, 2) : 0,
|
||||
'refund_rate' => $paidOrders > 0 ? round(($refundedOrders / $paidOrders) * 100, 2) : 0,
|
||||
'completion_rate' => $totalOrders > 0 ? round(($completedOrders / $totalOrders) * 100, 2) : 0,
|
||||
'cancellation_rate' => $totalOrders > 0 ? round(($cancelledOrders / $totalOrders) * 100, 2) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildTrendStats(Builder $query): array
|
||||
{
|
||||
$todayStart = Carbon::today();
|
||||
$tomorrowStart = (clone $todayStart)->copy()->addDay();
|
||||
$last7DaysStart = (clone $todayStart)->copy()->subDays(6)->startOfDay();
|
||||
|
||||
$today = (clone $query)
|
||||
->where('created_at', '>=', $todayStart)
|
||||
->where('created_at', '<', $tomorrowStart)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount')
|
||||
->first();
|
||||
|
||||
$last7Days = (clone $query)
|
||||
->where('created_at', '>=', $last7DaysStart)
|
||||
->where('created_at', '<', $tomorrowStart)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'today_orders' => (int) ($today->total_orders ?? 0),
|
||||
'today_pay_amount' => (float) ($today->total_pay_amount ?? 0),
|
||||
'last_7_days_orders' => (int) ($last7Days->total_orders ?? 0),
|
||||
'last_7_days_pay_amount' => (float) ($last7Days->total_pay_amount ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function emptyStatusStats(): array
|
||||
{
|
||||
$stats = ['all' => 0];
|
||||
|
||||
foreach ($this->statuses as $status) {
|
||||
$stats[$status] = 0;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function emptySummaryStats(): array
|
||||
{
|
||||
return [
|
||||
'total_orders' => 0,
|
||||
'total_pay_amount' => 0,
|
||||
'unpaid_pay_amount' => 0,
|
||||
'paid_pay_amount' => 0,
|
||||
'paid_orders' => 0,
|
||||
'refunded_orders' => 0,
|
||||
'failed_payment_orders' => 0,
|
||||
'pending_shipment_orders' => 0,
|
||||
'completed_orders' => 0,
|
||||
'cancelled_orders' => 0,
|
||||
'average_order_amount' => 0,
|
||||
'payment_rate' => 0,
|
||||
'refund_rate' => 0,
|
||||
'completion_rate' => 0,
|
||||
'cancellation_rate' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function emptyTrendStats(): array
|
||||
{
|
||||
return [
|
||||
'today_orders' => 0,
|
||||
'today_pay_amount' => 0,
|
||||
'last_7_days_orders' => 0,
|
||||
'last_7_days_pay_amount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function isValidDate(string $value): bool
|
||||
{
|
||||
try {
|
||||
$date = Carbon::createFromFormat('Y-m-d', $value);
|
||||
} catch (\Throwable $exception) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $date && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
|
||||
protected function exportableFilters(array $filters): array
|
||||
{
|
||||
return array_filter([
|
||||
'status' => $filters['status'] ?? '',
|
||||
'payment_status' => $filters['payment_status'] ?? '',
|
||||
'platform' => $filters['platform'] ?? '',
|
||||
'device_type' => $filters['device_type'] ?? '',
|
||||
'payment_channel' => $filters['payment_channel'] ?? '',
|
||||
'keyword' => $filters['keyword'] ?? '',
|
||||
'start_date' => $filters['start_date'] ?? '',
|
||||
'end_date' => $filters['end_date'] ?? '',
|
||||
'min_pay_amount' => $filters['min_pay_amount'] ?? '',
|
||||
'max_pay_amount' => $filters['max_pay_amount'] ?? '',
|
||||
'time_range' => $filters['time_range'] ?? '',
|
||||
'sort' => $filters['sort'] ?? '',
|
||||
], fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest');
|
||||
}
|
||||
|
||||
protected function exportSummaryRows(array $filters, string $scope, ?int $merchantId = null): array
|
||||
{
|
||||
return [
|
||||
['导出信息', $scope === 'platform' ? '平台订单导出' : '商家订单导出'],
|
||||
['导出时间', now()->format('Y-m-d H:i:s')],
|
||||
['商家ID', $merchantId ? (string) $merchantId : '全部商家'],
|
||||
['订单状态', $this->statusLabel($filters['status'] ?? '')],
|
||||
['支付状态', $this->paymentStatusLabel($filters['payment_status'] ?? '')],
|
||||
['平台', $this->platformLabel($filters['platform'] ?? '')],
|
||||
['设备类型', $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels())],
|
||||
['支付渠道', $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels())],
|
||||
['关键词', $this->displayTextValue($filters['keyword'] ?? '')],
|
||||
['快捷时间范围', $this->displayFilterValue($filters['time_range'] ?? 'all', [
|
||||
'all' => '全部时间',
|
||||
'today' => '今天',
|
||||
'last_7_days' => '近7天',
|
||||
])],
|
||||
['开始日期', $this->displayTextValue($filters['start_date'] ?? '')],
|
||||
['结束日期', $this->displayTextValue($filters['end_date'] ?? '')],
|
||||
['最低实付金额', $this->displayMoneyValue($filters['min_pay_amount'] ?? '')],
|
||||
['最高实付金额', $this->displayMoneyValue($filters['max_pay_amount'] ?? '')],
|
||||
['排序', $this->sortLabel($filters['sort'] ?? 'latest')],
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildActiveFilterSummary(array $filters): array
|
||||
{
|
||||
return [
|
||||
'关键词' => $this->displayTextValue($filters['keyword'] ?? '', '全部'),
|
||||
'订单状态' => $this->statusLabel($filters['status'] ?? ''),
|
||||
'支付状态' => $this->paymentStatusLabel($filters['payment_status'] ?? ''),
|
||||
'平台' => $this->platformLabel($filters['platform'] ?? ''),
|
||||
'设备类型' => $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels()),
|
||||
'支付渠道' => $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels()),
|
||||
'实付金额区间' => $this->formatMoneyRange($filters['min_pay_amount'] ?? '', $filters['max_pay_amount'] ?? ''),
|
||||
'排序' => $this->sortLabel($filters['sort'] ?? 'latest'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'pending' => '待处理',
|
||||
'paid' => '已支付',
|
||||
'shipped' => '已发货',
|
||||
'completed' => '已完成',
|
||||
'cancelled' => '已取消',
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabel(string $status): string
|
||||
{
|
||||
return $this->statusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function paymentStatusLabels(): array
|
||||
{
|
||||
return [
|
||||
'unpaid' => '未支付',
|
||||
'paid' => '已支付',
|
||||
'refunded' => '已退款',
|
||||
'failed' => '支付失败',
|
||||
];
|
||||
}
|
||||
|
||||
protected function paymentStatusLabel(string $status): string
|
||||
{
|
||||
return $this->paymentStatusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function platformLabels(): array
|
||||
{
|
||||
return [
|
||||
'pc' => 'PC 端',
|
||||
'h5' => 'H5',
|
||||
'wechat_mp' => '微信公众号',
|
||||
'wechat_mini' => '微信小程序',
|
||||
'app' => 'APP 接口预留',
|
||||
];
|
||||
}
|
||||
|
||||
protected function platformLabel(string $platform): string
|
||||
{
|
||||
return $this->platformLabels()[$platform] ?? '全部';
|
||||
}
|
||||
|
||||
protected function deviceTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
'desktop' => '桌面浏览器',
|
||||
'mobile' => '移动浏览器',
|
||||
'mini-program' => '小程序环境',
|
||||
'mobile-webview' => '微信内网页',
|
||||
'app-api' => 'APP 接口',
|
||||
];
|
||||
}
|
||||
|
||||
protected function deviceTypeLabel(string $deviceType): string
|
||||
{
|
||||
return $this->deviceTypeLabels()[$deviceType] ?? ($deviceType === '' ? '未设置' : $deviceType);
|
||||
}
|
||||
|
||||
protected function paymentChannelLabels(): array
|
||||
{
|
||||
return [
|
||||
'wechat_pay' => '微信支付',
|
||||
'alipay' => '支付宝',
|
||||
];
|
||||
}
|
||||
|
||||
protected function paymentChannelLabel(string $paymentChannel): string
|
||||
{
|
||||
return $this->paymentChannelLabels()[$paymentChannel] ?? ($paymentChannel === '' ? '未设置' : $paymentChannel);
|
||||
}
|
||||
|
||||
protected function sortLabel(string $sort): string
|
||||
{
|
||||
return match ($sort) {
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
'product_amount_desc' => '商品金额从高到低',
|
||||
'product_amount_asc' => '商品金额从低到高',
|
||||
default => '创建时间倒序',
|
||||
};
|
||||
}
|
||||
|
||||
protected function formatMoneyRange(string $min, string $max): string
|
||||
{
|
||||
if ($min === '' && $max === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
$minLabel = $min !== '' && is_numeric($min) ? ('¥' . number_format((float) $min, 2, '.', '')) : '不限';
|
||||
$maxLabel = $max !== '' && is_numeric($max) ? ('¥' . number_format((float) $max, 2, '.', '')) : '不限';
|
||||
|
||||
return $minLabel . ' ~ ' . $maxLabel;
|
||||
}
|
||||
|
||||
protected function displayFilterValue(string $value, array $options): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
return (string) ($options[$value] ?? $value);
|
||||
}
|
||||
|
||||
protected function displayTextValue(string $value, string $default = '未设置'): string
|
||||
{
|
||||
return $value === '' ? $default : $value;
|
||||
}
|
||||
|
||||
protected function displayMoneyValue(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value;
|
||||
}
|
||||
|
||||
protected function workbenchLinks(): array
|
||||
{
|
||||
return [
|
||||
'paid_high_amount' => '/merchant-admin/orders?sort=pay_amount_desc&payment_status=paid',
|
||||
'pending_latest' => '/merchant-admin/orders?sort=latest&payment_status=unpaid',
|
||||
'failed_latest' => '/merchant-admin/orders?sort=latest&payment_status=failed',
|
||||
'completed_latest' => '/merchant-admin/orders?sort=latest&status=completed',
|
||||
'current' => '/merchant-admin/orders',
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildOperationsFocus(int $merchantId, array $summaryStats, array $filters): array
|
||||
{
|
||||
$pendingCount = (int) Order::query()->forMerchant($merchantId)->where('payment_status', 'unpaid')->count();
|
||||
$failedCount = (int) Order::query()->forMerchant($merchantId)->where('payment_status', 'failed')->count();
|
||||
$completedCount = (int) Order::query()->forMerchant($merchantId)->where('status', 'completed')->count();
|
||||
$links = $this->workbenchLinks();
|
||||
$currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest'));
|
||||
$currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : '');
|
||||
$workbench = [
|
||||
'高金额已支付' => $links['paid_high_amount'],
|
||||
'待支付跟进' => $links['pending_latest'],
|
||||
'支付失败排查' => $links['failed_latest'],
|
||||
'最近完成订单' => $links['completed_latest'],
|
||||
'返回当前筛选视图' => $currentUrl,
|
||||
];
|
||||
$signals = [
|
||||
'待支付订单' => $pendingCount,
|
||||
'支付失败订单' => $failedCount,
|
||||
'已完成订单' => $completedCount,
|
||||
];
|
||||
|
||||
if (($filters['platform'] ?? '') === 'wechat_mini') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信小程序订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_channel'] ?? '') === 'wechat_pay') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看支付失败订单', 'url' => $links['failed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mini-program') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。',
|
||||
'actions' => [
|
||||
['label' => '继续查看小程序环境订单', 'url' => $currentUrl],
|
||||
['label' => '去看微信支付订单', 'url' => $links['failed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mobile-webview') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信内网页订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mobile') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。',
|
||||
'actions' => [
|
||||
['label' => '继续查看移动浏览器订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'desktop') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。',
|
||||
'actions' => [
|
||||
['label' => '继续查看桌面浏览器订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'failed') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。',
|
||||
'actions' => [
|
||||
['label' => '继续查看支付失败订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'unpaid') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦待支付订单,建议优先跟进下单未支付用户,并观察支付转化时效。',
|
||||
'actions' => [
|
||||
['label' => '继续查看待支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'paid') {
|
||||
return [
|
||||
'headline' => '当前正在查看已支付订单,建议优先关注高金额订单履约进度与异常售后风险。',
|
||||
'actions' => [
|
||||
['label' => '继续查看已支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看最近完成订单', 'url' => $links['completed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['status'] ?? '') === 'completed') {
|
||||
return [
|
||||
'headline' => '当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。',
|
||||
'actions' => [
|
||||
['label' => '继续查看已完成订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_orders'] ?? 0) <= 0) {
|
||||
return [
|
||||
'headline' => '当前商家暂无订单,建议先确认交易链路、支付链路与回写链路是否都已打通。',
|
||||
'actions' => [
|
||||
['label' => '先看订单整体情况', 'url' => $links['paid_high_amount']],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_orders'] ?? 0) < 5) {
|
||||
return [
|
||||
'headline' => '当前商家已有少量订单沉淀,建议优先关注待支付订单,并同步查看已支付订单质量。',
|
||||
'actions' => [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'headline' => $failedCount > 0
|
||||
? '当前商家订单已形成基础规模,建议优先关注待支付、支付失败与高金额已支付订单。'
|
||||
: '当前商家订单已形成基础规模,建议优先关注待支付与高金额已支付订单,保持交易闭环稳定。',
|
||||
'actions' => $failedCount > 0
|
||||
? [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看支付失败订单', 'url' => $links['failed_latest']],
|
||||
]
|
||||
: [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/MerchantAdmin/ProductCategoryController.php
Normal file
123
app/Http/Controllers/MerchantAdmin/ProductCategoryController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\MerchantAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesMerchantContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ProductCategory;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
use ResolvesMerchantContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
|
||||
$page = max((int) $request->integer('page', 1), 1);
|
||||
|
||||
return view('merchant_admin.product_categories.index', [
|
||||
'categories' => Cache::remember(
|
||||
CacheKeys::merchantCategoriesList($merchantId, $page),
|
||||
now()->addMinutes(10),
|
||||
fn () => ProductCategory::query()->forMerchant($merchantId)->orderBy('sort')->orderBy('id')->paginate(10)->withQueryString()
|
||||
),
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string'],
|
||||
'slug' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $merchantId)),
|
||||
],
|
||||
'status' => ['nullable', 'string'],
|
||||
'sort' => ['nullable', 'integer'],
|
||||
'description' => ['nullable', 'string'],
|
||||
], [
|
||||
'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。',
|
||||
]);
|
||||
|
||||
ProductCategory::query()->create([
|
||||
'merchant_id' => $merchantId,
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'],
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'sort' => $data['sort'] ?? 0,
|
||||
'description' => $data['description'] ?? null,
|
||||
]);
|
||||
|
||||
$this->flushMerchantCaches($merchantId);
|
||||
|
||||
return redirect('/merchant-admin/product-categories')->with('success', '商品分类创建成功');
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
|
||||
$category = ProductCategory::query()->forMerchant($merchantId)->findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string'],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $merchantId))->ignore($category->id),
|
||||
],
|
||||
'status' => ['required', 'string'],
|
||||
'sort' => ['nullable', 'integer'],
|
||||
'description' => ['nullable', 'string'],
|
||||
], [
|
||||
'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。',
|
||||
]);
|
||||
$category->update([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? $category->slug,
|
||||
'status' => $data['status'],
|
||||
'sort' => $data['sort'] ?? 0,
|
||||
'description' => $data['description'] ?? null,
|
||||
]);
|
||||
|
||||
$this->flushMerchantCaches($merchantId);
|
||||
|
||||
return redirect('/merchant-admin/product-categories')->with('success', '商品分类更新成功');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
|
||||
$category = ProductCategory::query()->forMerchant($merchantId)->findOrFail($id);
|
||||
$category->delete();
|
||||
|
||||
$this->flushMerchantCaches($merchantId);
|
||||
|
||||
return redirect('/merchant-admin/product-categories')->with('success', '商品分类删除成功');
|
||||
}
|
||||
|
||||
protected function flushMerchantCaches(int $merchantId): void
|
||||
{
|
||||
for ($page = 1; $page <= 5; $page++) {
|
||||
Cache::forget(CacheKeys::merchantProductsList($merchantId, $page));
|
||||
Cache::forget(CacheKeys::merchantCategoriesList($merchantId, $page));
|
||||
}
|
||||
|
||||
Cache::forget(CacheKeys::merchantDashboardStats($merchantId));
|
||||
}
|
||||
}
|
||||
1286
app/Http/Controllers/MerchantAdmin/ProductController.php
Normal file
1286
app/Http/Controllers/MerchantAdmin/ProductController.php
Normal file
File diff suppressed because it is too large
Load Diff
35
app/Http/Controllers/MerchantAdmin/UserController.php
Normal file
35
app/Http/Controllers/MerchantAdmin/UserController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\MerchantAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesMerchantContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
use ResolvesMerchantContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$merchantId = $this->merchantId($request);
|
||||
|
||||
$page = max((int) $request->integer('page', 1), 1);
|
||||
|
||||
return view('merchant_admin.users.index', [
|
||||
'users' => Cache::remember(
|
||||
CacheKeys::merchantUsersList($merchantId, $page),
|
||||
now()->addMinutes(10),
|
||||
fn () => User::query()->forMerchant($merchantId)->latest()->paginate(10)->withQueryString()
|
||||
),
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/SiteAdmin/AuthController.php
Normal file
57
app/Http/Controllers/SiteAdmin/AuthController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SiteAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('site_admin.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->with('merchant')->where('email', $data['email'])->first();
|
||||
if (! $admin || ! Hash::check($data['password'], $admin->password)) {
|
||||
return back()->withErrors(['email' => '账号或密码错误'])->withInput();
|
||||
}
|
||||
|
||||
if (! $admin->isMerchantAdmin()) {
|
||||
return back()->withErrors(['email' => '当前账号不是站点管理员,不能登录站点后台'])->withInput();
|
||||
}
|
||||
|
||||
$siteId = $admin->merchantId();
|
||||
|
||||
$request->session()->put('admin_id', $admin->id);
|
||||
$request->session()->put('admin_name', $admin->name);
|
||||
$request->session()->put('admin_email', $admin->email);
|
||||
$request->session()->put('admin_role', $admin->role);
|
||||
$request->session()->put('admin_merchant_id', $siteId);
|
||||
$request->session()->put('admin_site_id', $siteId);
|
||||
$request->session()->put('admin_scope', 'site');
|
||||
$request->session()->put('site_name', $admin->merchant?->name);
|
||||
|
||||
$admin->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return redirect('/site-admin');
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->forget(['admin_id', 'admin_name', 'admin_email', 'admin_role', 'admin_merchant_id', 'admin_site_id', 'admin_scope', 'site_name']);
|
||||
|
||||
return redirect('/site-admin/login');
|
||||
}
|
||||
}
|
||||
46
app/Http/Controllers/SiteAdmin/DashboardController.php
Normal file
46
app/Http/Controllers/SiteAdmin/DashboardController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SiteAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesSiteContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use App\Support\CacheKeys;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ResolvesSiteContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$siteId = $this->siteId($request);
|
||||
$site = $this->site($request);
|
||||
|
||||
$stats = Cache::remember(
|
||||
CacheKeys::merchantDashboardStats($siteId),
|
||||
now()->addMinutes(10),
|
||||
fn () => [
|
||||
'admins' => Admin::query()->where('merchant_id', $siteId)->count(),
|
||||
'users' => User::query()->forMerchant($siteId)->count(),
|
||||
'products' => Product::query()->forMerchant($siteId)->count(),
|
||||
'orders' => Order::query()->forMerchant($siteId)->count(),
|
||||
'pending_orders' => Order::query()->forMerchant($siteId)->where('status', 'pending')->count(),
|
||||
]
|
||||
);
|
||||
|
||||
return view('site_admin.dashboard', [
|
||||
'site' => $site,
|
||||
'stats' => $stats,
|
||||
'cacheMeta' => [
|
||||
'store' => config('cache.default'),
|
||||
'ttl' => '10m',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
228
app/Http/Controllers/SiteAdmin/MerchantController.php
Normal file
228
app/Http/Controllers/SiteAdmin/MerchantController.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SiteAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesSiteContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Merchant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class MerchantController extends Controller
|
||||
{
|
||||
use ResolvesSiteContext;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$site = $this->site($request);
|
||||
$filters = $this->filters($request);
|
||||
|
||||
$query = $this->applySorting(
|
||||
$this->applyFilters(
|
||||
Merchant::query()
|
||||
->withCount(['admins', 'users', 'products', 'orders', 'categories'])
|
||||
->whereKey($site->id),
|
||||
$filters
|
||||
),
|
||||
$filters
|
||||
);
|
||||
|
||||
$merchants = $query->get();
|
||||
$summaryStats = $this->buildSummaryStats($site);
|
||||
|
||||
return view('site_admin.merchants.index', [
|
||||
'site' => $site,
|
||||
'merchants' => $merchants,
|
||||
'filters' => $filters,
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'summaryStats' => $summaryStats,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'planLabels' => $this->planLabels(),
|
||||
'filterOptions' => [
|
||||
'statuses' => array_keys($this->statusLabels()),
|
||||
'plans' => array_keys($this->planLabels()),
|
||||
'sortOptions' => [
|
||||
'latest' => '最近激活优先',
|
||||
'name_asc' => '名称 A-Z',
|
||||
'name_desc' => '名称 Z-A',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$site = $this->site($request);
|
||||
$filters = $this->filters($request);
|
||||
|
||||
$merchants = $this->applySorting(
|
||||
$this->applyFilters(
|
||||
Merchant::query()
|
||||
->withCount(['admins', 'users', 'products', 'orders', 'categories'])
|
||||
->whereKey($site->id),
|
||||
$filters
|
||||
),
|
||||
$filters
|
||||
)->get();
|
||||
|
||||
$summaryStats = $this->buildSummaryStats($site);
|
||||
$fileName = 'site_' . $site->id . '_merchants_' . now()->format('Ymd_His') . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($site, $filters, $merchants, $summaryStats) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($handle, ['导出信息', '站点商家导出']);
|
||||
fputcsv($handle, ['站点ID', $site->id]);
|
||||
fputcsv($handle, ['站点名称', $site->name]);
|
||||
fputcsv($handle, ['关键词', ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部']);
|
||||
fputcsv($handle, ['状态', $this->statusLabel($filters['status'] ?? '')]);
|
||||
fputcsv($handle, ['套餐', $this->planLabel($filters['plan'] ?? '')]);
|
||||
fputcsv($handle, ['排序', $this->sortLabel($filters['sort'] ?? 'latest')]);
|
||||
fputcsv($handle, ['承接站点数', $summaryStats['site_count'] ?? 0]);
|
||||
fputcsv($handle, ['启用中站点', $summaryStats['active_site_count'] ?? 0]);
|
||||
fputcsv($handle, ['站点管理员数', $summaryStats['admin_count'] ?? 0]);
|
||||
fputcsv($handle, ['站点用户数', $summaryStats['user_count'] ?? 0]);
|
||||
fputcsv($handle, ['站点商品数', $summaryStats['product_count'] ?? 0]);
|
||||
fputcsv($handle, ['站点订单数', $summaryStats['order_count'] ?? 0]);
|
||||
fputcsv($handle, ['商品分类数', $summaryStats['category_count'] ?? 0]);
|
||||
fputcsv($handle, []);
|
||||
|
||||
fputcsv($handle, ['当前站点资料', '']);
|
||||
fputcsv($handle, ['站点标识', $site->slug]);
|
||||
fputcsv($handle, ['当前状态', $this->statusLabel((string) $site->status)]);
|
||||
fputcsv($handle, ['当前套餐', $this->planLabel((string) $site->plan)]);
|
||||
fputcsv($handle, ['联系人', $site->contact_name ?: '未设置']);
|
||||
fputcsv($handle, ['联系电话', $site->contact_phone ?: '未设置']);
|
||||
fputcsv($handle, ['联系邮箱', $site->contact_email ?: '未设置']);
|
||||
fputcsv($handle, ['激活时间', $site->activated_at?->format('Y-m-d H:i:s') ?? '未激活']);
|
||||
fputcsv($handle, []);
|
||||
|
||||
fputcsv($handle, ['ID', '名称', 'Slug', '状态', '套餐', '联系人', '联系电话', '联系邮箱', '管理员数', '用户数', '商品数', '订单数', '商品分类数', '激活时间']);
|
||||
|
||||
foreach ($merchants as $merchant) {
|
||||
fputcsv($handle, [
|
||||
$merchant->id,
|
||||
$merchant->name,
|
||||
$merchant->slug,
|
||||
$this->statusLabel((string) $merchant->status),
|
||||
$this->planLabel((string) $merchant->plan),
|
||||
$merchant->contact_name ?: '未设置',
|
||||
$merchant->contact_phone ?: '未设置',
|
||||
$merchant->contact_email ?: '未设置',
|
||||
$merchant->admins_count ?? 0,
|
||||
$merchant->users_count ?? 0,
|
||||
$merchant->products_count ?? 0,
|
||||
$merchant->orders_count ?? 0,
|
||||
$merchant->categories_count ?? 0,
|
||||
$merchant->activated_at?->format('Y-m-d H:i:s') ?? '未激活',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function filters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'keyword' => trim((string) $request->string('keyword')),
|
||||
'status' => trim((string) $request->string('status')),
|
||||
'plan' => trim((string) $request->string('plan')),
|
||||
'sort' => trim((string) $request->string('sort', 'latest')),
|
||||
];
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status']))
|
||||
->when(($filters['plan'] ?? '') !== '', fn ($builder) => $builder->where('plan', $filters['plan']))
|
||||
->when(($filters['keyword'] ?? '') !== '', function ($builder) use ($filters) {
|
||||
$keyword = $filters['keyword'];
|
||||
|
||||
$builder->where(function ($subQuery) use ($keyword) {
|
||||
$subQuery->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('slug', 'like', '%' . $keyword . '%')
|
||||
->orWhere('contact_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('contact_phone', 'like', '%' . $keyword . '%')
|
||||
->orWhere('contact_email', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected function applySorting(Builder $query, array $filters): Builder
|
||||
{
|
||||
return match ($filters['sort'] ?? 'latest') {
|
||||
'name_asc' => $query->orderBy('name')->orderBy('id'),
|
||||
'name_desc' => $query->orderByDesc('name')->orderByDesc('id'),
|
||||
default => $query->orderByDesc('activated_at')->orderByDesc('id'),
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildSummaryStats(Merchant $site): array
|
||||
{
|
||||
$site->loadCount(['admins', 'users', 'products', 'orders', 'categories']);
|
||||
|
||||
return [
|
||||
'site_count' => 1,
|
||||
'active_site_count' => $site->status === 'active' ? 1 : 0,
|
||||
'admin_count' => (int) ($site->admins_count ?? 0),
|
||||
'user_count' => (int) ($site->users_count ?? 0),
|
||||
'product_count' => (int) ($site->products_count ?? 0),
|
||||
'order_count' => (int) ($site->orders_count ?? 0),
|
||||
'category_count' => (int) ($site->categories_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildActiveFilterSummary(array $filters): array
|
||||
{
|
||||
return [
|
||||
'关键词' => ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部',
|
||||
'状态' => $this->statusLabel($filters['status'] ?? ''),
|
||||
'套餐' => $this->planLabel($filters['plan'] ?? ''),
|
||||
'排序' => $this->sortLabel($filters['sort'] ?? 'latest'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'active' => '启用中',
|
||||
'inactive' => '未启用',
|
||||
'suspended' => '已停用',
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabel(string $status): string
|
||||
{
|
||||
return $this->statusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function planLabels(): array
|
||||
{
|
||||
return [
|
||||
'basic' => '基础版',
|
||||
'pro' => '专业版',
|
||||
'enterprise' => '企业版',
|
||||
];
|
||||
}
|
||||
|
||||
protected function planLabel(string $plan): string
|
||||
{
|
||||
return $this->planLabels()[$plan] ?? (($plan === '') ? '全部' : $plan);
|
||||
}
|
||||
|
||||
protected function sortLabel(string $sort): string
|
||||
{
|
||||
return match ($sort) {
|
||||
'name_asc' => '名称 A-Z',
|
||||
'name_desc' => '名称 Z-A',
|
||||
default => '最近激活优先',
|
||||
};
|
||||
}
|
||||
}
|
||||
643
app/Http/Controllers/SiteAdmin/OrderController.php
Normal file
643
app/Http/Controllers/SiteAdmin/OrderController.php
Normal file
@@ -0,0 +1,643 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SiteAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesSiteContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
use ResolvesSiteContext;
|
||||
|
||||
protected array $statuses = ['pending', 'paid', 'shipped', 'completed', 'cancelled'];
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$siteId = $this->siteId($request);
|
||||
$site = $this->site($request);
|
||||
$filters = $this->filters($request);
|
||||
$statusStatsFilters = $filters;
|
||||
$statusStatsFilters['status'] = '';
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return view('site_admin.orders.index', [
|
||||
'site' => $site,
|
||||
'orders' => Order::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(),
|
||||
'summaryStats' => $this->emptySummaryStats(),
|
||||
'statusStats' => $this->emptyStatusStats(),
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'operationsFocus' => $this->buildOperationsFocus($siteId, $this->emptySummaryStats(), $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'filters' => $filters,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
'platformLabels' => $this->platformLabels(),
|
||||
'deviceTypeLabels' => $this->deviceTypeLabels(),
|
||||
'paymentChannelLabels' => $this->paymentChannelLabels(),
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statuses,
|
||||
'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'],
|
||||
'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'],
|
||||
'paymentChannels' => ['wechat_pay', 'alipay'],
|
||||
'sortOptions' => [
|
||||
'latest' => '创建时间倒序',
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$summaryStats = $this->buildSummaryStats(
|
||||
$this->applyFilters(Order::query()->forMerchant($siteId), $statusStatsFilters)
|
||||
);
|
||||
|
||||
return view('site_admin.orders.index', [
|
||||
'site' => $site,
|
||||
'orders' => $this->applySorting(
|
||||
$this->applyFilters(Order::query()->forMerchant($siteId), $filters),
|
||||
$filters
|
||||
)->paginate(10)->withQueryString(),
|
||||
'summaryStats' => $summaryStats,
|
||||
'statusStats' => $this->buildStatusStats(
|
||||
$this->applyFilters(Order::query()->forMerchant($siteId), $statusStatsFilters)
|
||||
),
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'operationsFocus' => $this->buildOperationsFocus($siteId, $summaryStats, $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'filters' => $filters,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'paymentStatusLabels' => $this->paymentStatusLabels(),
|
||||
'platformLabels' => $this->platformLabels(),
|
||||
'deviceTypeLabels' => $this->deviceTypeLabels(),
|
||||
'paymentChannelLabels' => $this->paymentChannelLabels(),
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statuses,
|
||||
'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'],
|
||||
'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'],
|
||||
'paymentChannels' => ['wechat_pay', 'alipay'],
|
||||
'sortOptions' => [
|
||||
'latest' => '创建时间倒序',
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$siteId = $this->siteId($request);
|
||||
$filters = $this->filters($request);
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return redirect('/site-admin/orders?' . http_build_query($this->exportableFilters($filters)))
|
||||
->withErrors($filters['validation_errors'] ?? ['订单筛选条件不合法,请先修正后再导出。']);
|
||||
}
|
||||
|
||||
$fileName = 'site_' . $siteId . '_orders_' . now()->format('Ymd_His') . '.csv';
|
||||
$exportSummary = $this->buildSummaryStats(
|
||||
$this->applyFilters(Order::query()->forMerchant($siteId), $filters)
|
||||
);
|
||||
|
||||
return response()->streamDownload(function () use ($siteId, $filters, $exportSummary) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($handle, ['导出信息', '站点订单导出']);
|
||||
fputcsv($handle, ['站点ID', $siteId]);
|
||||
fputcsv($handle, ['关键词', ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部']);
|
||||
fputcsv($handle, ['订单状态', $this->statusLabel($filters['status'] ?? '')]);
|
||||
fputcsv($handle, ['支付状态', $this->paymentStatusLabel($filters['payment_status'] ?? '')]);
|
||||
fputcsv($handle, ['平台', $this->platformLabel($filters['platform'] ?? '')]);
|
||||
fputcsv($handle, ['设备类型', $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels())]);
|
||||
fputcsv($handle, ['支付渠道', $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels())]);
|
||||
fputcsv($handle, ['最低实付金额', $this->displayMoneyValue($filters['min_pay_amount'] ?? '')]);
|
||||
fputcsv($handle, ['最高实付金额', $this->displayMoneyValue($filters['max_pay_amount'] ?? '')]);
|
||||
fputcsv($handle, ['排序', $this->sortLabel($filters['sort'] ?? 'latest')]);
|
||||
fputcsv($handle, ['导出订单数', $exportSummary['total_orders'] ?? 0]);
|
||||
fputcsv($handle, ['导出实付总额', number_format((float) ($exportSummary['total_pay_amount'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, ['导出平均客单价', number_format((float) ($exportSummary['average_order_amount'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, ['导出已支付订单数', $exportSummary['paid_orders'] ?? 0]);
|
||||
fputcsv($handle, ['导出支付失败订单', $exportSummary['failed_payment_orders'] ?? 0]);
|
||||
fputcsv($handle, []);
|
||||
|
||||
fputcsv($handle, [
|
||||
'ID',
|
||||
'订单号',
|
||||
'订单状态',
|
||||
'支付状态',
|
||||
'平台',
|
||||
'设备类型',
|
||||
'支付渠道',
|
||||
'买家姓名',
|
||||
'买家手机',
|
||||
'买家邮箱',
|
||||
'商品金额',
|
||||
'优惠金额',
|
||||
'运费',
|
||||
'实付金额',
|
||||
'创建时间',
|
||||
'支付时间',
|
||||
'完成时间',
|
||||
'备注',
|
||||
]);
|
||||
|
||||
foreach ($this->applySorting($this->applyFilters(Order::query()->forMerchant($siteId), $filters), $filters)->cursor() as $order) {
|
||||
fputcsv($handle, [
|
||||
$order->id,
|
||||
$order->order_no,
|
||||
$this->statusLabel((string) $order->status),
|
||||
$this->paymentStatusLabel((string) $order->payment_status),
|
||||
$this->platformLabel((string) $order->platform),
|
||||
$this->deviceTypeLabel((string) $order->device_type),
|
||||
$this->paymentChannelLabel((string) $order->payment_channel),
|
||||
$order->buyer_name,
|
||||
$order->buyer_phone,
|
||||
$order->buyer_email,
|
||||
number_format((float) $order->product_amount, 2, '.', ''),
|
||||
number_format((float) $order->discount_amount, 2, '.', ''),
|
||||
number_format((float) $order->shipping_amount, 2, '.', ''),
|
||||
number_format((float) $order->pay_amount, 2, '.', ''),
|
||||
optional($order->created_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->paid_at)?->format('Y-m-d H:i:s'),
|
||||
optional($order->completed_at)?->format('Y-m-d H:i:s'),
|
||||
$order->remark,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function filters(Request $request): array
|
||||
{
|
||||
$minPayAmount = trim((string) $request->string('min_pay_amount'));
|
||||
$maxPayAmount = trim((string) $request->string('max_pay_amount'));
|
||||
$validationErrors = [];
|
||||
|
||||
if ($minPayAmount !== '' && ! is_numeric($minPayAmount)) {
|
||||
$validationErrors[] = '最低实付金额必须为数字。';
|
||||
}
|
||||
|
||||
if ($maxPayAmount !== '' && ! is_numeric($maxPayAmount)) {
|
||||
$validationErrors[] = '最高实付金额必须为数字。';
|
||||
}
|
||||
|
||||
if ($minPayAmount !== '' && $maxPayAmount !== '' && is_numeric($minPayAmount) && is_numeric($maxPayAmount) && (float) $minPayAmount > (float) $maxPayAmount) {
|
||||
$validationErrors[] = '最低实付金额不能大于最高实付金额。';
|
||||
}
|
||||
|
||||
return [
|
||||
'keyword' => trim((string) $request->string('keyword')),
|
||||
'status' => trim((string) $request->string('status')),
|
||||
'payment_status' => trim((string) $request->string('payment_status')),
|
||||
'platform' => trim((string) $request->string('platform')),
|
||||
'device_type' => trim((string) $request->string('device_type')),
|
||||
'payment_channel' => trim((string) $request->string('payment_channel')),
|
||||
'min_pay_amount' => $minPayAmount,
|
||||
'max_pay_amount' => $maxPayAmount,
|
||||
'sort' => trim((string) $request->string('sort', 'latest')),
|
||||
'validation_errors' => $validationErrors,
|
||||
'has_validation_error' => ! empty($validationErrors),
|
||||
];
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status']))
|
||||
->when(($filters['payment_status'] ?? '') !== '', fn ($builder) => $builder->where('payment_status', $filters['payment_status']))
|
||||
->when(($filters['platform'] ?? '') !== '', fn ($builder) => $builder->where('platform', $filters['platform']))
|
||||
->when(($filters['device_type'] ?? '') !== '', fn ($builder) => $builder->where('device_type', $filters['device_type']))
|
||||
->when(($filters['payment_channel'] ?? '') !== '', fn ($builder) => $builder->where('payment_channel', $filters['payment_channel']))
|
||||
->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) {
|
||||
$subQuery->where('order_no', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_name', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_phone', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('buyer_email', 'like', '%' . $filters['keyword'] . '%');
|
||||
}))
|
||||
->when(($filters['min_pay_amount'] ?? '') !== '' && is_numeric($filters['min_pay_amount']), fn ($builder) => $builder->where('pay_amount', '>=', $filters['min_pay_amount']))
|
||||
->when(($filters['max_pay_amount'] ?? '') !== '' && is_numeric($filters['max_pay_amount']), fn ($builder) => $builder->where('pay_amount', '<=', $filters['max_pay_amount']));
|
||||
}
|
||||
|
||||
protected function applySorting(Builder $query, array $filters): Builder
|
||||
{
|
||||
return match ($filters['sort'] ?? 'latest') {
|
||||
'oldest' => $query->orderBy('created_at')->orderBy('id'),
|
||||
'pay_amount_desc' => $query->orderByDesc('pay_amount')->orderByDesc('id'),
|
||||
'pay_amount_asc' => $query->orderBy('pay_amount')->orderByDesc('id'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildSummaryStats(Builder $query): array
|
||||
{
|
||||
$summary = (clone $query)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(pay_amount), 0) as total_pay_amount')
|
||||
->selectRaw('COALESCE(AVG(pay_amount), 0) as average_order_amount')
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN 1 ELSE 0 END) as paid_orders")
|
||||
->selectRaw("SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) as failed_payment_orders")
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_orders' => (int) ($summary->total_orders ?? 0),
|
||||
'total_pay_amount' => (float) ($summary->total_pay_amount ?? 0),
|
||||
'average_order_amount' => (float) ($summary->average_order_amount ?? 0),
|
||||
'paid_orders' => (int) ($summary->paid_orders ?? 0),
|
||||
'failed_payment_orders' => (int) ($summary->failed_payment_orders ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildStatusStats(Builder $query): array
|
||||
{
|
||||
$counts = (clone $query)
|
||||
->selectRaw('status, COUNT(*) as aggregate')
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status');
|
||||
|
||||
$stats = ['all' => (int) $counts->sum()];
|
||||
|
||||
foreach ($this->statuses as $status) {
|
||||
$stats[$status] = (int) ($counts[$status] ?? 0);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function emptySummaryStats(): array
|
||||
{
|
||||
return [
|
||||
'total_orders' => 0,
|
||||
'total_pay_amount' => 0,
|
||||
'average_order_amount' => 0,
|
||||
'paid_orders' => 0,
|
||||
'failed_payment_orders' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function emptyStatusStats(): array
|
||||
{
|
||||
$stats = ['all' => 0];
|
||||
|
||||
foreach ($this->statuses as $status) {
|
||||
$stats[$status] = 0;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function exportableFilters(array $filters): array
|
||||
{
|
||||
return [
|
||||
'keyword' => $filters['keyword'] ?? '',
|
||||
'status' => $filters['status'] ?? '',
|
||||
'payment_status' => $filters['payment_status'] ?? '',
|
||||
'platform' => $filters['platform'] ?? '',
|
||||
'device_type' => $filters['device_type'] ?? '',
|
||||
'payment_channel' => $filters['payment_channel'] ?? '',
|
||||
'min_pay_amount' => $filters['min_pay_amount'] ?? '',
|
||||
'max_pay_amount' => $filters['max_pay_amount'] ?? '',
|
||||
'sort' => $filters['sort'] ?? 'latest',
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildActiveFilterSummary(array $filters): array
|
||||
{
|
||||
return [
|
||||
'关键词' => ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部',
|
||||
'订单状态' => $this->statusLabel($filters['status'] ?? ''),
|
||||
'支付状态' => $this->paymentStatusLabel($filters['payment_status'] ?? ''),
|
||||
'平台' => $this->platformLabel($filters['platform'] ?? ''),
|
||||
'设备类型' => $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels()),
|
||||
'支付渠道' => $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels()),
|
||||
'实付金额区间' => $this->formatMoneyRange($filters['min_pay_amount'] ?? '', $filters['max_pay_amount'] ?? ''),
|
||||
'排序' => $this->sortLabel($filters['sort'] ?? 'latest'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'pending' => '待处理',
|
||||
'paid' => '已支付',
|
||||
'shipped' => '已发货',
|
||||
'completed' => '已完成',
|
||||
'cancelled' => '已取消',
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabel(string $status): string
|
||||
{
|
||||
return $this->statusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function paymentStatusLabels(): array
|
||||
{
|
||||
return [
|
||||
'unpaid' => '未支付',
|
||||
'paid' => '已支付',
|
||||
'refunded' => '已退款',
|
||||
'failed' => '支付失败',
|
||||
];
|
||||
}
|
||||
|
||||
protected function paymentStatusLabel(string $status): string
|
||||
{
|
||||
return $this->paymentStatusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function platformLabels(): array
|
||||
{
|
||||
return [
|
||||
'pc' => 'PC 端',
|
||||
'h5' => 'H5',
|
||||
'wechat_mp' => '微信公众号',
|
||||
'wechat_mini' => '微信小程序',
|
||||
'app' => 'APP 接口预留',
|
||||
];
|
||||
}
|
||||
|
||||
protected function platformLabel(string $platform): string
|
||||
{
|
||||
return $this->platformLabels()[$platform] ?? '全部';
|
||||
}
|
||||
|
||||
protected function deviceTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
'desktop' => '桌面浏览器',
|
||||
'mobile' => '移动浏览器',
|
||||
'mini-program' => '小程序环境',
|
||||
'mobile-webview' => '微信内网页',
|
||||
'app-api' => 'APP 接口',
|
||||
];
|
||||
}
|
||||
|
||||
protected function deviceTypeLabel(string $deviceType): string
|
||||
{
|
||||
return $this->deviceTypeLabels()[$deviceType] ?? ($deviceType === '' ? '未设置' : $deviceType);
|
||||
}
|
||||
|
||||
protected function paymentChannelLabels(): array
|
||||
{
|
||||
return [
|
||||
'wechat_pay' => '微信支付',
|
||||
'alipay' => '支付宝',
|
||||
];
|
||||
}
|
||||
|
||||
protected function paymentChannelLabel(string $paymentChannel): string
|
||||
{
|
||||
return $this->paymentChannelLabels()[$paymentChannel] ?? ($paymentChannel === '' ? '未设置' : $paymentChannel);
|
||||
}
|
||||
|
||||
protected function formatMoneyRange(string $min, string $max): string
|
||||
{
|
||||
if ($min === '' && $max === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
$minLabel = $min !== '' && is_numeric($min)
|
||||
? ('¥' . number_format((float) $min, 2, '.', ''))
|
||||
: '不限';
|
||||
$maxLabel = $max !== '' && is_numeric($max)
|
||||
? ('¥' . number_format((float) $max, 2, '.', ''))
|
||||
: '不限';
|
||||
|
||||
return $minLabel . ' ~ ' . $maxLabel;
|
||||
}
|
||||
|
||||
protected function sortLabel(string $sort): string
|
||||
{
|
||||
return match ($sort) {
|
||||
'oldest' => '创建时间正序',
|
||||
'pay_amount_desc' => '实付金额从高到低',
|
||||
'pay_amount_asc' => '实付金额从低到高',
|
||||
default => '创建时间倒序',
|
||||
};
|
||||
}
|
||||
|
||||
protected function displayFilterValue(string $value, array $options): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
return (string) ($options[$value] ?? $value);
|
||||
}
|
||||
|
||||
protected function displayMoneyValue(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value;
|
||||
}
|
||||
|
||||
protected function workbenchLinks(): array
|
||||
{
|
||||
return [
|
||||
'paid_high_amount' => '/site-admin/orders?sort=pay_amount_desc&payment_status=paid',
|
||||
'pending_latest' => '/site-admin/orders?sort=latest&payment_status=unpaid',
|
||||
'failed_latest' => '/site-admin/orders?sort=latest&payment_status=failed',
|
||||
'completed_latest' => '/site-admin/orders?sort=latest&status=completed',
|
||||
'current' => '/site-admin/orders',
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildOperationsFocus(int $siteId, array $summaryStats, array $filters): array
|
||||
{
|
||||
$pendingCount = (int) Order::query()->forMerchant($siteId)->where('payment_status', 'unpaid')->count();
|
||||
$failedCount = (int) Order::query()->forMerchant($siteId)->where('payment_status', 'failed')->count();
|
||||
$completedCount = (int) Order::query()->forMerchant($siteId)->where('status', 'completed')->count();
|
||||
$links = $this->workbenchLinks();
|
||||
$currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== ''));
|
||||
$currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : '');
|
||||
$workbench = [
|
||||
'高金额已支付' => $links['paid_high_amount'],
|
||||
'待支付跟进' => $links['pending_latest'],
|
||||
'支付失败排查' => $links['failed_latest'],
|
||||
'最近完成订单' => $links['completed_latest'],
|
||||
'返回当前筛选视图' => $currentUrl,
|
||||
];
|
||||
$signals = [
|
||||
'待支付订单' => $pendingCount,
|
||||
'支付失败订单' => $failedCount,
|
||||
'已完成订单' => $completedCount,
|
||||
];
|
||||
|
||||
if (($filters['platform'] ?? '') === 'wechat_mini') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信小程序订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_channel'] ?? '') === 'wechat_pay') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看支付失败订单', 'url' => $links['failed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mini-program') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。',
|
||||
'actions' => [
|
||||
['label' => '继续查看小程序环境订单', 'url' => $currentUrl],
|
||||
['label' => '去看微信支付订单', 'url' => $links['failed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mobile-webview') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。',
|
||||
'actions' => [
|
||||
['label' => '继续查看微信内网页订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'mobile') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。',
|
||||
'actions' => [
|
||||
['label' => '继续查看移动浏览器订单', 'url' => $currentUrl],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['device_type'] ?? '') === 'desktop') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。',
|
||||
'actions' => [
|
||||
['label' => '继续查看桌面浏览器订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'failed') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。',
|
||||
'actions' => [
|
||||
['label' => '继续查看支付失败订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'unpaid') {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦待支付订单,建议优先跟进下单未支付用户,并观察支付转化时效。',
|
||||
'actions' => [
|
||||
['label' => '继续查看待支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['payment_status'] ?? '') === 'paid') {
|
||||
return [
|
||||
'headline' => '当前正在查看已支付订单,建议优先关注高金额订单履约进度与异常售后风险。',
|
||||
'actions' => [
|
||||
['label' => '继续查看已支付订单', 'url' => $currentUrl],
|
||||
['label' => '去看最近完成订单', 'url' => $links['completed_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($filters['status'] ?? '') === 'completed') {
|
||||
return [
|
||||
'headline' => '当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。',
|
||||
'actions' => [
|
||||
['label' => '继续查看已完成订单', 'url' => $currentUrl],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_orders'] ?? 0) <= 0) {
|
||||
return [
|
||||
'headline' => '当前站点暂无订单,建议先确认交易链路、支付链路与回写链路是否都已打通。',
|
||||
'actions' => [
|
||||
['label' => '先看订单整体情况', 'url' => $links['paid_high_amount']],
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_orders'] ?? 0) < 5) {
|
||||
return [
|
||||
'headline' => '当前站点已有少量订单沉淀,建议优先关注待支付订单,并同步查看已支付订单质量。',
|
||||
'actions' => [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'headline' => $failedCount > 0
|
||||
? '当前站点订单已形成基础规模,建议优先关注待支付、支付失败与高金额已支付订单。'
|
||||
: '当前站点订单已形成基础规模,建议优先关注待支付与高金额已支付订单,保持交易闭环稳定。',
|
||||
'actions' => $failedCount > 0
|
||||
? [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看支付失败订单', 'url' => $links['failed_latest']],
|
||||
]
|
||||
: [
|
||||
['label' => '去看待支付订单', 'url' => $links['pending_latest']],
|
||||
['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
}
|
||||
699
app/Http/Controllers/SiteAdmin/ProductController.php
Normal file
699
app/Http/Controllers/SiteAdmin/ProductController.php
Normal file
@@ -0,0 +1,699 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SiteAdmin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesSiteContext;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductCategory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
use ResolvesSiteContext;
|
||||
|
||||
protected array $statusOptions = ['draft', 'published', 'offline'];
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$siteId = $this->siteId($request);
|
||||
$site = $this->site($request);
|
||||
$filters = $this->filters($request);
|
||||
$statusStatsFilters = $filters;
|
||||
$statusStatsFilters['status'] = '';
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return view('site_admin.products.index', [
|
||||
'site' => $site,
|
||||
'products' => Product::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(),
|
||||
'summaryStats' => $this->emptySummaryStats(),
|
||||
'statusStats' => $this->emptyStatusStats(),
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'operationsFocus' => $this->buildOperationsFocus($siteId, $this->emptySummaryStats(), $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'filters' => $filters,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statusOptions,
|
||||
'sortOptions' => [
|
||||
'latest' => '最新创建',
|
||||
'price_asc' => '价格从低到高',
|
||||
'price_desc' => '价格从高到低',
|
||||
'stock_asc' => '库存从低到高',
|
||||
'stock_desc' => '库存从高到低',
|
||||
],
|
||||
],
|
||||
'categories' => ProductCategory::query()->forMerchant($siteId)->orderBy('sort')->orderBy('id')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
$summaryStats = $this->buildSummaryStats(
|
||||
$this->applyFilters(Product::query()->forMerchant($siteId), $statusStatsFilters)
|
||||
);
|
||||
|
||||
return view('site_admin.products.index', [
|
||||
'site' => $site,
|
||||
'products' => $this->applySorting(
|
||||
$this->applyFilters(Product::query()->with('category')->forMerchant($siteId), $filters),
|
||||
$filters
|
||||
)->paginate(10)->withQueryString(),
|
||||
'summaryStats' => $summaryStats,
|
||||
'statusStats' => $this->buildStatusStats(
|
||||
$this->applyFilters(Product::query()->forMerchant($siteId), $statusStatsFilters)
|
||||
),
|
||||
'activeFilterSummary' => $this->buildActiveFilterSummary($filters),
|
||||
'operationsFocus' => $this->buildOperationsFocus($siteId, $summaryStats, $filters),
|
||||
'workbenchLinks' => $this->workbenchLinks(),
|
||||
'filters' => $filters,
|
||||
'statusLabels' => $this->statusLabels(),
|
||||
'filterOptions' => [
|
||||
'statuses' => $this->statusOptions,
|
||||
'sortOptions' => [
|
||||
'latest' => '最新创建',
|
||||
'price_asc' => '价格从低到高',
|
||||
'price_desc' => '价格从高到低',
|
||||
'stock_asc' => '库存从低到高',
|
||||
'stock_desc' => '库存从高到低',
|
||||
],
|
||||
],
|
||||
'categories' => ProductCategory::query()->forMerchant($siteId)->orderBy('sort')->orderBy('id')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$siteId = $this->siteId($request);
|
||||
$filters = $this->filters($request);
|
||||
|
||||
if ($filters['has_validation_error'] ?? false) {
|
||||
return redirect('/site-admin/products?' . http_build_query($this->exportableFilters($filters)))
|
||||
->withErrors($filters['validation_errors'] ?? ['商品筛选条件不合法,请先修正后再导出。']);
|
||||
}
|
||||
|
||||
$fileName = 'site_' . $siteId . '_products_' . now()->format('Ymd_His') . '.csv';
|
||||
|
||||
$exportSummary = $this->buildSummaryStats(
|
||||
$this->applyFilters(Product::query()->forMerchant($siteId), $filters)
|
||||
);
|
||||
|
||||
return response()->streamDownload(function () use ($siteId, $filters, $exportSummary) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($handle, ['导出信息', '站点商品导出']);
|
||||
fputcsv($handle, ['站点ID', $siteId]);
|
||||
fputcsv($handle, ['关键词', $filters['keyword'] !== '' ? $filters['keyword'] : '全部']);
|
||||
fputcsv($handle, ['状态', $this->statusLabel($filters['status'] ?? '')]);
|
||||
fputcsv($handle, ['分类', $this->categoryLabel($filters['category_id'] ?? '')]);
|
||||
fputcsv($handle, ['最低价格', $filters['min_price'] !== '' && is_numeric($filters['min_price']) ? ('¥' . number_format((float) $filters['min_price'], 2, '.', '')) : '全部']);
|
||||
fputcsv($handle, ['最高价格', $filters['max_price'] !== '' && is_numeric($filters['max_price']) ? ('¥' . number_format((float) $filters['max_price'], 2, '.', '')) : '全部']);
|
||||
fputcsv($handle, ['最低库存', $filters['min_stock'] !== '' ? ($filters['min_stock'] . ' 件') : '全部']);
|
||||
fputcsv($handle, ['最高库存', $filters['max_stock'] !== '' ? ($filters['max_stock'] . ' 件') : '全部']);
|
||||
fputcsv($handle, ['排序', $this->sortLabel($filters['sort'] ?? 'latest')]);
|
||||
fputcsv($handle, ['导出商品数', $exportSummary['total_products'] ?? 0]);
|
||||
fputcsv($handle, ['导出总库存', $exportSummary['total_stock'] ?? 0]);
|
||||
fputcsv($handle, ['导出总货值', number_format((float) ($exportSummary['total_stock_value'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, ['导出平均售价', number_format((float) ($exportSummary['average_price'] ?? 0), 2, '.', '')]);
|
||||
fputcsv($handle, []);
|
||||
|
||||
fputcsv($handle, [
|
||||
'ID',
|
||||
'分类ID',
|
||||
'分类名称',
|
||||
'商品标题',
|
||||
'商品Slug',
|
||||
'SKU',
|
||||
'售价',
|
||||
'划线价',
|
||||
'库存',
|
||||
'状态',
|
||||
'商品简介',
|
||||
'创建时间',
|
||||
'更新时间',
|
||||
]);
|
||||
|
||||
foreach ($this->applySorting($this->applyFilters(Product::query()->with('category')->forMerchant($siteId), $filters), $filters)->cursor() as $product) {
|
||||
fputcsv($handle, [
|
||||
$product->id,
|
||||
$product->category_id,
|
||||
$product->category?->name ?? '',
|
||||
$product->title,
|
||||
$product->slug,
|
||||
$product->sku,
|
||||
number_format((float) $product->price, 2, '.', ''),
|
||||
number_format((float) $product->original_price, 2, '.', ''),
|
||||
$product->stock,
|
||||
$this->statusLabel($product->status),
|
||||
$product->summary,
|
||||
optional($product->created_at)?->format('Y-m-d H:i:s'),
|
||||
optional($product->updated_at)?->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function filters(Request $request): array
|
||||
{
|
||||
$minPrice = trim((string) $request->string('min_price'));
|
||||
$maxPrice = trim((string) $request->string('max_price'));
|
||||
$minStock = trim((string) $request->string('min_stock'));
|
||||
$maxStock = trim((string) $request->string('max_stock'));
|
||||
$validationErrors = [];
|
||||
|
||||
if ($minPrice !== '' && ! is_numeric($minPrice)) {
|
||||
$validationErrors[] = '最低价格必须为数字。';
|
||||
}
|
||||
|
||||
if ($maxPrice !== '' && ! is_numeric($maxPrice)) {
|
||||
$validationErrors[] = '最高价格必须为数字。';
|
||||
}
|
||||
|
||||
if ($minPrice !== '' && $maxPrice !== '' && is_numeric($minPrice) && is_numeric($maxPrice) && (float) $minPrice > (float) $maxPrice) {
|
||||
$validationErrors[] = '最低价格不能大于最高价格。';
|
||||
}
|
||||
|
||||
if ($minStock !== '' && filter_var($minStock, FILTER_VALIDATE_INT) === false) {
|
||||
$validationErrors[] = '最低库存必须为整数。';
|
||||
}
|
||||
|
||||
if ($maxStock !== '' && filter_var($maxStock, FILTER_VALIDATE_INT) === false) {
|
||||
$validationErrors[] = '最高库存必须为整数。';
|
||||
}
|
||||
|
||||
if ($minStock !== '' && $maxStock !== '' && filter_var($minStock, FILTER_VALIDATE_INT) !== false && filter_var($maxStock, FILTER_VALIDATE_INT) !== false && (int) $minStock > (int) $maxStock) {
|
||||
$validationErrors[] = '最低库存不能大于最高库存。';
|
||||
}
|
||||
|
||||
return [
|
||||
'keyword' => trim((string) $request->string('keyword')),
|
||||
'status' => trim((string) $request->string('status')),
|
||||
'category_id' => trim((string) $request->string('category_id')),
|
||||
'min_price' => $minPrice,
|
||||
'max_price' => $maxPrice,
|
||||
'min_stock' => $minStock,
|
||||
'max_stock' => $maxStock,
|
||||
'sort' => trim((string) $request->string('sort', 'latest')),
|
||||
'validation_errors' => $validationErrors,
|
||||
'has_validation_error' => ! empty($validationErrors),
|
||||
];
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
return $query
|
||||
->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) {
|
||||
$subQuery->where('title', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('sku', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('slug', 'like', '%' . $filters['keyword'] . '%');
|
||||
}))
|
||||
->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status']))
|
||||
->when(($filters['category_id'] ?? '') !== '', fn ($builder) => $builder->where('category_id', $filters['category_id']))
|
||||
->when(($filters['min_price'] ?? '') !== '' && is_numeric($filters['min_price']), fn ($builder) => $builder->where('price', '>=', $filters['min_price']))
|
||||
->when(($filters['max_price'] ?? '') !== '' && is_numeric($filters['max_price']), fn ($builder) => $builder->where('price', '<=', $filters['max_price']))
|
||||
->when(($filters['min_stock'] ?? '') !== '' && filter_var($filters['min_stock'], FILTER_VALIDATE_INT) !== false, fn ($builder) => $builder->where('stock', '>=', (int) $filters['min_stock']))
|
||||
->when(($filters['max_stock'] ?? '') !== '' && filter_var($filters['max_stock'], FILTER_VALIDATE_INT) !== false, fn ($builder) => $builder->where('stock', '<=', (int) $filters['max_stock']));
|
||||
}
|
||||
|
||||
protected function applySorting(Builder $query, array $filters): Builder
|
||||
{
|
||||
return match ($filters['sort'] ?? 'latest') {
|
||||
'price_asc' => $query->orderBy('price')->orderByDesc('id'),
|
||||
'price_desc' => $query->orderByDesc('price')->orderByDesc('id'),
|
||||
'stock_asc' => $query->orderBy('stock')->orderByDesc('id'),
|
||||
'stock_desc' => $query->orderByDesc('stock')->orderByDesc('id'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildSummaryStats(Builder $query): array
|
||||
{
|
||||
$summary = (clone $query)
|
||||
->selectRaw('COUNT(*) as total_products')
|
||||
->selectRaw('COALESCE(SUM(stock), 0) as total_stock')
|
||||
->selectRaw('COALESCE(SUM(price * stock), 0) as total_stock_value')
|
||||
->selectRaw('COALESCE(AVG(price), 0) as average_price')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_products' => (int) ($summary->total_products ?? 0),
|
||||
'total_stock' => (int) ($summary->total_stock ?? 0),
|
||||
'total_stock_value' => (float) ($summary->total_stock_value ?? 0),
|
||||
'average_price' => (float) ($summary->average_price ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildStatusStats(Builder $query): array
|
||||
{
|
||||
$counts = (clone $query)
|
||||
->selectRaw('status, COUNT(*) as aggregate')
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status');
|
||||
|
||||
$stats = ['all' => (int) $counts->sum()];
|
||||
|
||||
foreach ($this->statusOptions as $status) {
|
||||
$stats[$status] = (int) ($counts[$status] ?? 0);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function emptySummaryStats(): array
|
||||
{
|
||||
return [
|
||||
'total_products' => 0,
|
||||
'total_stock' => 0,
|
||||
'total_stock_value' => 0,
|
||||
'average_price' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function emptyStatusStats(): array
|
||||
{
|
||||
$stats = ['all' => 0];
|
||||
|
||||
foreach ($this->statusOptions as $status) {
|
||||
$stats[$status] = 0;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
protected function exportableFilters(array $filters): array
|
||||
{
|
||||
return [
|
||||
'keyword' => $filters['keyword'] ?? '',
|
||||
'status' => $filters['status'] ?? '',
|
||||
'category_id' => $filters['category_id'] ?? '',
|
||||
'min_price' => $filters['min_price'] ?? '',
|
||||
'max_price' => $filters['max_price'] ?? '',
|
||||
'min_stock' => $filters['min_stock'] ?? '',
|
||||
'max_stock' => $filters['max_stock'] ?? '',
|
||||
'sort' => $filters['sort'] ?? 'latest',
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildActiveFilterSummary(array $filters): array
|
||||
{
|
||||
return [
|
||||
'关键词' => $this->displayTextValue($filters['keyword'] ?? '', '全部'),
|
||||
'状态' => $this->statusLabel($filters['status'] ?? ''),
|
||||
'分类' => $this->categoryLabel($filters['category_id'] ?? ''),
|
||||
'价格区间' => $this->formatMoneyRange($filters['min_price'] ?? '', $filters['max_price'] ?? ''),
|
||||
'库存区间' => $this->formatStockRange($filters['min_stock'] ?? '', $filters['max_stock'] ?? ''),
|
||||
'排序' => $this->sortLabel($filters['sort'] ?? 'latest'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabels(): array
|
||||
{
|
||||
return [
|
||||
'draft' => '草稿',
|
||||
'published' => '已上架',
|
||||
'offline' => '已下架',
|
||||
];
|
||||
}
|
||||
|
||||
protected function statusLabel(string $status): string
|
||||
{
|
||||
return $this->statusLabels()[$status] ?? '全部';
|
||||
}
|
||||
|
||||
protected function categoryLabel(string $categoryId): string
|
||||
{
|
||||
if ($categoryId === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
$category = ProductCategory::query()->find($categoryId);
|
||||
|
||||
return $category?->name ?? ('分类 #' . $categoryId);
|
||||
}
|
||||
|
||||
protected function formatMoneyRange(string $min, string $max): string
|
||||
{
|
||||
if ($min === '' && $max === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
$minLabel = $min !== '' && is_numeric($min)
|
||||
? ('¥' . number_format((float) $min, 2, '.', ''))
|
||||
: '不限';
|
||||
$maxLabel = $max !== '' && is_numeric($max)
|
||||
? ('¥' . number_format((float) $max, 2, '.', ''))
|
||||
: '不限';
|
||||
|
||||
return $minLabel . ' ~ ' . $maxLabel;
|
||||
}
|
||||
|
||||
protected function formatStockRange(string $min, string $max): string
|
||||
{
|
||||
if ($min === '' && $max === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
$minLabel = $min !== '' ? $min : '不限';
|
||||
$maxLabel = $max !== '' ? $max : '不限';
|
||||
|
||||
return $minLabel . ' ~ ' . $maxLabel . ' 件';
|
||||
}
|
||||
|
||||
protected function sortLabel(string $sort): string
|
||||
{
|
||||
return match ($sort) {
|
||||
'price_asc' => '价格从低到高',
|
||||
'price_desc' => '价格从高到低',
|
||||
'stock_asc' => '库存从低到高',
|
||||
'stock_desc' => '库存从高到低',
|
||||
default => '最新创建',
|
||||
};
|
||||
}
|
||||
|
||||
protected function displayTextValue(string $value, string $default = '未设置'): string
|
||||
{
|
||||
return $value === '' ? $default : $value;
|
||||
}
|
||||
|
||||
protected function displayMoneyValue(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '全部';
|
||||
}
|
||||
|
||||
return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value;
|
||||
}
|
||||
|
||||
protected function displayStockValue(string $value): string
|
||||
{
|
||||
return $value === '' ? '全部' : ($value . ' 件');
|
||||
}
|
||||
|
||||
protected function workbenchLinks(): array
|
||||
{
|
||||
return [
|
||||
'published_stock_desc' => '/site-admin/products?sort=stock_desc&status=published',
|
||||
'published_stock_asc' => '/site-admin/products?sort=stock_asc&status=published',
|
||||
'latest' => '/site-admin/products?sort=latest',
|
||||
'draft' => '/site-admin/products?status=draft&sort=latest',
|
||||
'current' => '/site-admin/products',
|
||||
];
|
||||
}
|
||||
|
||||
protected function hasCategoryFilter(array $filters): bool
|
||||
{
|
||||
return ($filters['category_id'] ?? '') !== '';
|
||||
}
|
||||
|
||||
protected function hasKeywordFilter(array $filters): bool
|
||||
{
|
||||
return ($filters['keyword'] ?? '') !== '';
|
||||
}
|
||||
|
||||
protected function hasPriceRangeFilter(array $filters): bool
|
||||
{
|
||||
return (($filters['min_price'] ?? '') !== '') || (($filters['max_price'] ?? '') !== '');
|
||||
}
|
||||
|
||||
protected function hasPublishedStockFocus(array $filters): bool
|
||||
{
|
||||
return ($filters['status'] ?? '') === 'published'
|
||||
&& ((($filters['max_stock'] ?? '') !== '')
|
||||
|| ((($filters['min_stock'] ?? '') !== '')
|
||||
&& is_numeric($filters['min_stock'])
|
||||
&& (int) $filters['min_stock'] <= 20));
|
||||
}
|
||||
|
||||
protected function buildOperationsFocusResponse(string $headline, array $actions, array $workbench, array $signals): array
|
||||
{
|
||||
return [
|
||||
'headline' => $headline,
|
||||
'actions' => $actions,
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildOperationsFocus(int $siteId, array $summaryStats, array $filters): array
|
||||
{
|
||||
$publishedCount = (int) Product::query()->forMerchant($siteId)->where('status', 'published')->count();
|
||||
$lowStockCount = (int) Product::query()->forMerchant($siteId)->where('status', 'published')->where('stock', '<=', 20)->count();
|
||||
$categoryCount = (int) ProductCategory::query()->forMerchant($siteId)->count();
|
||||
$links = $this->workbenchLinks();
|
||||
$currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== ''));
|
||||
$currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : '');
|
||||
$workbench = [
|
||||
'高库存已上架' => $links['published_stock_desc'],
|
||||
'低库存补货' => $links['published_stock_asc'],
|
||||
'最近新增' => $links['latest'],
|
||||
'草稿待整理' => $links['draft'],
|
||||
'返回当前筛选视图' => $currentUrl,
|
||||
];
|
||||
$signals = [
|
||||
'已上架商品' => $publishedCount,
|
||||
'低库存商品' => $lowStockCount,
|
||||
'分类覆盖数' => $categoryCount,
|
||||
];
|
||||
|
||||
$isPublished = ($filters['status'] ?? '') === 'published';
|
||||
$hasCategoryFilter = $this->hasCategoryFilter($filters);
|
||||
$hasKeywordFilter = $this->hasKeywordFilter($filters);
|
||||
$hasPriceRangeFilter = $this->hasPriceRangeFilter($filters);
|
||||
$categoryLabel = $hasCategoryFilter ? $this->categoryLabel((string) ($filters['category_id'] ?? '')) : '';
|
||||
$keyword = (string) ($filters['keyword'] ?? '');
|
||||
$priceRange = $hasPriceRangeFilter ? $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')) : '';
|
||||
$categoryUrl = $links['current'] . '?category_id=' . ($filters['category_id'] ?? '');
|
||||
$categoryKeywordUrl = $categoryUrl . '&keyword=' . urlencode($keyword);
|
||||
$publishedCategoryUrl = $links['current'] . '?status=published&category_id=' . ($filters['category_id'] ?? '');
|
||||
$publishedKeywordUrl = $links['current'] . '?status=published&keyword=' . urlencode($keyword);
|
||||
$publishedCategoryKeywordUrl = $publishedCategoryUrl . '&keyword=' . urlencode($keyword);
|
||||
|
||||
if (($filters['status'] ?? '') === 'draft') {
|
||||
return $this->buildOperationsFocusResponse(
|
||||
'当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。',
|
||||
[
|
||||
['label' => '继续查看当前草稿', 'url' => $currentUrl],
|
||||
['label' => '去看已上架商品', 'url' => $links['published_stock_desc']],
|
||||
],
|
||||
$workbench,
|
||||
$signals,
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasPublishedStockFocus($filters)) {
|
||||
return $this->buildOperationsFocusResponse(
|
||||
'当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。',
|
||||
[
|
||||
['label' => '继续查看当前库存视角', 'url' => $currentUrl],
|
||||
['label' => '去看高库存商品', 'url' => $links['published_stock_desc']],
|
||||
],
|
||||
$workbench,
|
||||
$signals,
|
||||
);
|
||||
}
|
||||
|
||||
if ($isPublished && $hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对在售商品命名、分类承接、价格梯度与搜索结果是否一致,并同步观察库存结构是否健康。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前已上架分类关键词价格带商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前已上架分类关键词商品', 'url' => $publishedCategoryKeywordUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPublished && $hasCategoryFilter && $hasKeywordFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对在售商品命名、分类承接与搜索结果是否一致,并同步观察价格带与库存结构是否健康。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前已上架分类关键词商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前已上架分类商品', 'url' => $publishedCategoryUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPublished && $hasCategoryFilter && $hasPriceRangeFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类在售商品的价格结构、库存分布与承接效率是否协调。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前已上架分类价格带商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前已上架分类商品', 'url' => $publishedCategoryUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPublished && $hasKeywordFilter && $hasPriceRangeFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构与搜索承接是否健康。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前已上架关键词价格带商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前已上架关键词商品', 'url' => $publishedKeywordUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPublished && $hasKeywordFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的结果,建议优先核对在售商品命名、卖点表达与搜索承接是否一致,并同步观察价格带与库存结构是否健康。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前已上架关键词商品', 'url' => $currentUrl],
|
||||
['label' => '去看全部已上架商品', 'url' => $links['published_stock_desc']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构与搜索承接是否健康。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前分类关键词价格带商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前分类关键词商品', 'url' => $categoryKeywordUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasCategoryFilter && $hasKeywordFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对分类承接、命名卖点与搜索结果是否一致,并同步观察相关商品的价格带与库存结构。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前分类关键词商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前分类商品', 'url' => $categoryUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasCategoryFilter && $hasPriceRangeFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布与承接效率是否健康。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前分类价格带商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前分类商品', 'url' => $categoryUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPublished && $hasCategoryFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类商品,建议优先核对该分类在售商品的价格带、库存结构与承接质量是否均衡。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前已上架分类商品', 'url' => $currentUrl],
|
||||
['label' => '去看当前分类商品', 'url' => $categoryUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasCategoryFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类商品,建议优先核对分类承接是否准确,并同步观察价格带与库存结构是否均衡。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前分类商品', 'url' => $currentUrl],
|
||||
['label' => '去看低库存商品', 'url' => $links['published_stock_asc']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasKeywordFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦关键词“' . $keyword . '”命中的商品,建议优先核对命名、卖点与搜索承接是否一致,并同步观察相关商品的价格带与库存结构。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前关键词商品', 'url' => $currentUrl],
|
||||
['label' => '去看最近新增商品', 'url' => $links['latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasPriceRangeFilter) {
|
||||
return [
|
||||
'headline' => '当前筛选已聚焦价格带 ' . $priceRange . ' 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构与转化表现是否匹配。',
|
||||
'actions' => [
|
||||
['label' => '继续查看当前价格带商品', 'url' => $currentUrl],
|
||||
['label' => '去看最近新增商品', 'url' => $links['latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPublished) {
|
||||
return [
|
||||
'headline' => '当前正在查看已上架商品,建议优先关注库存结构、价格带与分类覆盖是否均衡。',
|
||||
'actions' => [
|
||||
['label' => '去看低库存商品', 'url' => $links['published_stock_asc']],
|
||||
['label' => '继续查看已上架商品', 'url' => $currentUrl],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_products'] ?? 0) <= 0) {
|
||||
return [
|
||||
'headline' => '当前站点暂无商品,建议先补齐基础商品数据,再开始做上架与库存运营。',
|
||||
'actions' => [
|
||||
['label' => '先看商品空白情况', 'url' => $links['latest']],
|
||||
['label' => '查看草稿商品', 'url' => $links['draft']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
if (($summaryStats['total_products'] ?? 0) < 3) {
|
||||
return [
|
||||
'headline' => '当前站点商品仍较少,建议优先查看最近新增与已上架商品,确认基础信息是否完整。',
|
||||
'actions' => [
|
||||
['label' => '去看最近新增商品', 'url' => $links['latest']],
|
||||
['label' => '去看已上架商品', 'url' => $links['published_stock_desc']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'headline' => $lowStockCount > 0
|
||||
? '当前站点商品已形成基础规模,建议优先巡检低库存商品,并同步关注高库存结构是否均衡。'
|
||||
: '当前站点商品已形成基础规模,建议优先关注高库存结构与最近新增商品质量。',
|
||||
'actions' => $lowStockCount > 0
|
||||
? [
|
||||
['label' => '去看低库存商品', 'url' => $links['published_stock_asc']],
|
||||
['label' => '去看高库存商品', 'url' => $links['published_stock_desc']],
|
||||
]
|
||||
: [
|
||||
['label' => '去看高库存商品', 'url' => $links['published_stock_desc']],
|
||||
['label' => '去看最近新增商品', 'url' => $links['latest']],
|
||||
],
|
||||
'workbench' => $workbench,
|
||||
'signals' => $signals,
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Http/Controllers/Wechat/MiniProgramController.php
Normal file
18
app/Http/Controllers/Wechat/MiniProgramController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Wechat;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MiniProgramController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'channel' => 'wechat_mini',
|
||||
'message' => '微信小程序接口占位已预留',
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
app/Http/Controllers/Wechat/MpController.php
Normal file
18
app/Http/Controllers/Wechat/MpController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Wechat;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MpController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'channel' => 'wechat_mp',
|
||||
'message' => '微信公众号接口占位已预留',
|
||||
]);
|
||||
}
|
||||
}
|
||||
34
app/Http/Middleware/AdminAuth.php
Normal file
34
app/Http/Middleware/AdminAuth.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Admin;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$adminId = $request->session()->get('admin_id');
|
||||
|
||||
if (! $adminId) {
|
||||
return redirect('/admin/login');
|
||||
}
|
||||
|
||||
$admin = Admin::query()->find($adminId);
|
||||
|
||||
if (! $admin || ! $admin->isPlatformAdmin()) {
|
||||
abort(403, '当前账号没有总台管理访问权限');
|
||||
}
|
||||
|
||||
$request->session()->put('admin_scope', $admin->platformLabel());
|
||||
$request->session()->put('admin_role', $admin->role);
|
||||
$request->session()->put('admin_name', $admin->name);
|
||||
$request->session()->put('admin_email', $admin->email);
|
||||
$request->session()->put('admin_merchant_id', null);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
35
app/Http/Middleware/MerchantAdminAuth.php
Normal file
35
app/Http/Middleware/MerchantAdminAuth.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Admin;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MerchantAdminAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$adminId = $request->session()->get('admin_id');
|
||||
|
||||
if (! $adminId) {
|
||||
return redirect('/merchant-admin/login');
|
||||
}
|
||||
|
||||
$admin = Admin::query()->with('merchant')->find($adminId);
|
||||
|
||||
if (! $admin || ! $admin->isMerchantAdmin()) {
|
||||
abort(403, '当前账号未绑定商家后台访问权限');
|
||||
}
|
||||
|
||||
$request->session()->put('admin_scope', 'merchant');
|
||||
$request->session()->put('admin_role', $admin->role);
|
||||
$request->session()->put('admin_name', $admin->name);
|
||||
$request->session()->put('admin_email', $admin->email);
|
||||
$request->session()->put('admin_merchant_id', $admin->merchantId());
|
||||
$request->session()->put('merchant_name', $admin->merchant?->name);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
36
app/Http/Middleware/SiteAdminAuth.php
Normal file
36
app/Http/Middleware/SiteAdminAuth.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Admin;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SiteAdminAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$adminId = $request->session()->get('admin_id');
|
||||
|
||||
if (! $adminId) {
|
||||
return redirect('/site-admin/login');
|
||||
}
|
||||
|
||||
$admin = Admin::query()->with('merchant')->find($adminId);
|
||||
|
||||
if (! $admin || ! $admin->isMerchantAdmin()) {
|
||||
abort(403, '当前账号未绑定站点后台访问权限');
|
||||
}
|
||||
|
||||
$request->session()->put('admin_scope', 'site');
|
||||
$request->session()->put('admin_role', $admin->role);
|
||||
$request->session()->put('admin_name', $admin->name);
|
||||
$request->session()->put('admin_email', $admin->email);
|
||||
$request->session()->put('admin_merchant_id', $admin->merchantId());
|
||||
$request->session()->put('admin_site_id', $admin->merchantId());
|
||||
$request->session()->put('site_name', $admin->merchant?->name);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
49
app/Models/Admin.php
Normal file
49
app/Models/Admin.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Admin extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'name', 'email', 'phone', 'password', 'role', 'status', 'last_login_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password', 'remember_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_login_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function merchantId(): ?int
|
||||
{
|
||||
return $this->merchant_id ? (int) $this->merchant_id : null;
|
||||
}
|
||||
|
||||
public function isPlatformAdmin(): bool
|
||||
{
|
||||
return $this->merchantId() === null;
|
||||
}
|
||||
|
||||
public function isMerchantAdmin(): bool
|
||||
{
|
||||
return $this->merchantId() !== null;
|
||||
}
|
||||
|
||||
public function platformLabel(): string
|
||||
{
|
||||
return $this->isPlatformAdmin() ? 'platform' : 'merchant';
|
||||
}
|
||||
}
|
||||
23
app/Models/ChannelConfig.php
Normal file
23
app/Models/ChannelConfig.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ChannelConfig extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'channel_code', 'channel_name', 'channel_type', 'status', 'entry_path',
|
||||
'supports_login', 'supports_payment', 'supports_share', 'sort', 'settings', 'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'supports_login' => 'boolean',
|
||||
'supports_payment' => 'boolean',
|
||||
'supports_share' => 'boolean',
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
50
app/Models/Merchant.php
Normal file
50
app/Models/Merchant.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Merchant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'merchants';
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'domain', 'contact_name', 'contact_phone', 'contact_email',
|
||||
'plan', 'status', 'trial_ends_at', 'activated_at', 'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'trial_ends_at' => 'datetime',
|
||||
'activated_at' => 'datetime',
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
public function admins(): HasMany
|
||||
{
|
||||
return $this->hasMany(Admin::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductCategory::class, 'merchant_id');
|
||||
}
|
||||
}
|
||||
30
app/Models/OauthAccount.php
Normal file
30
app/Models/OauthAccount.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OauthAccount extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'merchant_id', 'platform', 'provider', 'openid', 'unionid', 'app_id', 'nickname', 'avatar', 'raw_payload',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw_payload' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
}
|
||||
50
app/Models/Order.php
Normal file
50
app/Models/Order.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'user_id', 'order_no', 'status', 'platform', 'payment_channel', 'payment_status', 'device_type',
|
||||
'product_amount', 'discount_amount', 'shipping_amount', 'pay_amount', 'buyer_name', 'buyer_phone', 'buyer_email',
|
||||
'remark', 'paid_at', 'shipped_at', 'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_amount' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'shipping_amount' => 'decimal:2',
|
||||
'pay_amount' => 'decimal:2',
|
||||
'paid_at' => 'datetime',
|
||||
'shipped_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function scopeForMerchant(Builder $query, int $merchantId): Builder
|
||||
{
|
||||
return $query->where('merchant_id', $merchantId);
|
||||
}
|
||||
}
|
||||
43
app/Models/OrderItem.php
Normal file
43
app/Models/OrderItem.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'order_id', 'product_id', 'product_title', 'product_sku', 'product_price', 'quantity', 'line_total_amount', 'snapshot',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_price' => 'decimal:2',
|
||||
'line_total_amount' => 'decimal:2',
|
||||
'snapshot' => 'array',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function scopeForMerchant(Builder $query, int $merchantId): Builder
|
||||
{
|
||||
return $query->where('merchant_id', $merchantId);
|
||||
}
|
||||
}
|
||||
21
app/Models/PaymentConfig.php
Normal file
21
app/Models/PaymentConfig.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentConfig extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'payment_code', 'payment_name', 'provider', 'status', 'is_sandbox', 'supports_refund', 'settings', 'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_sandbox' => 'boolean',
|
||||
'supports_refund' => 'boolean',
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
33
app/Models/Plan.php
Normal file
33
app/Models/Plan.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Plan extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'code', 'name', 'billing_cycle', 'price', 'list_price', 'status', 'sort', 'description', 'settings', 'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'list_price' => 'decimal:2',
|
||||
'settings' => 'array',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(SiteSubscription::class);
|
||||
}
|
||||
|
||||
public function platformOrders(): HasMany
|
||||
{
|
||||
return $this->hasMany(PlatformOrder::class);
|
||||
}
|
||||
}
|
||||
53
app/Models/PlatformOrder.php
Normal file
53
app/Models/PlatformOrder.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PlatformOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'plan_id', 'site_subscription_id', 'created_by_admin_id', 'order_no', 'order_type', 'status',
|
||||
'payment_status', 'payment_channel', 'plan_name', 'billing_cycle', 'period_months', 'quantity', 'list_amount',
|
||||
'discount_amount', 'payable_amount', 'paid_amount', 'placed_at', 'paid_at', 'activated_at', 'cancelled_at',
|
||||
'refunded_at', 'plan_snapshot', 'meta', 'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'list_amount' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'payable_amount' => 'decimal:2',
|
||||
'paid_amount' => 'decimal:2',
|
||||
'placed_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
'activated_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
'refunded_at' => 'datetime',
|
||||
'plan_snapshot' => 'array',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Plan::class);
|
||||
}
|
||||
|
||||
public function siteSubscription(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SiteSubscription::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Admin::class, 'created_by_admin_id');
|
||||
}
|
||||
}
|
||||
44
app/Models/Product.php
Normal file
44
app/Models/Product.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'category_id', 'title', 'slug', 'sku', 'summary', 'content', 'price', 'original_price', 'stock', 'status', 'images',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'original_price' => 'decimal:2',
|
||||
'images' => 'array',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function scopeForMerchant(Builder $query, int $merchantId): Builder
|
||||
{
|
||||
return $query->where('merchant_id', $merchantId);
|
||||
}
|
||||
}
|
||||
33
app/Models/ProductCategory.php
Normal file
33
app/Models/ProductCategory.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'name', 'slug', 'status', 'sort', 'description',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'category_id');
|
||||
}
|
||||
|
||||
public function scopeForMerchant(Builder $query, int $merchantId): Builder
|
||||
{
|
||||
return $query->where('merchant_id', $merchantId);
|
||||
}
|
||||
}
|
||||
37
app/Models/ProductImportHistory.php
Normal file
37
app/Models/ProductImportHistory.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductImportHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'scope',
|
||||
'merchant_id',
|
||||
'admin_id',
|
||||
'file_name',
|
||||
'success_count',
|
||||
'failed_count',
|
||||
'failure_file',
|
||||
'imported_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'imported_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class);
|
||||
}
|
||||
|
||||
public function admin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Admin::class);
|
||||
}
|
||||
}
|
||||
44
app/Models/SiteSubscription.php
Normal file
44
app/Models/SiteSubscription.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SiteSubscription extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'plan_id', 'status', 'source', 'subscription_no', 'plan_name', 'billing_cycle', 'period_months',
|
||||
'amount', 'starts_at', 'ends_at', 'trial_ends_at', 'activated_at', 'cancelled_at', 'snapshot', 'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'trial_ends_at' => 'datetime',
|
||||
'activated_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
'snapshot' => 'array',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Plan::class);
|
||||
}
|
||||
|
||||
public function platformOrders(): HasMany
|
||||
{
|
||||
return $this->hasMany(PlatformOrder::class);
|
||||
}
|
||||
}
|
||||
19
app/Models/SystemConfig.php
Normal file
19
app/Models/SystemConfig.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SystemConfig extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'config_key', 'config_name', 'config_value', 'value_type', 'autoload', 'group', 'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'autoload' => 'boolean',
|
||||
];
|
||||
}
|
||||
53
app/Models/User.php
Normal file
53
app/Models/User.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'merchant_id', 'name', 'email', 'phone', 'password', 'status',
|
||||
'register_source', 'last_login_source', 'wechat_openid', 'wechat_unionid', 'mini_openid',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function merchant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Merchant::class, 'merchant_id');
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function oauthAccounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(OauthAccount::class);
|
||||
}
|
||||
|
||||
public function scopeForMerchant(Builder $query, int $merchantId): Builder
|
||||
{
|
||||
return $query->where('merchant_id', $merchantId);
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
28
app/Support/ApiResponse.php
Normal file
28
app/Support/ApiResponse.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ApiResponse
|
||||
{
|
||||
public static function success(mixed $data = null, string $message = 'ok', int $code = 0): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
'server_time' => now()->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function error(string $message = 'error', int $code = 1, mixed $data = null, int $httpStatus = 400): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
'server_time' => now()->toDateTimeString(),
|
||||
], $httpStatus);
|
||||
}
|
||||
}
|
||||
183
app/Support/CacheKeys.php
Normal file
183
app/Support/CacheKeys.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class CacheKeys
|
||||
{
|
||||
protected static function filtersSuffix(array $filters = []): string
|
||||
{
|
||||
return empty($filters) ? 'default' : md5(json_encode($filters, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public static function merchantDashboardStats(int $merchantId): string
|
||||
{
|
||||
return "merchant:{$merchantId}:dashboard:stats";
|
||||
}
|
||||
|
||||
public static function merchantProductsVersion(int $merchantId): string
|
||||
{
|
||||
return "merchant:{$merchantId}:products:version";
|
||||
}
|
||||
|
||||
public static function merchantProductsList(int $merchantId, int $page = 1, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::merchantProductsVersion($merchantId), 1);
|
||||
|
||||
return "merchant:{$merchantId}:products:v{$version}:list:page:{$page}:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function merchantProductsStatusStats(int $merchantId, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::merchantProductsVersion($merchantId), 1);
|
||||
|
||||
return "merchant:{$merchantId}:products:v{$version}:status-stats:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function merchantProductsSummary(int $merchantId, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::merchantProductsVersion($merchantId), 1);
|
||||
|
||||
return "merchant:{$merchantId}:products:v{$version}:summary:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformDashboardStats(): string
|
||||
{
|
||||
return 'platform:dashboard:stats';
|
||||
}
|
||||
|
||||
public static function platformProductsVersion(): string
|
||||
{
|
||||
return 'platform:products:version';
|
||||
}
|
||||
|
||||
public static function platformMerchantsList(int $page = 1): string
|
||||
{
|
||||
return "platform:merchants:list:page:{$page}";
|
||||
}
|
||||
|
||||
public static function merchantOrdersVersion(int $merchantId): string
|
||||
{
|
||||
return "merchant:{$merchantId}:orders:version";
|
||||
}
|
||||
|
||||
public static function merchantOrdersList(int $merchantId, int $page = 1, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1);
|
||||
|
||||
return "merchant:{$merchantId}:orders:v{$version}:list:page:{$page}:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function merchantOrdersStatusStats(int $merchantId, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1);
|
||||
|
||||
return "merchant:{$merchantId}:orders:v{$version}:status-stats:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function merchantOrdersSummary(int $merchantId, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1);
|
||||
|
||||
return "merchant:{$merchantId}:orders:v{$version}:summary:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function merchantOrdersTrendSummary(int $merchantId, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1);
|
||||
|
||||
return "merchant:{$merchantId}:orders:v{$version}:trend-summary:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformOrdersVersion(): string
|
||||
{
|
||||
return 'platform:orders:version';
|
||||
}
|
||||
|
||||
public static function platformOrdersList(int $page = 1, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::platformOrdersVersion(), 1);
|
||||
|
||||
return "platform:orders:v{$version}:list:page:{$page}:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformOrdersStatusStats(array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::platformOrdersVersion(), 1);
|
||||
|
||||
return "platform:orders:v{$version}:status-stats:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformOrdersSummary(array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::platformOrdersVersion(), 1);
|
||||
|
||||
return "platform:orders:v{$version}:summary:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformOrdersTrendSummary(array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::platformOrdersVersion(), 1);
|
||||
|
||||
return "platform:orders:v{$version}:trend-summary:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformProductsList(int $page = 1, array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::platformProductsVersion(), 1);
|
||||
|
||||
return "platform:products:v{$version}:list:page:{$page}:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformProductsStatusStats(array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::platformProductsVersion(), 1);
|
||||
|
||||
return "platform:products:v{$version}:status-stats:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function platformProductsSummary(array $filters = []): string
|
||||
{
|
||||
$suffix = self::filtersSuffix($filters);
|
||||
$version = (int) cache()->get(self::platformProductsVersion(), 1);
|
||||
|
||||
return "platform:products:v{$version}:summary:filters:{$suffix}";
|
||||
}
|
||||
|
||||
public static function merchantCategoriesList(int $merchantId, int $page = 1): string
|
||||
{
|
||||
return "merchant:{$merchantId}:categories:list:page:{$page}";
|
||||
}
|
||||
|
||||
public static function merchantUsersList(int $merchantId, int $page = 1): string
|
||||
{
|
||||
return "merchant:{$merchantId}:users:list:page:{$page}";
|
||||
}
|
||||
|
||||
public static function platformCategoriesList(int $page = 1): string
|
||||
{
|
||||
return "platform:categories:list:page:{$page}";
|
||||
}
|
||||
|
||||
public static function platformSystemConfigs(): string
|
||||
{
|
||||
return 'platform:settings:system';
|
||||
}
|
||||
|
||||
public static function platformChannelsOverview(): string
|
||||
{
|
||||
return 'platform:settings:channels';
|
||||
}
|
||||
}
|
||||
138
app/Support/SubscriptionActivationService.php
Normal file
138
app/Support/SubscriptionActivationService.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\PlatformOrder;
|
||||
use App\Models\SiteSubscription;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 订阅激活服务(最小闭环)
|
||||
*
|
||||
* 目标:当平台订单满足“已支付 + 已生效”时,让站点订阅真正生效或续期。
|
||||
*
|
||||
* 约束:
|
||||
* - 只负责业务层状态/时间的联动,不负责支付回执、异步通知等入口。
|
||||
* - 支持两种情况:
|
||||
* 1) 订单已绑定 site_subscription_id => 续期/延长该订阅
|
||||
* 2) 未绑定 => 创建新订阅并回写订单 site_subscription_id
|
||||
*/
|
||||
class SubscriptionActivationService
|
||||
{
|
||||
/**
|
||||
* @throws ModelNotFoundException
|
||||
*/
|
||||
public function activateOrder(int $orderId, ?int $adminId = null): SiteSubscription
|
||||
{
|
||||
/** @var PlatformOrder $order */
|
||||
$order = PlatformOrder::query()->findOrFail($orderId);
|
||||
|
||||
// 最小校验:必须已支付 + 已生效
|
||||
if ($order->payment_status !== 'paid' || $order->status !== 'activated') {
|
||||
throw new \InvalidArgumentException('订单未满足生效条件:需 payment_status=paid 且 status=activated');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$months = max(1, (int) $order->period_months) * max(1, (int) ($order->quantity ?? 1));
|
||||
|
||||
// 幂等保护:若该订单已同步过订阅,则直接返回对应订阅(避免重复点击导致无限续期)
|
||||
$activationMeta = (array) data_get($order->meta ?? [], 'subscription_activation', []);
|
||||
$activatedSubscriptionId = (int) ($activationMeta['subscription_id'] ?? 0);
|
||||
if ($activatedSubscriptionId > 0) {
|
||||
$subscription = SiteSubscription::query()->find($activatedSubscriptionId);
|
||||
if ($subscription) {
|
||||
return $subscription;
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅快照:优先使用订单上记录的 plan_name / billing_cycle / plan_snapshot
|
||||
$snapshot = [
|
||||
'from_order_id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'order_type' => $order->order_type,
|
||||
'plan_snapshot' => $order->plan_snapshot,
|
||||
];
|
||||
|
||||
if ($order->site_subscription_id) {
|
||||
/** @var SiteSubscription $subscription */
|
||||
$subscription = SiteSubscription::query()->findOrFail($order->site_subscription_id);
|
||||
|
||||
// 以 ends_at 为基准续期:
|
||||
// - 若 ends_at 为空或已过期 => 从 now 起算
|
||||
// - 若仍有效 => 从 ends_at 起算
|
||||
$base = $subscription->ends_at && $subscription->ends_at->greaterThan($now)
|
||||
? $subscription->ends_at->copy()
|
||||
: $now->copy();
|
||||
|
||||
$newEndsAt = $base->copy()->addMonthsNoOverflow($months);
|
||||
|
||||
// starts_at:若为空则补齐
|
||||
$startsAt = $subscription->starts_at ?: $now->copy();
|
||||
|
||||
$subscription->fill([
|
||||
'status' => 'activated',
|
||||
'plan_id' => $order->plan_id,
|
||||
'plan_name' => $order->plan_name,
|
||||
'billing_cycle' => $order->billing_cycle,
|
||||
'period_months' => (int) $order->period_months,
|
||||
'amount' => $order->paid_amount > 0 ? $order->paid_amount : $order->payable_amount,
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $newEndsAt,
|
||||
'activated_at' => $subscription->activated_at ?: $now,
|
||||
'snapshot' => array_merge((array) ($subscription->snapshot ?? []), $snapshot),
|
||||
]);
|
||||
$subscription->save();
|
||||
|
||||
// 写入订单 meta,标记该订单已完成订阅同步(幂等)
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
data_set($meta, 'subscription_activation', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'synced_at' => $now->toDateTimeString(),
|
||||
'admin_id' => $adminId,
|
||||
]);
|
||||
$order->meta = $meta;
|
||||
$order->save();
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
// 创建新订阅
|
||||
$subscriptionNo = 'SUB' . $now->format('YmdHis') . Str::padLeft((string) random_int(1, 9999), 4, '0');
|
||||
|
||||
$subscription = SiteSubscription::query()->create([
|
||||
'merchant_id' => $order->merchant_id,
|
||||
'plan_id' => $order->plan_id,
|
||||
'status' => 'activated',
|
||||
'source' => 'platform_order',
|
||||
'subscription_no' => $subscriptionNo,
|
||||
'plan_name' => $order->plan_name,
|
||||
'billing_cycle' => $order->billing_cycle,
|
||||
'period_months' => (int) $order->period_months,
|
||||
'amount' => $order->paid_amount > 0 ? $order->paid_amount : $order->payable_amount,
|
||||
'starts_at' => $now,
|
||||
'ends_at' => $now->copy()->addMonthsNoOverflow($months),
|
||||
'activated_at' => $now,
|
||||
'snapshot' => $snapshot,
|
||||
'meta' => [
|
||||
'activated_by_admin_id' => $adminId,
|
||||
],
|
||||
]);
|
||||
|
||||
// 回写订单 + 写入 meta 标记同步完成(幂等)
|
||||
$order->site_subscription_id = $subscription->id;
|
||||
$order->activated_at = $order->activated_at ?: $now;
|
||||
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
data_set($meta, 'subscription_activation', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'synced_at' => $now->toDateTimeString(),
|
||||
'admin_id' => $adminId,
|
||||
]);
|
||||
$order->meta = $meta;
|
||||
|
||||
$order->save();
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
}
|
||||
18
artisan
Normal file
18
artisan
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
23
bootstrap/app.php
Normal file
23
bootstrap/app.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'admin.auth' => \App\Http\Middleware\AdminAuth::class,
|
||||
'merchant.admin.auth' => \App\Http\Middleware\MerchantAdminAuth::class,
|
||||
'site.admin.auth' => \App\Http\Middleware\SiteAdminAuth::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
2
bootstrap/cache/.gitignore
vendored
Executable file
2
bootstrap/cache/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
5
bootstrap/providers.php
Normal file
5
bootstrap/providers.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
89
composer.json
Normal file
89
composer.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
],
|
||||
"db:sql-migrate": [
|
||||
"php scripts/sql_migrate.php"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
8397
composer.lock
generated
Normal file
8397
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
115
config/auth.php
Normal file
115
config/auth.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
117
config/cache.php
Normal file
117
config/cache.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "octane",
|
||||
| "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
];
|
||||
183
config/database.php
Normal file
183
config/database.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
];
|
||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
217
config/session.php
Normal file
217
config/session.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
];
|
||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
52
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
52
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('merchant_id')->nullable()->index();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->string('phone', 30)->nullable();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->string('status', 30)->default('active');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('merchants', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('domain')->nullable()->unique();
|
||||
$table->string('contact_name')->nullable();
|
||||
$table->string('contact_phone', 30)->nullable();
|
||||
$table->string('contact_email')->nullable();
|
||||
$table->string('plan', 50)->default('basic');
|
||||
$table->string('status', 30)->default('active');
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->timestamp('activated_at')->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('merchants');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('admins', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('merchant_id')->nullable()->constrained('merchants')->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->string('phone', 30)->nullable();
|
||||
$table->string('password');
|
||||
$table->string('role', 30)->default('owner');
|
||||
$table->string('status', 30)->default('active');
|
||||
$table->timestamp('last_login_at')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('admins');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('order_no', 50)->unique();
|
||||
$table->string('status', 30)->default('pending');
|
||||
$table->decimal('product_amount', 10, 2)->default(0);
|
||||
$table->decimal('discount_amount', 10, 2)->default(0);
|
||||
$table->decimal('shipping_amount', 10, 2)->default(0);
|
||||
$table->decimal('pay_amount', 10, 2)->default(0);
|
||||
$table->string('buyer_name')->nullable();
|
||||
$table->string('buyer_phone', 30)->nullable();
|
||||
$table->string('buyer_email')->nullable();
|
||||
$table->text('remark')->nullable();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->timestamp('shipped_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('orders');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete();
|
||||
$table->string('title');
|
||||
$table->string('slug');
|
||||
$table->string('sku', 80)->unique();
|
||||
$table->text('summary')->nullable();
|
||||
$table->longText('content')->nullable();
|
||||
$table->decimal('price', 10, 2)->default(0);
|
||||
$table->decimal('original_price', 10, 2)->nullable();
|
||||
$table->integer('stock')->default(0);
|
||||
$table->string('status', 30)->default('draft');
|
||||
$table->json('images')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['merchant_id', 'slug']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('register_source', 30)->default('pc')->after('status');
|
||||
$table->string('last_login_source', 30)->nullable()->after('register_source');
|
||||
$table->string('wechat_openid', 64)->nullable()->after('last_login_source');
|
||||
$table->string('wechat_unionid', 64)->nullable()->after('wechat_openid');
|
||||
$table->string('mini_openid', 64)->nullable()->after('wechat_unionid');
|
||||
});
|
||||
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->string('platform', 30)->default('pc')->after('status');
|
||||
$table->string('payment_channel', 30)->nullable()->after('platform');
|
||||
$table->string('payment_status', 30)->default('unpaid')->after('payment_channel');
|
||||
$table->string('device_type', 30)->nullable()->after('payment_status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn(['register_source', 'last_login_source', 'wechat_openid', 'wechat_unionid', 'mini_openid']);
|
||||
});
|
||||
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['platform', 'payment_channel', 'payment_status', 'device_type']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_accounts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('merchant_id')->nullable()->constrained('merchants')->nullOnDelete();
|
||||
$table->string('platform', 30);
|
||||
$table->string('provider', 30);
|
||||
$table->string('openid', 100)->nullable();
|
||||
$table->string('unionid', 100)->nullable();
|
||||
$table->string('app_id', 100)->nullable();
|
||||
$table->string('nickname')->nullable();
|
||||
$table->string('avatar')->nullable();
|
||||
$table->json('raw_payload')->nullable();
|
||||
$table->timestamps();
|
||||
$table->index(['platform', 'provider']);
|
||||
$table->unique(['provider', 'openid']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_accounts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('system_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('config_key')->unique();
|
||||
$table->string('config_name');
|
||||
$table->text('config_value')->nullable();
|
||||
$table->string('value_type', 30)->default('string');
|
||||
$table->boolean('autoload')->default(true);
|
||||
$table->string('group', 50)->default('system')->index();
|
||||
$table->string('remark')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('system_configs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('channel_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('channel_code', 50)->unique();
|
||||
$table->string('channel_name');
|
||||
$table->string('channel_type', 50)->default('sales');
|
||||
$table->string('status', 30)->default('enabled')->index();
|
||||
$table->string('entry_path')->nullable();
|
||||
$table->boolean('supports_login')->default(false);
|
||||
$table->boolean('supports_payment')->default(false);
|
||||
$table->boolean('supports_share')->default(false);
|
||||
$table->unsignedInteger('sort')->default(0);
|
||||
$table->json('settings')->nullable();
|
||||
$table->string('remark')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('channel_configs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payment_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('payment_code', 50)->unique();
|
||||
$table->string('payment_name');
|
||||
$table->string('provider', 50);
|
||||
$table->string('status', 30)->default('disabled')->index();
|
||||
$table->boolean('is_sandbox')->default(true);
|
||||
$table->boolean('supports_refund')->default(false);
|
||||
$table->json('settings')->nullable();
|
||||
$table->string('remark')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payment_configs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug');
|
||||
$table->string('status', 30)->default('active');
|
||||
$table->unsignedInteger('sort')->default(0);
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['merchant_id', 'slug']);
|
||||
});
|
||||
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->foreignId('category_id')->nullable()->after('merchant_id')->constrained('product_categories')->nullOnDelete();
|
||||
$table->index(['merchant_id', 'category_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('category_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('product_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('order_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete();
|
||||
$table->foreignId('order_id')->constrained('orders')->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete();
|
||||
$table->string('product_title');
|
||||
$table->string('product_sku', 80)->nullable();
|
||||
$table->decimal('product_price', 10, 2)->default(0);
|
||||
$table->unsignedInteger('quantity')->default(1);
|
||||
$table->decimal('line_total_amount', 10, 2)->default(0);
|
||||
$table->json('snapshot')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['merchant_id', 'order_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('order_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_import_histories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('scope', 30);
|
||||
$table->foreignId('merchant_id')->nullable()->constrained('merchants')->nullOnDelete();
|
||||
$table->foreignId('admin_id')->nullable()->constrained('admins')->nullOnDelete();
|
||||
$table->string('file_name');
|
||||
$table->unsignedInteger('success_count')->default(0);
|
||||
$table->unsignedInteger('failed_count')->default(0);
|
||||
$table->string('failure_file')->nullable();
|
||||
$table->timestamp('imported_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['scope', 'imported_at']);
|
||||
$table->index(['merchant_id', 'imported_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_import_histories');
|
||||
}
|
||||
};
|
||||
31
database/migrations/2026_03_10_000100_create_plans_table.php
Normal file
31
database/migrations/2026_03_10_000100_create_plans_table.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('plans', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code', 50)->unique();
|
||||
$table->string('name', 100);
|
||||
$table->string('billing_cycle', 30)->default('monthly');
|
||||
$table->decimal('price', 10, 2)->default(0);
|
||||
$table->decimal('list_price', 10, 2)->default(0);
|
||||
$table->string('status', 30)->default('active');
|
||||
$table->unsignedInteger('sort')->default(0);
|
||||
$table->text('description')->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plans');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('site_subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete();
|
||||
$table->foreignId('plan_id')->nullable()->constrained('plans')->nullOnDelete();
|
||||
$table->string('status', 30)->default('pending');
|
||||
$table->string('source', 30)->default('manual');
|
||||
$table->string('subscription_no', 50)->unique();
|
||||
$table->string('plan_name', 100)->nullable();
|
||||
$table->string('billing_cycle', 30)->nullable();
|
||||
$table->unsignedInteger('period_months')->default(1);
|
||||
$table->decimal('amount', 10, 2)->default(0);
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->timestamp('activated_at')->nullable();
|
||||
$table->timestamp('cancelled_at')->nullable();
|
||||
$table->json('snapshot')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['merchant_id', 'status']);
|
||||
$table->index(['plan_id', 'status']);
|
||||
$table->index('ends_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('site_subscriptions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('platform_orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete();
|
||||
$table->foreignId('plan_id')->nullable()->constrained('plans')->nullOnDelete();
|
||||
$table->foreignId('site_subscription_id')->nullable()->constrained('site_subscriptions')->nullOnDelete();
|
||||
$table->foreignId('created_by_admin_id')->nullable()->constrained('admins')->nullOnDelete();
|
||||
$table->string('order_no', 50)->unique();
|
||||
$table->string('order_type', 30)->default('new_purchase');
|
||||
$table->string('status', 30)->default('pending');
|
||||
$table->string('payment_status', 30)->default('unpaid');
|
||||
$table->string('payment_channel', 30)->nullable();
|
||||
$table->string('plan_name', 100)->nullable();
|
||||
$table->string('billing_cycle', 30)->nullable();
|
||||
$table->unsignedInteger('period_months')->default(1);
|
||||
$table->unsignedInteger('quantity')->default(1);
|
||||
$table->decimal('list_amount', 10, 2)->default(0);
|
||||
$table->decimal('discount_amount', 10, 2)->default(0);
|
||||
$table->decimal('payable_amount', 10, 2)->default(0);
|
||||
$table->decimal('paid_amount', 10, 2)->default(0);
|
||||
$table->timestamp('placed_at')->nullable();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->timestamp('activated_at')->nullable();
|
||||
$table->timestamp('cancelled_at')->nullable();
|
||||
$table->timestamp('refunded_at')->nullable();
|
||||
$table->json('plan_snapshot')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->text('remark')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['merchant_id', 'status']);
|
||||
$table->index(['payment_status', 'status']);
|
||||
$table->index(['plan_id', 'status']);
|
||||
$table->index('placed_at');
|
||||
$table->index('paid_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('platform_orders');
|
||||
}
|
||||
};
|
||||
3
database/migrations/V1__baseline.sql
Normal file
3
database/migrations/V1__baseline.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- V1 baseline
|
||||
-- 说明:用于初始化 SQL 迁移体系的基线版本。
|
||||
-- 注意:本文件不做任何结构变更,仅作为版本占位。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user