370 lines
14 KiB
PHP
370 lines
14 KiB
PHP
<?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 App\Support\BackUrl;
|
||
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 安全阀:统一收敛到 BackUrl::sanitizeForLinks(用于 Blade `{!! !!}` 原样输出场景)
|
||
$safeBack = BackUrl::sanitizeForLinks($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 安全阀:统一收敛到 BackUrl::sanitizeForLinks(用于 Blade `{!! !!}` 原样输出场景)
|
||
$safeBack = BackUrl::sanitizeForLinks($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 安全阀:统一收敛到 BackUrl::sanitizeForLinks(用于 Blade `{!! !!}` 原样输出场景)
|
||
$safeBack = BackUrl::sanitizeForLinks($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 setPublished(Request $request, Plan $plan): RedirectResponse
|
||
{
|
||
$this->ensurePlatformAdmin($request);
|
||
|
||
$data = $request->validate([
|
||
'published' => ['required', Rule::in(['0', '1'])],
|
||
]);
|
||
|
||
$published = (string) $data['published'] === '1';
|
||
|
||
if ($published) {
|
||
// 最小治理:发布动作仅写 published_at,不改变 status(避免误把草稿/停用套餐误启用)。
|
||
$plan->published_at = $plan->published_at ?? now();
|
||
} else {
|
||
$plan->published_at = null;
|
||
}
|
||
|
||
$plan->save();
|
||
|
||
return redirect()->back()->with('success', $published ? '套餐已发布' : '套餐已取消发布');
|
||
}
|
||
|
||
public function update(Request $request, Plan $plan): RedirectResponse
|
||
{
|
||
$this->ensurePlatformAdmin($request);
|
||
|
||
$data = $this->validatePlan($request, $plan->id);
|
||
|
||
$back = (string) $request->input('back', '');
|
||
// back 安全阀:统一收敛到 BackUrl::sanitizeForLinks(用于 Blade `{!! !!}` 原样输出场景)
|
||
$safeBack = BackUrl::sanitizeForLinks($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' => '一次性',
|
||
];
|
||
}
|
||
}
|