chore: init saasshop repo + sql migrations runner + gitee go

This commit is contained in:
萝卜
2026-03-10 11:31:02 +00:00
commit 50f15cdea8
210 changed files with 29534 additions and 0 deletions

View 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');
}
}

View 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'],
],
]);
}
}

View 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());
}
}

View 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,
];
}
}

View 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' => '一次性',
];
}
}

View 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 byMySQL 会返回带引号的 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' => '支付失败',
];
}
}

View 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);
}
}

View 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));
}
}
}

File diff suppressed because it is too large Load Diff

View 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_7d7 天内到期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 . '%');
});
});
}
}

View 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,
], '渠道登录占位已预留');
}
}

View 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, '订单创建成功');
}
}

View 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, '商品详情获取成功');
}
}

View 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',
]);
}
}

View 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'));
}
}

View File

@@ -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;
}
}

View 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'));
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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(),
]);
}
}

View 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(),
]);
}
}

View 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'],
],
]);
}
}

View 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');
}
}

View 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',
],
]);
}
}

View 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,
];
}
}

View 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));
}
}

File diff suppressed because it is too large Load Diff

View 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',
],
]);
}
}

View 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');
}
}

View 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',
],
]);
}
}

View 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 => '最近激活优先',
};
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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' => '微信小程序接口占位已预留',
]);
}
}

View 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' => '微信公众号接口占位已预留',
]);
}
}