Files
2026-04-26 22:05:21 +08:00

844 lines
34 KiB
PHP

<?php
declare(strict_types=1);
namespace app\smt\service;
use app\smt\model\SmokeLog;
use app\smt\model\SmokeMotivationQuote;
use app\smt\model\SmokeShare;
use app\smt\model\SmokeUserProfile;
use app\smt\model\User;
use DateInterval;
use DateTimeImmutable;
use think\facade\Db;
class SmokeService
{
public function getProfile(int $userId): ?array
{
$profile = SmokeUserProfile::findByUid($userId);
if (!$profile) {
return null;
}
return $this->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),
]);
}
}