formatProfileRow($profile->toArray()); } public function getProfileView(int $userId): array { $profile = $this->getProfile($userId); if (!$profile) { return [ 'exists' => false, 'profile' => null, 'is_completed' => false, 'awake_minutes' => 16 * 60, 'baseline_interval_minutes' => 0, ]; } $awakeMinutes = Support::awakeMinutes((string) $profile['wake_up_time'], (string) $profile['sleep_time']); return [ 'exists' => true, 'profile' => $profile, 'is_completed' => $this->isProfileCompleted($profile), 'awake_minutes' => $awakeMinutes, 'baseline_interval_minutes' => Support::baselineIntervalMinutes($awakeMinutes, (int) $profile['baseline_cigs_per_day']), ]; } public function upsertProfile(int $userId, array $data): array { $profile = SmokeUserProfile::findByUid($userId); $isNew = $profile === null; if (!$profile) { $profile = new SmokeUserProfile(); $profile->uid = $userId; } foreach (['baseline_cigs_per_day', 'pack_price_cent', 'achievement_theme_id'] as $field) { if (array_key_exists($field, $data) && $data[$field] !== null) { $profile->{$field} = (int) $data[$field]; } } if (array_key_exists('smoking_years', $data) && $data['smoking_years'] !== null) { $profile->smoking_years = (float) $data['smoking_years']; } if (array_key_exists('mode', $data)) { $profile->mode = Support::normalizedMode((string) ($data['mode'] ?? '')); } if (array_key_exists('smoke_motivations', $data)) { $profile->smoke_motivations = Support::jsonEncodeArray((array) ($data['smoke_motivations'] ?? [])); } if (array_key_exists('quit_motivations', $data)) { $profile->quit_motivations = Support::jsonEncodeArray((array) ($data['quit_motivations'] ?? [])); } if (array_key_exists('wake_up_time', $data)) { $wakeUpTime = trim((string) ($data['wake_up_time'] ?? '')); if ($wakeUpTime !== '') { Support::parseHHMM($wakeUpTime); } $profile->wake_up_time = $wakeUpTime; } if (array_key_exists('sleep_time', $data)) { $sleepTime = trim((string) ($data['sleep_time'] ?? '')); if ($sleepTime !== '') { Support::parseHHMM($sleepTime); } $profile->sleep_time = $sleepTime; } if (array_key_exists('quit_date', $data)) { $quitDate = trim((string) ($data['quit_date'] ?? '')); $profile->quit_date = $quitDate === '' ? null : Support::parseDate($quitDate, 'quit_date')->format(Support::DATE_LAYOUT); } $profileArray = $this->formatProfileRow(array_merge($profile->toArray(), ['deleted_at' => null])); if (empty($profile->onboarding_completed_at) && $this->isProfileCompleted($profileArray)) { $profile->onboarding_completed_at = Support::now()->format(Support::DATETIME_LAYOUT); } if ($isNew) { $profile->created_at = Support::now()->format(Support::DATETIME_LAYOUT); } $profile->updated_at = Support::now()->format(Support::DATETIME_LAYOUT); $profile->save(); return $this->getProfileView($userId); } public function createLog(int $userId, array $data, bool $resisted = false): array { $smokeAt = array_key_exists('smoke_at', $data) ? Support::parseDateTime((string) ($data['smoke_at'] ?? ''), 'smoke_at') : null; $smokeTime = array_key_exists('smoke_time', $data) ? Support::parseDate((string) ($data['smoke_time'] ?? ''), 'smoke_time') : null; if ($smokeAt) { $smokeTime = Support::dateOnly($smokeAt); } if (!$smokeTime) { $smokeTime = Support::dateOnly(); } $num = $resisted ? 0 : max(0, (int) ($data['num'] ?? 1)); $level = ($resisted || $num === 0) ? 0 : max(0, (int) ($data['level'] ?? 1)); $insertId = Db::connect('mysql')->name('fa_smoke_log')->insertGetId([ 'uid' => $userId, 'smoke_time' => $smokeTime->format(Support::DATE_LAYOUT), 'smoke_at' => $smokeAt ? $smokeAt->format(Support::DATETIME_LAYOUT) : null, 'remark' => (string) ($data['remark'] ?? ''), 'reason_tags' => Support::jsonEncodeArray((array) ($data['reason_tags'] ?? [])), 'createtime' => time(), 'updatetime' => time(), 'level' => $level, 'num' => $num, ]); return $this->getLog($userId, (int) $insertId); } public function getLog(int $userId, int $id): array { $row = Db::connect('mysql')->name('fa_smoke_log') ->where('id', $id) ->where('uid', $userId) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->find(); if (!$row) { throw new \RuntimeException('记录不存在', 404); } return Support::formatLog($row); } public function listLogs(int $userId, array $params = []): array { $page = max(1, (int) ($params['page'] ?? 1)); $pageSize = min(200, max(1, (int) ($params['page_size'] ?? 20))); $type = strtolower(trim((string) ($params['type'] ?? 'all'))); if (!in_array($type, ['all', 'smoke', 'resisted'], true)) { throw new \RuntimeException('type 应为 all|smoke|resisted', 400); } $query = Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->whereRaw('(deletetime IS NULL OR deletetime = 0)'); if (!empty($params['start'])) { $query->where('smoke_time', '>=', Support::parseDate((string) $params['start'], 'start')->format(Support::DATE_LAYOUT)); } if (!empty($params['end'])) { $query->where('smoke_time', '<=', Support::parseDate((string) $params['end'], 'end')->format(Support::DATE_LAYOUT)); } if ($type === 'smoke') { $query->where('num', '>', 0); } elseif ($type === 'resisted') { $query->where('level', 0)->where('num', 0); } $total = (int) (clone $query)->count(); $rows = $query->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC') ->order('id', 'desc') ->page($page, $pageSize) ->select() ->toArray(); return [ 'items' => array_map(static function ($row) { return Support::formatLog($row); }, $rows), 'total' => $total, 'page' => $page, 'page_size' => $pageSize, ]; } public function updateLog(int $userId, int $id, array $data): array { $this->getLog($userId, $id); $updates = ['updatetime' => time()]; if (array_key_exists('smoke_time', $data)) { $smokeTime = trim((string) ($data['smoke_time'] ?? '')); $updates['smoke_time'] = $smokeTime === '' ? null : Support::parseDate($smokeTime, 'smoke_time')->format(Support::DATE_LAYOUT); } if (array_key_exists('smoke_at', $data)) { $smokeAt = trim((string) ($data['smoke_at'] ?? '')); $updates['smoke_at'] = $smokeAt === '' ? null : Support::parseDateTime($smokeAt, 'smoke_at')->format(Support::DATETIME_LAYOUT); if ($updates['smoke_at'] !== null) { $updates['smoke_time'] = Support::dateOnly($updates['smoke_at'])->format(Support::DATE_LAYOUT); } } if (array_key_exists('remark', $data)) { $updates['remark'] = (string) ($data['remark'] ?? ''); } if (array_key_exists('reason_tags', $data)) { $updates['reason_tags'] = Support::jsonEncodeArray((array) ($data['reason_tags'] ?? [])); } if (array_key_exists('level', $data)) { $updates['level'] = max(0, (int) ($data['level'] ?? 1)); } if (array_key_exists('num', $data)) { $updates['num'] = max(0, (int) ($data['num'] ?? 1)); } Db::connect('mysql')->name('fa_smoke_log') ->where('id', $id) ->where('uid', $userId) ->update($updates); return $this->getLog($userId, $id); } public function deleteLog(int $userId, int $id): array { $affected = Db::connect('mysql')->name('fa_smoke_log') ->where('id', $id) ->where('uid', $userId) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->update([ 'deletetime' => time(), 'updatetime' => time(), ]); if ((int) $affected <= 0) { throw new \RuntimeException('记录不存在', 404); } return ['deleted' => true]; } public function getHomeSummary(int $userId, ?DateTimeImmutable $asOf = null): array { $asOf = $asOf ?: Support::now(); $today = Support::dateOnly($asOf); $todayKey = $today->format(Support::DATE_LAYOUT); $yesterdayKey = $today->modify('-1 day')->format(Support::DATE_LAYOUT); $todayCount = (int) Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->where('smoke_time', $todayKey) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->sum('num'); $resistedCount = (int) Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->where('smoke_time', $todayKey) ->where('level', 0) ->where('num', 0) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->count(); $yesterdayCount = (int) Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->where('smoke_time', $yesterdayKey) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->sum('num'); $diff = $yesterdayCount - $todayCount; $lastSmoke = $this->findLastActualSmoke($userId); $secondsSinceLast = -1; if ($lastSmoke) { $secondsSinceLast = max(0, $asOf->getTimestamp() - $lastSmoke->getTimestamp()); } return [ 'last_smoke_at' => $lastSmoke ? $lastSmoke->format(DATE_ATOM) : '', 'today_count' => $todayCount, 'resisted_count' => $resistedCount, 'reduced_from_yesterday' => $diff > 0 ? $diff : abs($diff), 'exceeded_yesterday' => $diff < 0, 'seconds_since_last' => $secondsSinceLast, ]; } public function getDefaultNextSuggestion(int $userId, DateTimeImmutable $asOf, DateTimeImmutable $planDate, array $profileView): array { $base = (int) ($profileView['baseline_interval_minutes'] ?? 0); if ($base <= 0) { $base = 60; } $lastSmokeAt = $this->findLastActualSmoke($userId) ?: $asOf; if ($lastSmokeAt > $asOf) { $lastSmokeAt = $asOf; } $resisted7d = (int) Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->where('level', 0) ->where('num', 0) ->whereBetween('smoke_time', [$asOf->modify('-6 day')->format(Support::DATE_LAYOUT), Support::dateOnly($asOf)->format(Support::DATE_LAYOUT)]) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->count(); $stage = min(12, intdiv($resisted7d, 5)); $interval = min(240, max(5, $base + $stage * 5)); $nextSmokeAt = $lastSmokeAt->add(new DateInterval('PT' . $interval . 'M')); if (Support::dateOnly($planDate) <= Support::dateOnly($asOf) && $nextSmokeAt < $asOf) { $elapsed = max(0, $asOf->getTimestamp() - $lastSmokeAt->getTimestamp()); $missed = intdiv($elapsed, $interval * 60); $nextSmokeAt = $lastSmokeAt->add(new DateInterval('PT' . (($missed + 1) * $interval) . 'M')); } $sleepAdjusted = false; $profile = $profileView['profile'] ?? null; $wakeUpTime = $profile['wake_up_time'] ?? ''; $sleepTime = $profile['sleep_time'] ?? ''; if (Support::dateOnly($planDate) > Support::dateOnly($asOf)) { $minNotBefore = Support::dateOnly($planDate)->setTime(7, 0); if ($wakeUpTime !== '') { $wakeMin = Support::parseHHMM((string) $wakeUpTime); $minNotBefore = Support::dateOnly($planDate)->setTime(intdiv($wakeMin, 60), $wakeMin % 60); } if ($nextSmokeAt < $minNotBefore) { $nextSmokeAt = $minNotBefore; } } if ($wakeUpTime !== '' && $sleepTime !== '') { $adjusted = $this->adjustToWakeIfInSleep($nextSmokeAt, (string) $wakeUpTime, (string) $sleepTime); if ($adjusted != $nextSmokeAt) { $nextSmokeAt = $adjusted; $sleepAdjusted = true; } } return [ 'last_smoke_at' => $lastSmokeAt->format(DATE_ATOM), 'next_smoke_at' => $nextSmokeAt->format(DATE_ATOM), 'base_interval_minutes' => $base, 'interval_minutes' => $interval, 'stage' => $stage, 'resisted_7d' => $resisted7d, 'sleep_adjusted' => $sleepAdjusted, 'algorithm' => 'staircase_delay_v1', 'as_of' => $asOf->format(DATE_ATOM), ]; } public function motivation(int $userId, ?array $profile = null, ?DateTimeImmutable $asOf = null): array { $home = $this->getHomeSummary($userId, $asOf ?: Support::now()); $minutesSinceLast = -1; if (!empty($home['last_smoke_at'])) { $minutesSinceLast = max(0, (int) floor((Support::now()->getTimestamp() - Support::toDateTime((string) $home['last_smoke_at'])->getTimestamp()) / 60)); } $dailyTarget = $profile ? (int) ($profile['baseline_cigs_per_day'] ?? 0) : 0; $quitMotivation = $profile && !empty($profile['quit_motivations']) ? (string) $profile['quit_motivations'][0] : ''; $scene = 'default'; $fallback = ['message' => '保持连胜纪录!', 'type' => 'encourage']; if ((int) $home['resisted_count'] > 0 && $minutesSinceLast >= 0 && $minutesSinceLast < 30) { $scene = 'recent_resist'; $fallback = ['message' => '太棒了!你刚刚成功抵抗了一次烟瘾', 'type' => 'praise']; } elseif ($dailyTarget > 0 && (int) $home['today_count'] < (int) floor($dailyTarget * 0.5)) { $scene = 'below_half_target'; $fallback = ['message' => '今天的表现非常出色,继续保持!', 'type' => 'encourage']; } elseif ($dailyTarget > 0 && (int) $home['today_count'] === $dailyTarget - 1) { $scene = 'near_limit'; $fallback = ['message' => '还剩最后一支配额,考虑把它留到睡前?', 'type' => 'hint']; } elseif ($dailyTarget > 0 && (int) $home['today_count'] > $dailyTarget) { $scene = 'over_target'; $fallback = ['message' => '没关系,明天是新的一天。' . ($quitMotivation !== '' ? '记住你为什么要戒烟:' . $quitMotivation : ''), 'type' => 'comfort']; } $quote = SmokeMotivationQuote::where('scene', $scene) ->where('enabled', 1) ->whereNull('deleted_at') ->order('weight', 'desc') ->order('id', 'asc') ->find(); if (!$quote && $scene !== 'default') { $quote = SmokeMotivationQuote::where('scene', 'default') ->where('enabled', 1) ->whereNull('deleted_at') ->order('weight', 'desc') ->order('id', 'asc') ->find(); } if ($quote) { return [ 'message' => (string) $quote->message, 'type' => (string) $quote->type, ]; } return $fallback; } public function stats(int $userId, array $params = [], ?array $profile = null): array { $range = strtolower(trim((string) ($params['range'] ?? 'week'))); $anchor = !empty($params['date']) ? Support::parseDate((string) $params['date'], 'date')->setTime(23, 59, 59) : Support::now(); [$start, $end, $prevStart, $prevEnd, $trendUnit] = $this->buildStatsRange($range, $anchor); if ($trendUnit === 'month') { [$trend, $total] = $this->loadMonthlyTrend($userId, $start, $end); } else { [$trend, $total] = $this->loadDailyTrend($userId, $start, $end); } $trend = $this->limitTrend($trend, 7); $dayCount = Support::daysBetweenInclusive($start, $end); $dailyAverage = $dayCount > 0 ? (int) round($total / $dayCount) : 0; $prevTotal = $this->sumCigs($userId, $prevStart, $prevEnd); $changePercent = $prevTotal > 0 ? (int) round((($total - $prevTotal) / $prevTotal) * 100) : 0; $resistedTotal = $this->countResisted($userId, $start, $end); $streakDays = $this->getStreakDays($userId, $anchor); return [ 'range' => $range, 'start' => $start->format(Support::DATE_LAYOUT), 'end' => $end->format(Support::DATE_LAYOUT), 'trend_unit' => $trendUnit, 'trend' => $trend, 'daily_average' => $dailyAverage, 'change_percent' => $changePercent, 'money' => $this->computeMoney($userId, $profile, $total, $start, $end), 'health' => $this->computeHealth($userId, $anchor), 'streak_days' => $streakDays, 'resisted_total' => $resistedTotal, ]; } public function getStreakDays(int $userId, ?DateTimeImmutable $asOf = null): int { $asOf = Support::dateOnly($asOf ?: Support::now()); $rows = Db::connect('mysql')->name('fa_smoke_log') ->distinct(true) ->field('smoke_time') ->where('uid', $userId) ->whereBetween('smoke_time', [$asOf->modify('-400 day')->format(Support::DATE_LAYOUT), $asOf->format(Support::DATE_LAYOUT)]) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->order('smoke_time', 'desc') ->select() ->toArray(); $daySet = []; foreach ($rows as $row) { $daySet[(string) $row['smoke_time']] = true; } $streak = 0; for ($cursor = $asOf; ; $cursor = $cursor->modify('-1 day')) { $key = $cursor->format(Support::DATE_LAYOUT); if (!isset($daySet[$key])) { break; } $streak++; } return $streak; } public function createShare(int $userId, array $data = []): array { $days = (int) ($data['days'] ?? 0); if ($days <= 0) { $days = 7; } if ($days > 30) { $days = 30; } $share = new SmokeShare(); $share->uid = $userId; $share->share_token = bin2hex(random_bytes(16)); $share->expire_at = Support::now()->modify('+' . $days . ' day')->format(Support::DATETIME_LAYOUT); $share->view_count = 0; $share->created_at = Support::now()->format(Support::DATETIME_LAYOUT); $share->updated_at = Support::now()->format(Support::DATETIME_LAYOUT); $share->save(); return [ 'share_token' => (string) $share->share_token, 'expire_at' => Support::formatRfc3339((string) $share->expire_at), 'share_path' => 'pages/share/index?share_token=' . $share->share_token, ]; } public function getShareView(string $token, array $params = []): array { $share = $this->findShareByToken($token); $this->touchShareViewed((int) $share->id); $anchor = !empty($params['date']) ? Support::parseDate((string) $params['date'], 'date')->setTime(23, 59, 59) : Support::now(); $stats = $this->stats((int) $share->uid, [ 'range' => $params['range'] ?? 'week', 'date' => $anchor->format(Support::DATE_LAYOUT), ], $this->getProfile((int) $share->uid)); $homeSummary = $this->getHomeSummary((int) $share->uid, $anchor); $logs = $this->listLogs((int) $share->uid, [ 'page' => $params['page'] ?? 1, 'page_size' => $params['page_size'] ?? 20, 'type' => $params['type'] ?? 'all', ]); $owner = User::findActiveById((int) $share->uid); return [ 'owner' => [ 'nickname' => Support::maskNickname((string) ($owner->nick_name ?? '')), 'avatar_url' => (string) ($owner->avatar_url ?? ''), ], 'share' => [ 'share_token' => (string) $share->share_token, 'expire_at' => Support::formatRfc3339((string) $share->expire_at), 'last_viewed_at' => Support::formatRfc3339((string) $share->last_viewed_at), 'view_count' => (int) $share->view_count + 1, ], 'overview' => [ 'today_count' => (int) $homeSummary['today_count'], 'resisted_count' => (int) $homeSummary['resisted_count'], 'reduced_from_yesterday' => (int) $homeSummary['reduced_from_yesterday'], 'exceeded_yesterday' => (bool) $homeSummary['exceeded_yesterday'], 'last_smoke_at' => (string) $homeSummary['last_smoke_at'], 'seconds_since_last' => (int) $homeSummary['seconds_since_last'], 'streak_days' => (int) $stats['streak_days'], ], 'stats' => $stats, 'logs' => $logs, ]; } private function formatProfileRow(array $row): array { return [ 'id' => (int) ($row['id'] ?? 0), 'baseline_cigs_per_day' => (int) ($row['baseline_cigs_per_day'] ?? 0), 'smoking_years' => (float) ($row['smoking_years'] ?? 0), 'pack_price_cent' => (int) ($row['pack_price_cent'] ?? 0), 'smoke_motivations' => Support::jsonArray($row['smoke_motivations'] ?? []), 'quit_motivations' => Support::jsonArray($row['quit_motivations'] ?? []), 'mode' => Support::normalizedMode((string) ($row['mode'] ?? 'record')), 'wake_up_time' => (string) ($row['wake_up_time'] ?? ''), 'sleep_time' => (string) ($row['sleep_time'] ?? ''), 'quit_date' => !empty($row['quit_date']) ? Support::formatRfc3339((string) $row['quit_date']) : '', 'achievement_theme_id' => isset($row['achievement_theme_id']) && $row['achievement_theme_id'] !== null ? (int) $row['achievement_theme_id'] : null, 'onboarding_completed_at' => !empty($row['onboarding_completed_at']) ? Support::formatRfc3339((string) $row['onboarding_completed_at']) : '', ]; } private function isProfileCompleted(array $profile): bool { return (int) ($profile['baseline_cigs_per_day'] ?? 0) > 0 && (int) ($profile['pack_price_cent'] ?? 0) > 0 && !empty($profile['quit_motivations']) && trim((string) ($profile['wake_up_time'] ?? '')) !== '' && trim((string) ($profile['sleep_time'] ?? '')) !== ''; } private function adjustToWakeIfInSleep(DateTimeImmutable $time, string $wakeUpTime, string $sleepTime): DateTimeImmutable { $wakeMin = Support::parseHHMM($wakeUpTime); $sleepMin = Support::parseHHMM($sleepTime); if ($wakeMin === $sleepMin) { return $time; } $minuteOfDay = ((int) $time->format('H')) * 60 + (int) $time->format('i'); $inSleep = $wakeMin < $sleepMin ? ($minuteOfDay < $wakeMin || $minuteOfDay >= $sleepMin) : ($minuteOfDay >= $sleepMin && $minuteOfDay < $wakeMin); if (!$inSleep) { return $time; } $wakeToday = Support::dateOnly($time)->setTime(intdiv($wakeMin, 60), $wakeMin % 60); if ($wakeToday <= $time) { $wakeToday = $wakeToday->add(new DateInterval('P1D')); } return $wakeToday; } private function findLastActualSmoke(int $userId): ?DateTimeImmutable { $row = Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->where('num', '>', 0) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC') ->order('id', 'desc') ->find(); return $row ? Support::logEventAt($row) : null; } private function buildStatsRange(string $range, DateTimeImmutable $anchor): array { if ($range === 'month') { $start = Support::dateOnly($anchor->modify('first day of this month')); $end = Support::dateOnly($anchor->modify('last day of this month')); $prevEnd = $start->modify('-1 day'); $prevStart = Support::dateOnly($prevEnd->modify('first day of this month')); return [$start, $end, $prevStart, $prevEnd, 'day']; } if ($range === 'year') { $start = Support::dateOnly(new DateTimeImmutable($anchor->format('Y-01-01'), Support::tz())); $end = Support::dateOnly(new DateTimeImmutable($anchor->format('Y-12-31'), Support::tz())); $prevStart = Support::dateOnly(new DateTimeImmutable(((int) $anchor->format('Y') - 1) . '-01-01', Support::tz())); $prevEnd = Support::dateOnly(new DateTimeImmutable(((int) $anchor->format('Y') - 1) . '-12-31', Support::tz())); return [$start, $end, $prevStart, $prevEnd, 'month']; } if ($range !== 'week') { throw new \RuntimeException('range 应为 week|month|year', 400); } [$start, $end] = Support::weekRange($anchor); return [$start, $end, $start->modify('-7 day'), $end->modify('-7 day'), 'day']; } private function loadDailyTrend(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): array { $rows = Db::connect('mysql')->name('fa_smoke_log') ->field('smoke_time, SUM(num) AS total') ->where('uid', $userId) ->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)]) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->group('smoke_time') ->order('smoke_time', 'asc') ->select() ->toArray(); $counts = []; $total = 0; foreach ($rows as $row) { $counts[(string) $row['smoke_time']] = (int) ($row['total'] ?? 0); $total += (int) ($row['total'] ?? 0); } $trend = []; for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) { $key = $cursor->format(Support::DATE_LAYOUT); $trend[] = ['label' => $key, 'count' => (int) ($counts[$key] ?? 0)]; } return [$trend, $total]; } private function loadMonthlyTrend(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): array { $rows = Db::connect('mysql')->name('fa_smoke_log') ->field("DATE_FORMAT(smoke_time, '%Y-%m') AS month, SUM(num) AS total") ->where('uid', $userId) ->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)]) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->group("DATE_FORMAT(smoke_time, '%Y-%m')") ->order('month', 'asc') ->select() ->toArray(); $counts = []; $total = 0; foreach ($rows as $row) { $counts[(string) $row['month']] = (int) ($row['total'] ?? 0); $total += (int) ($row['total'] ?? 0); } $trend = []; for ($cursor = Support::dateOnly($start->modify('first day of this month')); $cursor <= $end; $cursor = $cursor->modify('first day of next month')) { $key = $cursor->format('Y-m'); $trend[] = ['label' => $key, 'count' => (int) ($counts[$key] ?? 0)]; } return [$trend, $total]; } private function limitTrend(array $items, int $max): array { if ($max <= 0 || count($items) <= $max) { return $items; } $lastIndex = count($items) - 1; $result = []; $seen = []; for ($i = 0; $i < $max; $i++) { $position = (int) round($i * $lastIndex / max(1, $max - 1)); if (isset($seen[$position])) { continue; } $seen[$position] = true; $result[] = $items[$position]; } return $result; } private function sumCigs(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): int { return (int) Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)]) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->sum('num'); } private function countResisted(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): int { return (int) Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->where('level', 0) ->where('num', 0) ->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)]) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->count(); } private function computeMoney(int $userId, ?array $profile, int $actualTotal, DateTimeImmutable $start, DateTimeImmutable $end): array { if (!$profile || (int) ($profile['baseline_cigs_per_day'] ?? 0) <= 0 || (int) ($profile['pack_price_cent'] ?? 0) <= 0) { return ['available' => false]; } $activeDays = (int) Db::connect('mysql')->name('fa_smoke_log') ->distinct(true) ->field('smoke_time') ->where('uid', $userId) ->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)]) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->count(); $expectedTotal = (int) $profile['baseline_cigs_per_day'] * max(0, $activeDays); $savedCigs = max(0, $expectedTotal - $actualTotal); $savedCent = (int) round(($savedCigs / 20) * (int) $profile['pack_price_cent']); return [ 'available' => true, 'pack_price_cent' => (int) $profile['pack_price_cent'], 'cigs_per_pack' => 20, 'expected_total' => $expectedTotal, 'actual_total' => $actualTotal, 'saved_cent' => $savedCent, ]; } private function computeHealth(int $userId, DateTimeImmutable $asOf): array { $lastSmoke = $this->findLastActualSmoke($userId); if (!$lastSmoke) { return ['available' => false]; } $minutes = max(0, (int) floor(($asOf->getTimestamp() - $lastSmoke->getTimestamp()) / 60)); return [ 'available' => true, 'smoke_free_minutes' => $minutes, 'lung_recovery_percent' => $this->computeLungRecoveryPercent($minutes), 'milestones' => $this->buildHealthMilestones($minutes), ]; } private function computeLungRecoveryPercent(int $minutes): int { $days = $minutes / (24 * 60); if ($days < 14) { return (int) round(($days / 14) * 15); } if ($days < 30) { return (int) round(15 + (($days - 14) / 16) * 15); } if ($days < 90) { return (int) round(30 + (($days - 30) / 60) * 20); } return (int) round(min(100, 50 + (($days - 90) / 275) * 50)); } private function buildHealthMilestones(int $minutes): array { $steps = [ ['name' => '心率血压恢复正常', 'minutes' => 20], ['name' => '血氧水平恢复', 'minutes' => 8 * 60], ['name' => '心脏病风险开始下降', 'minutes' => 24 * 60], ['name' => '嗅觉味觉开始恢复', 'minutes' => 48 * 60], ['name' => '肺功能提升 15%', 'minutes' => 14 * 24 * 60], ['name' => '肺功能提升 30%', 'minutes' => 30 * 24 * 60], ['name' => '肺功能提升 50%', 'minutes' => 90 * 24 * 60], ['name' => '心脏病风险降低 50%', 'minutes' => 365 * 24 * 60], ]; return array_map(static function ($step) use ($minutes) { return [ 'name' => $step['name'], 'minutes' => $step['minutes'], 'reached' => $minutes >= $step['minutes'], ]; }, $steps); } private function findShareByToken(string $token): SmokeShare { $share = SmokeShare::where('share_token', trim($token))->whereNull('deleted_at')->find(); if (!$share) { throw new \RuntimeException('分享不存在', 404); } if (!empty($share->revoked_at)) { throw new \RuntimeException('分享已失效', 404); } if (!empty($share->expire_at) && Support::toDateTime((string) $share->expire_at) < Support::now()) { throw new \RuntimeException('分享已过期', 404); } return $share; } private function touchShareViewed(int $shareId): void { Db::connect('mysql')->name('fa_smoke_share') ->where('id', $shareId) ->update([ 'last_viewed_at' => Support::now()->format(Support::DATETIME_LAYOUT), 'view_count' => Db::raw('view_count + 1'), 'updated_at' => Support::now()->format(Support::DATETIME_LAYOUT), ]); } }