diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2656775..0df1fab 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -7,6 +7,8 @@ use App\Jobs\Host\ScanAllHostsJob; use App\Jobs\Module\DispatchFetchModuleJob; use App\Jobs\Module\SendModuleEarningsJob; +use App\Jobs\Subscription\DeleteDraftJob; +use App\Jobs\Subscription\UpdateSubscriptionStatusJob; use App\Jobs\User\CheckAndChargeBalanceJob; use App\Jobs\User\ClearTasksJob; use App\Jobs\User\DeleteUnverifiedUserJob; @@ -61,6 +63,10 @@ protected function schedule(Schedule $schedule): void // 删除注册超过 3 天未验证邮箱的用户 $schedule->job(new DeleteUnverifiedUserJob())->daily()->onOneServer()->name('删除注册超过 3 天未验证邮箱的用户'); + + // 订阅 + $schedule->job(new DeleteDraftJob())->daily()->onOneServer()->name('删除超过 1 天的草稿订阅'); + $schedule->job(new UpdateSubscriptionStatusJob())->everyMinute()->onOneServer()->name('更新订阅状态'); } /** diff --git a/app/Http/Controllers/Api/SubscriptionController.php b/app/Http/Controllers/Api/SubscriptionController.php new file mode 100644 index 0000000..bb70599 --- /dev/null +++ b/app/Http/Controllers/Api/SubscriptionController.php @@ -0,0 +1,66 @@ +with('module')->orderBy('status')->paginate(); + + return $this->success($subscriptions); + } + + /** + * Display the specified resource. + */ + public function show(Subscription $subscription): JsonResponse + { + $subscription->load('module'); + + return $this->success($subscription); + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, Subscription $subscription): JsonResponse + { + $request->validate([ + 'cancel_at_period_end' => 'nullable|boolean', + 'status' => 'nullable|in:active', + ]); + + if ($request->filled('cancel_at_period_end')) { + $subscription->update([ + 'cancel_at_period_end' => $request->cancel_at_period_end, + ]); + } + + if ($request->filled('status') && $request->input('status') === 'active') { + if (! $subscription->active()) { + return $this->badRequest('无法激活此订阅。'); + } + } + + return $this->success($subscription); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Subscription $subscription): JsonResponse + { + $subscription->safeDelete(); + + return $this->deleted(); + } +} diff --git a/app/Http/Controllers/Module/SubscriptionController.php b/app/Http/Controllers/Module/SubscriptionController.php new file mode 100644 index 0000000..77df346 --- /dev/null +++ b/app/Http/Controllers/Module/SubscriptionController.php @@ -0,0 +1,108 @@ +subscriptions()->where('module_id', $request->user('module')->id); + + if ($request->filled('status')) { + $subscriptions->where('status', $request->input('status')); + } + + if ($request->filled('plan_id')) { + $subscriptions->where('plan_id', $request->input('plan_id')); + } + + $subscriptions = $subscriptions->paginate(); + + return $this->success($subscriptions); + } + + /** + * 向用户发送订阅请求。 + */ + public function store(Request $request, User $user): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:255', + 'plan_id' => 'required|string|max:255', + 'configuration' => 'nullable|json', + 'price' => 'required|numeric|min:0', + 'trial_ends_at' => 'nullable|date|after:now', + ]); + + $subscription = $user->subscriptions()->create([ + 'name' => $request->input('name'), + 'plan_id' => $request->input('plan_id'), + 'configuration' => $request->input('configuration'), + 'price' => $request->input('price'), + 'trial_ends_at' => $request->input('trial_ends_at'), + 'module_id' => $request->user('module')->id, + ]); + + $subscription->url = route('subscriptions.show', $subscription); + + return $this->success($subscription); + } + + /** + * 展示订阅详情。 + */ + public function show(Subscription $subscription): JsonResponse + { + return $this->success($subscription); + } + + /** + * 更新订阅。 + */ + public function update(Request $request, User $user, Subscription $subscription): JsonResponse + { + unset($user); + + if ($subscription->status === 'active') { + return $this->badRequest('此订阅已经成立,无法修改。'); + } + + $subscription->update($request->only([ + 'name', + 'plan_id', + 'configuration', + 'price', + 'trial_ends_at', + ])); + + return $this->success($subscription); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(User $user, Subscription $subscription): JsonResponse + { + unset($user); + + $subscription->safeDelete(); + + return $this->deleted(); + } + + public function by_plan_id(User $user, Subscription $subscription): JsonResponse + { + unset($user); + + return $this->success($subscription); + } +} diff --git a/app/Http/Controllers/Web/SubscriptionController.php b/app/Http/Controllers/Web/SubscriptionController.php new file mode 100644 index 0000000..c4e87ae --- /dev/null +++ b/app/Http/Controllers/Web/SubscriptionController.php @@ -0,0 +1,63 @@ +thisUser()->with('module')->orderBy('status')->get(); + + return view('subscription.index', compact('subscriptions')); + } + + public function update(Request $request, Subscription $subscription): JsonResponse|RedirectResponse + { + $request->validate([ + 'cancel_at_period_end' => 'nullable|boolean', + 'status' => 'nullable|in:active', + ]); + + if ($request->filled('cancel_at_period_end')) { + $subscription->update([ + 'cancel_at_period_end' => $request->cancel_at_period_end, + ]); + } + + if ($request->filled('status') && $request->input('status') === 'active') { + if (! $subscription->active()) { + if ($request->ajax()) { + return $this->badRequest('无法激活此订阅。'); + } + + return back()->withErrors('无法激活此订阅。'); + } + } + + if ($request->ajax()) { + return $this->success($subscription); + } + + return back(); + } + + public function show(Subscription $subscription) + { + $subscription->load('module'); + + return view('subscription.show', compact('subscription')); + } + + public function destroy(Subscription $subscription) + { + $subscription->safeDelete(); + + return back(); + } +} diff --git a/app/Jobs/Subscription/DeleteDraftJob.php b/app/Jobs/Subscription/DeleteDraftJob.php new file mode 100644 index 0000000..939f52a --- /dev/null +++ b/app/Jobs/Subscription/DeleteDraftJob.php @@ -0,0 +1,25 @@ +where('created_at', '<', now()->subDay()) + ->delete(); + } +} diff --git a/app/Jobs/Subscription/UpdateSubscriptionStatusJob.php b/app/Jobs/Subscription/UpdateSubscriptionStatusJob.php new file mode 100644 index 0000000..b138d05 --- /dev/null +++ b/app/Jobs/Subscription/UpdateSubscriptionStatusJob.php @@ -0,0 +1,80 @@ +subscription = $subscription; + } + + /** + * Execute the job. + */ + public function handle(): void + { + if (! $this->subscription) { + // 遍历所有订阅 + (new Subscription)->where('status', 'active')->chunk(100, function ($subscriptions) { + $subscriptions->each(function ($subscription) { + self::dispatch($subscription); + }); + }); + + return; + } + + $subscription = $this->subscription; + + // 如果是试用期,在到期当天,自动续费 + if ($subscription->trial_ends_at && $subscription->trial_ends_at->isToday()) { + if ($subscription->cancel_at_period_end) { + // 到期不续费了,直接过期 + $subscription->update([ + 'status' => 'expired', + ]); + } else { + // 去除试用标识 + $subscription->update([ + 'trial_ends_at' => null, + ]); + + $subscription->renew(); + } + + return; + } + + // 如果已经过期,则设置为 expired + if ($subscription->expired_at && $subscription->expired_at->lt(now())) { + $subscription->update([ + 'status' => 'expired', + ]); + + return; + } + + // 如果还有 7 天过期,则提醒用户续费 + // if ($subscription->cancel_at_period_end && $subscription->expired_at && $subscription->expired_at->gt(now()->addDays(7))) { + // + // } + + // 剩余 3 天就要过期时,自动续费 + if ($subscription->cancel_at_period_end && $subscription->expired_at && $subscription->expired_at->gt(now()->addDays(3))) { + // 发送邮件提醒用户续费 + $subscription->renew(); + } + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..0dcd65e --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,169 @@ + 'array', + 'price' => 'decimal:2', + 'renew_price' => 'decimal:2', + ]; + + protected $dates = [ + 'expired_at', + 'trial_ends_at', + ]; + + public function scopeThisUser($query, $module_id = null) + { + $query = $query->where('user_id', auth()->id()); + + if ($module_id) { + $query = $query->where('module_id', $module_id); + } + + return $query; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function module(): BelongsTo + { + return $this->belongsTo(Module::class); + } + + public function active(): bool + { + return $this->canActivate() && $this->renew(); + } + + public function canActivate(bool $ignore_activated = false): bool + { + if ($ignore_activated && $this->isActive()) { + return true; + } + + // 检测 trial_ends_at 是否过期 + if ($this->trial_ends_at && $this->trial_ends_at->isPast()) { + return false; + } + + return true; + } + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function renew(): bool + { + if ($this->isTrial()) { + $this->status = 'active'; + } + + // 如果过期时间距离今天超过了 7 天,那么就不能续费了 + if ($this->expired_at && $this->expired_at->diffInDays(now()) > 7) { + return false; + } + + $price = $this->price; + + try { + $this->user->reduce($price, '订阅: '.$this->name, true, [ + 'module_id' => $this->module_id, + 'user_id' => $this->user_id, + 'subscription_id' => $this->id, + ]); + $this->module->charge($price, 'module_balance', '订阅: '.$this->name.' 续费', [ + 'module_id' => $this->module_id, + 'user_id' => $this->user_id, + 'subscription_id' => $this->id, + ]); + } catch (BalanceNotEnoughException) { + return false; + } + + $this->renew_price = $price; + + if (! $this->isTrial()) { + $this->expired_at = now()->addMonth(); + } + + $this->status = 'active'; + $this->save(); + + return true; + } + + public function isTrial(): bool + { + return $this->trial_ends_at !== null; + } + + public function safeDelete() + { + // 如果是试用,那么就直接删除 + if ($this->isTrial() || $this->isExpired() || $this->isDraft() || ! $this->renew_price) { + $this->delete(); + + return; + } + + // 如果是正式订阅,那么就按使用天数计算退款 + $days = $this->expired_at ? $this->expired_at->diffInDays(now()) : 27; + // 获取 create_at 当时的月份的天数 + $daysInMonth = $this->created_at->daysInMonth; + + // 按照使用天数计算退款(bcdiv 保留两位小数) + $refund = bcdiv($this->renew_price, $daysInMonth, 2) * $days; + + // 如果退款金额大于 0,那么就退款 + if ($refund > 0) { + $this->user->charge($refund, 'balance', '订阅: '.$this->name.' 退款', [ + 'module_id' => $this->module_id, + 'user_id' => $this->user_id, + 'subscription_id' => $this->id, + ]); + $this->module->reduce($refund, '订阅: '.$this->name.' 退款', false, [ + 'module_id' => $this->module_id, + 'user_id' => $this->user_id, + 'subscription_id' => $this->id, + ]); + } + + $this->delete(); + } + + public function isExpired(): bool + { + return $this->expired_at && $this->expired_at->isPast(); + } + + public function isDraft(): bool + { + return $this->status === 'draft'; + } +} diff --git a/app/Observers/SubscriptionObserve.php b/app/Observers/SubscriptionObserve.php new file mode 100644 index 0000000..81ffdb1 --- /dev/null +++ b/app/Observers/SubscriptionObserve.php @@ -0,0 +1,54 @@ +status) { + $subscription->status = 'draft'; + } + } + + /** + * Handle the Subscription "created" event. + */ + public function created(Subscription $subscription): void + { + broadcast(new Users($subscription->user, 'subscription.created', $subscription)); + } + + public function updating(Subscription $subscription): void + { + // 如果 status 是 expired, expired_at 为空,就设置为当前时间 + if ($subscription->status === 'expired' && ! $subscription->expired_at) { + $subscription->expired_at = now(); + } + + // 如果 expired_at 和 trial_ends_at 为空,就当作过期处理 + if ($subscription->status !== 'draft' && ! $subscription->expired_at && ! $subscription->trial_ends_at) { + $subscription->status = 'expired'; + } + } + + /** + * Handle the Subscription "updated" event. + */ + public function updated(Subscription $subscription): void + { + broadcast(new Users($subscription->user, 'subscription.updated', $subscription)); + } + + /** + * Handle the Subscription "deleted" event. + */ + public function deleted(Subscription $subscription): void + { + broadcast(new Users($subscription->user, 'subscription.deleted', $subscription)); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8d76404..d93f147 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,12 +6,14 @@ use App\Models\Host; use App\Models\Module; use App\Models\PersonalAccessToken; +use App\Models\Subscription; use App\Models\Task; use App\Models\User; use App\Models\WorkOrder\WorkOrder; use App\Observers\BalanceObserver; use App\Observers\HostObserver; use App\Observers\ModuleObserver; +use App\Observers\SubscriptionObserve; use App\Observers\TaskObserver; use App\Observers\UserObserver; use App\Observers\WorkOrderObserver; @@ -20,16 +22,11 @@ use Illuminate\Support\ServiceProvider; use Laravel\Sanctum\Sanctum; +// use App\Models\Invoice; +// use App\Observers\InvoiceObserver; + class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ - public function register(): void - { - // - } - /** * Bootstrap any application services. */ @@ -63,5 +60,7 @@ private function registerObservers(): void Module::observe(ModuleObserver::class); Balance::observe(BalanceObserver::class); WorkOrder::observe(WorkOrderObserver::class); + // Invoice::observe(InvoiceObserver::class); + Subscription::observe(SubscriptionObserve::class); } } diff --git a/database/migrations/2023_02_27_230411_create_subscriptions_table.php b/database/migrations/2023_02_27_230411_create_subscriptions_table.php new file mode 100644 index 0000000..caef3cc --- /dev/null +++ b/database/migrations/2023_02_27_230411_create_subscriptions_table.php @@ -0,0 +1,68 @@ +id(); + + // 类型 + $table->enum('status', [ + 'draft', + 'active', + 'expired', + 'canceled', + ])->index(); + + // 名称 + $table->string('name')->nullable(); + + // 计划 ID + $table->string('plan_id')->index(); + + // 配置项目 + $table->json('configuration')->nullable(); + + // 价格 + $table->decimal('price', 10)->default(0); + + // 结束时间 + $table->timestamp('expired_at')->nullable(); + + // 试用结束时间 + $table->timestamp('trial_ends_at')->nullable(); + + // 下个月取消 + $table->boolean('cancel_at_period_end')->default(false)->index(); + + // 续费时价格 + $table->decimal('renew_price', 10)->default(0); + + // 模块 ID + $table->string('module_id')->index()->nullable(); + $table->foreign('module_id')->references('id')->on('modules')->nullOnDelete(); + + // 用户 ID + $table->unsignedBigInteger('user_id')->index()->nullable(); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/resources/views/subscription/index.blade.php b/resources/views/subscription/index.blade.php new file mode 100644 index 0000000..c729315 --- /dev/null +++ b/resources/views/subscription/index.blade.php @@ -0,0 +1,109 @@ +@extends('layouts.app') + +@section('title', '订阅') + +@section('content') +

