findActivePlan($userId)) { throw new \RuntimeException('已有进行中的戒烟计划,请先重置', 409); } $profile = SmokeUserProfile::findByUid($userId); if (!$profile || (int) $profile->baseline_cigs_per_day <= 0) { throw new \RuntimeException('请先完成个人资料填写', 400); } $startDate = Support::parseDate((string) ($data['start_date'] ?? ''), 'start_date') ?? Support::dateOnly(); $endDate = $startDate->modify('+29 day'); $days = $this->buildPlanDays($profile->toArray(), $startDate); $now = time(); $summary = $this->buildSummary($profile->toArray()); Db::connect('mysql')->transaction(function () use ($userId, $profile, $startDate, $endDate, $days, $summary, $now) { $plan = new SmokeQuitPlan(); $plan->uid = $userId; $plan->status = self::STATUS_ACTIVE; $plan->start_date = $startDate->format(Support::DATE_LAYOUT); $plan->end_date = $endDate->format(Support::DATE_LAYOUT); $plan->baseline_cigs_per_day = (int) $profile->baseline_cigs_per_day; $plan->smoking_years = (float) $profile->smoking_years; $plan->pack_price_cent = (int) $profile->pack_price_cent; $plan->current_stage = self::STAGE_RECORDING; $plan->current_day = 1; $plan->completed_days = 0; $plan->prompt_version = 'rule_v1'; $plan->provider = 'built-in'; $plan->model = 'rule-based'; $plan->summary = $summary; $plan->createtime = $now; $plan->updatetime = $now; $plan->save(); foreach ($days as $day) { $row = new SmokeQuitPlanDay(); $row->plan_id = (int) $plan->id; $row->uid = $userId; $row->plan_date = $day['plan_date']; $row->stage = $day['stage']; $row->day = $day['day']; $row->target_cigs = $day['target_cigs']; $row->target_reduced = $day['target_reduced'] ? 1 : 0; $row->advice = $day['advice']; $row->createtime = $now; $row->updatetime = $now; $row->save(); } }); return $this->getActivePlan($userId); } public function reset(int $userId, array $data = []): array { $existing = $this->findActivePlan($userId); if ($existing) { $existing->status = self::STATUS_FAILED; $existing->updatetime = time(); $existing->save(); } return $this->generate($userId, $data); } public function getActivePlan(int $userId): array { $plan = $this->findActivePlan($userId); if (!$plan) { throw new \RuntimeException('暂无进行中的戒烟计划', 404); } $today = Support::dateOnly(); $startDate = Support::dateOnly((string) $plan->start_date); if ($today < $startDate) { $currentDay = 1; } else { $currentDay = max(1, min(30, (int) $startDate->diff($today)->days + 1)); } $currentStage = $this->resolveStageByDay($currentDay); $dayPlan = SmokeQuitPlanDay::where('uid', $userId) ->where('plan_date', $today->format(Support::DATE_LAYOUT)) ->whereNull('deleted_at') ->find(); return [ 'id' => (int) $plan->id, 'status' => (string) $plan->status, 'start_date' => (string) $plan->start_date, 'end_date' => (string) $plan->end_date, 'current_stage' => $currentStage, 'current_day' => $currentDay, 'completed_days' => (int) $plan->completed_days, 'baseline_cigs' => (int) $plan->baseline_cigs_per_day, 'summary' => (string) $plan->summary, 'today_target' => $dayPlan ? (int) $dayPlan->target_cigs : null, 'today_advice' => $dayPlan ? (string) $dayPlan->advice : null, ]; } public function getPlanDays(int $userId, ?int $planId = null): array { if ($planId === null || $planId <= 0) { $active = $this->findActivePlan($userId); if (!$active) { throw new \RuntimeException('暂无进行中的戒烟计划', 404); } $planId = (int) $active->id; } $plan = SmokeQuitPlan::where('id', $planId)->where('uid', $userId)->whereNull('deleted_at')->find(); if (!$plan) { throw new \RuntimeException('戒烟计划不存在', 404); } $rows = SmokeQuitPlanDay::where('plan_id', $planId) ->whereNull('deleted_at') ->order('day', 'asc') ->select() ->all(); $days = array_map(static function ($row) { return [ 'day' => (int) $row->day, 'plan_date' => (string) $row->plan_date, 'stage' => (string) $row->stage, 'target_cigs' => (int) $row->target_cigs, 'target_reduced' => (bool) $row->target_reduced, 'advice' => (string) $row->advice, 'actual_cigs' => $row->actual_cigs !== null ? (int) $row->actual_cigs : null, 'resisted_cnt' => $row->resisted_cnt !== null ? (int) $row->resisted_cnt : null, 'achieved' => $row->achieved !== null ? (bool) $row->achieved : null, ]; }, $rows); return [ 'plan_id' => $planId, 'days' => $days, ]; } private function findActivePlan(int $userId): ?SmokeQuitPlan { return SmokeQuitPlan::where('uid', $userId) ->where('status', self::STATUS_ACTIVE) ->whereNull('deleted_at') ->find(); } private function buildPlanDays(array $profile, \DateTimeImmutable $startDate): array { $baseline = max(1, (int) ($profile['baseline_cigs_per_day'] ?? 0)); $quitMotivation = Support::jsonArray($profile['quit_motivations'] ?? []); $smokeMotivation = Support::jsonArray($profile['smoke_motivations'] ?? []); $days = []; $previousTarget = $baseline; $reducingFloor = max(1, (int) round($baseline * 0.4)); for ($day = 1; $day <= 30; $day++) { $stage = $this->resolveStageByDay($day); if ($day <= 7) { $target = $baseline; } elseif ($day <= 21) { $progress = ($day - 8) / 13; $target = (int) round($baseline - (($baseline - $reducingFloor) * $progress)); } else { $progress = ($day - 22) / 8; $target = (int) round($reducingFloor * (1 - $progress)); } $target = max(0, min($baseline, $target)); $days[] = [ 'day' => $day, 'plan_date' => $startDate->modify('+' . ($day - 1) . ' day')->format(Support::DATE_LAYOUT), 'stage' => $stage, 'target_cigs' => $target, 'target_reduced' => $day > 1 && $target < $previousTarget, 'advice' => $this->buildDayAdvice($stage, $target, $quitMotivation, $smokeMotivation), ]; $previousTarget = $target; } return $days; } private function buildSummary(array $profile): string { $baseline = max(1, (int) ($profile['baseline_cigs_per_day'] ?? 0)); $motivation = Support::jsonArray($profile['quit_motivations'] ?? []); $goal = $motivation[0] ?? '让自己回到更轻松的状态'; return sprintf( '这是一个 30 天的渐进式戒烟计划:前 7 天建立稳定记录,第 8-21 天逐步减量,最后 9 天把目标压低到接近清零。请始终把“%s”作为你每天复盘时的提醒。当前基线按日均 %d 支计算。', $goal, $baseline ); } private function buildDayAdvice(string $stage, int $target, array $quitMotivation, array $smokeMotivation): string { $quitLine = $quitMotivation[0] ?? '提醒自己坚持的理由'; $smokeLine = $smokeMotivation[0] ?? '高压或社交场景'; if ($stage === self::STAGE_RECORDING) { return sprintf('今天先把记录做完整,目标控制在 %d 支以内。特别留意 %s 这些触发场景,先不急着求完美,把每次想抽前的情绪和时间点记下来。', $target, $smokeLine); } if ($stage === self::STAGE_REDUCING) { return sprintf('今天目标降到 %d 支。把最容易多抽的一个时段延后 10-15 分钟,用喝水、走动或深呼吸替代,复盘时提醒自己:%s。', $target, $quitLine); } return sprintf('今天进入巩固期,目标控制在 %d 支。优先守住“第一根烟”和“最晚一根烟”两个节点,把注意力放在连续完成目标上,继续记住:%s。', $target, $quitLine); } private function resolveStageByDay(int $day): string { if ($day <= 7) { return self::STAGE_RECORDING; } if ($day <= 21) { return self::STAGE_REDUCING; } return self::STAGE_CONSOLIDATING; } }