Files
saasshop/app/Http/Controllers/Admin/PlanController.php

347 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Models\PlatformOrder;
use App\Models\SiteSubscription;
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);
// 安全阀:必须显式声明 download=1避免浏览器预取/误触发导致频繁导出
if ((string) $request->query('download', '') !== '1') {
abort(400, 'download=1 required');
}
$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()->withCount(['subscriptions', 'platformOrders']), $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(),
// 治理联动:当前筛选范围内关联订阅/订单总量
// 注意subscriptions_count/platform_orders_count 是 withCount() 的别名列,不能直接 sum('subscriptions_count')
// 否则 MySQL 会报 Unknown column。
'subscriptions_count' => (function () use ($plansQuery) {
$planIds = (clone $plansQuery)->pluck('id')->all();
if (count($planIds) === 0) {
return 0;
}
return (int) SiteSubscription::query()->whereIn('plan_id', $planIds)->count();
})(),
'platform_orders_count' => (function () use ($plansQuery) {
$planIds = (clone $plansQuery)->pluck('id')->all();
if (count($planIds) === 0) {
return 0;
}
return (int) PlatformOrder::query()->whereIn('plan_id', $planIds)->count();
})(),
],
'statusLabels' => $this->statusLabels(),
'billingCycleLabels' => $this->billingCycleLabels(),
]);
}
public function create(Request $request): View
{
$this->ensurePlatformAdmin($request);
$back = (string) $request->query('back', '');
// back 需为站内相对路径,并拒绝引号/尖括号,避免后续页面以 `{!! !!}` 原样输出时引入 XSS 风险。
$safeBack = (str_starts_with($back, '/') && !preg_match('/["\'<>]/', $back)) ? $back : '';
return view('admin.plans.form', [
'plan' => new Plan(),
'statusLabels' => $this->statusLabels(),
'billingCycleLabels' => $this->billingCycleLabels(),
'formAction' => '/admin/plans',
'method' => 'post',
'back' => $safeBack,
]);
}
public function store(Request $request): RedirectResponse
{
$this->ensurePlatformAdmin($request);
$data = $this->validatePlan($request);
$back = (string) $request->input('back', '');
// back 需为站内相对路径,并拒绝引号/尖括号,避免后续页面以 `{!! !!}` 原样输出时引入 XSS 风险。
$safeBack = (str_starts_with($back, '/') && !preg_match('/["\'<>]/', $back)) ? $back : '';
$plan = Plan::query()->create($data);
if ($safeBack !== '') {
return redirect($safeBack)->with('success', '套餐已创建:' . $plan->name);
}
return redirect('/admin/plans')->with('success', '套餐已创建:' . $plan->name);
}
public function edit(Request $request, Plan $plan): View
{
$this->ensurePlatformAdmin($request);
$back = (string) $request->query('back', '');
// back 需为站内相对路径,并拒绝引号/尖括号,避免后续页面以 `{!! !!}` 原样输出时引入 XSS 风险。
$safeBack = (str_starts_with($back, '/') && !preg_match('/["\'<>]/', $back)) ? $back : '';
return view('admin.plans.form', [
'plan' => $plan,
'statusLabels' => $this->statusLabels(),
'billingCycleLabels' => $this->billingCycleLabels(),
'formAction' => '/admin/plans/' . $plan->id,
'method' => 'post',
'back' => $safeBack,
]);
}
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);
$back = (string) $request->input('back', '');
// back 需为站内相对路径,并拒绝引号/尖括号,避免后续页面以 `{!! !!}` 原样输出时引入 XSS 风险。
$safeBack = (str_starts_with($back, '/') && !preg_match('/["\'<>]/', $back)) ? $back : '';
$plan->update($data);
if ($safeBack !== '') {
return redirect($safeBack)->with('success', '套餐已更新:' . $plan->name);
}
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 . '%');
});
});
}
public function seedDefaults(Request $request): RedirectResponse
{
$this->ensurePlatformAdmin($request);
// 安全护栏:仅当当前库没有任何套餐时才允许一键初始化
$existingCount = Plan::query()->count();
if ($existingCount > 0) {
return redirect()->back()->with('error', '当前已有套餐(' . $existingCount . ' 条),为避免污染运营数据,已阻止一键初始化。');
}
$now = now();
$defaults = [
[
'code' => 'starter_monthly',
'name' => '基础版(月付)',
'billing_cycle' => 'monthly',
'price' => 99,
'list_price' => 129,
'status' => 'active',
'sort' => 10,
'description' => '适合刚开站的试运营阶段,可用于 Demo 场景。',
'published_at' => $now,
],
[
'code' => 'pro_yearly',
'name' => '专业版(年付)',
'billing_cycle' => 'yearly',
'price' => 1999,
'list_price' => 2599,
'status' => 'active',
'sort' => 20,
'description' => '面向成长型站点,后续搭配授权项配置。',
'published_at' => $now,
],
];
foreach ($defaults as $row) {
Plan::query()->create($row);
}
return redirect('/admin/plans')->with('success', '已初始化默认套餐:' . count($defaults) . ' 条。');
}
protected function statusLabels(): array
{
return [
'active' => '启用中',
'draft' => '草稿中',
'inactive' => '未启用',
];
}
protected function billingCycleLabels(): array
{
return [
'monthly' => '月付',
'quarterly' => '季付',
'yearly' => '年付',
'one_time' => '一次性',
];
}
}