订阅

+ +
+ + + + + + + + + + + + + + @foreach ($subscriptions as $subscription) + + + + + + + + + + + @endforeach + +
ID名称模块计划 ID续期价格状态到期时间操作
+ {{ $subscription->id }} + + {{ $subscription->name }} + + {{ $subscription->module->name }} + + {{ $subscription->plan_id }} + + {{ $subscription->price }} 元 + + + @if ($subscription->cancel_at_period_end) +
+ + 自动续订已取消 + + @endif +
+ + @if ($subscription->isTrial()) + {{ $subscription->trial_ends_at }}(试用) + @else + {{ $subscription->expired_at }} + @endif + + + + +
+
+ +@endsection diff --git a/resources/views/subscription/show.blade.php b/resources/views/subscription/show.blade.php new file mode 100644 index 0000000..5aa3f6e --- /dev/null +++ b/resources/views/subscription/show.blade.php @@ -0,0 +1,64 @@ +@extends('layouts.app') + +@section('title', $subscription->name) + +@section('content') + @if (!$subscription->canActivate(true)) +
+
+

不能激活此订阅。

+
+

+ 模块向您发送了一个不正确的订阅草稿。 +

+
+
+ @elseif ($subscription->isDraft()) +
+
+

激活此订阅

+ + + + + + + + + + + + + + + + + +
模块{{ $subscription->module->name }}
月付价格{{ $subscription->price }}
计划 ID{{ $subscription->plan_id }}
截止 + {{ ($subscription->expired_at ?? $subscription->trial_ends_at) ?? '订阅后开始计算' }} + @if ($subscription->isTrial()) + 试用 + @endif +
+ +
+
+ @csrf + @method('PATCH') + + + +
+
+
+
+ @endif + + @if($subscription->isActive()) +
+
+

