249 lines
9.7 KiB
PHP
249 lines
9.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace app\smt\service;
|
|
|
|
use app\smt\model\SmokeQuitPlan;
|
|
use app\smt\model\SmokeQuitPlanDay;
|
|
use app\smt\model\SmokeUserProfile;
|
|
use think\facade\Db;
|
|
|
|
class QuitPlanService
|
|
{
|
|
public const STATUS_ACTIVE = 'active';
|
|
public const STATUS_FAILED = 'failed';
|
|
|
|
public const STAGE_RECORDING = 'recording';
|
|
public const STAGE_REDUCING = 'reducing';
|
|
public const STAGE_CONSOLIDATING = 'consolidating';
|
|
|
|
public function generate(int $userId, array $data = []): array
|
|
{
|
|
if ($this->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;
|
|
}
|
|
}
|