谢谢。

+
+
+ @endif +@endsection diff --git a/routes/api.php b/routes/api.php index 8efcb4e..6679fff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Api\MaintenanceController; use App\Http\Controllers\Api\ModuleController; use App\Http\Controllers\Api\ReplyController; +use App\Http\Controllers\Api\SubscriptionController; use App\Http\Controllers\Api\TaskController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\WorkOrderController; @@ -39,6 +40,7 @@ Route::apiResource('hosts', HostController::class); Route::apiResource('work-orders', WorkOrderController::class)->only(['index', 'store']); +Route::apiResource('subscriptions', SubscriptionController::class)->middleware('resource_owner:subscription'); Route::withoutMiddleware('auth:sanctum')->prefix('work-orders')->group(function () { Route::get('{workOrder:uuid}', [WorkOrderController::class, 'show']); diff --git a/routes/modules.php b/routes/modules.php index 21f0820..fec68bb 100644 --- a/routes/modules.php +++ b/routes/modules.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Module\HostController; use App\Http\Controllers\Module\ModuleController; use App\Http\Controllers\Module\ReplyController; +use App\Http\Controllers\Module\SubscriptionController; use App\Http\Controllers\Module\TaskController; use App\Http\Controllers\Module\UserController; use App\Http\Controllers\Module\WorkOrderController; @@ -24,7 +25,8 @@ // 用户信息 Route::post('users/attempt', [UserController::class, 'attempt']); -Route::resource('users', UserController::class)->only(['index', 'show', 'update', 'store']); +Route::apiResource('users', UserController::class)->only(['index', 'show', 'update', 'store']); +Route::apiResource('users.subscriptions', SubscriptionController::class); Route::get('token/{token}', [UserController::class, 'auth']); Route::get('users/{user}/hosts', [UserController::class, 'hosts']); diff --git a/routes/web.php b/routes/web.php index 82d2a41..ce09df5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Web\HostController; use App\Http\Controllers\Web\MaintenanceController; use App\Http\Controllers\Web\RealNameController; +use App\Http\Controllers\Web\SubscriptionController; use App\Http\Controllers\Web\TransferController; use Illuminate\Support\Facades\Route; @@ -86,6 +87,10 @@ function () { Route::middleware('guest')->withoutMiddleware(['verified', 'auth:web'])->get('affiliates/{affiliate:uuid}', [AffiliateController::class, 'show'])->name('affiliates.show'); /* End 推介 */ + /* Start 订阅 */ + Route::middleware('resource_owner:subscription')->resource('subscriptions', SubscriptionController::class)->except(['edit']); + /* End 订阅 */ + /* Start 匿名登录 */ Route::get('auth_request/{auth_request}', [AuthController::class, 'showAuthRequest'])->withoutMiddleware(['auth:web', 'verified'])->name('auth_request.show'); Route::post('auth_request', [AuthController::class, 'storeAuthRequest'])->name('auth_request.store');