feat: add smt module
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
<?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();
|
||||
}
|
||||
|
||||
$level = $resisted ? 0 : max(0, (int) ($data['level'] ?? 1));
|
||||
$num = $resisted ? 0 : max(0, (int) ($data['num'] ?? 1));
|
||||
if (!$resisted && $num === 0) {
|
||||
throw new \RuntimeException('num=0 请使用 /smoke/logs/resisted', 400);
|
||||
}
|
||||
|
||||
$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 latestLogs(int $userId, int $limit = 20): array
|
||||
{
|
||||
$limit = min(100, max(1, $limit));
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC')
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return ['items' => array_map(static function ($row) {
|
||||
return Support::formatLog($row);
|
||||
}, $rows)];
|
||||
}
|
||||
|
||||
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 dashboard(int $userId, array $params = []): array
|
||||
{
|
||||
$now = Support::now();
|
||||
[$defaultStart, $defaultEnd] = Support::weekRange($now);
|
||||
$start = !empty($params['start']) ? Support::parseDate((string) $params['start'], 'start') : $defaultStart;
|
||||
$end = !empty($params['end']) ? Support::parseDate((string) $params['end'], 'end') : (!empty($params['start']) ? $start->modify('+6 day') : $defaultEnd);
|
||||
if ($end < $start) {
|
||||
throw new \RuntimeException('end 不能早于 start', 400);
|
||||
}
|
||||
|
||||
$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')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[(string) $row['smoke_time']] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
$today = Support::dateOnly($now);
|
||||
$todayKey = $today->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');
|
||||
|
||||
$lastSmoke = $this->findLastActualSmoke($userId);
|
||||
$minutesSinceLast = null;
|
||||
if ($lastSmoke) {
|
||||
$minutesSinceLast = max(0, (int) floor(($now->getTimestamp() - $lastSmoke->getTimestamp()) / 60));
|
||||
}
|
||||
|
||||
$weekly = [];
|
||||
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) {
|
||||
$key = $cursor->format(Support::DATE_LAYOUT);
|
||||
$weekly[] = [
|
||||
'date' => $key,
|
||||
'count' => (int) ($counts[$key] ?? 0),
|
||||
'is_today' => $key === $todayKey,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'today_count' => $todayCount,
|
||||
'minutes_since_last' => $minutesSinceLast,
|
||||
'weekly' => $weekly,
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
public function revokeShare(int $userId, string $token): array
|
||||
{
|
||||
$share = SmokeShare::where('share_token', trim($token))->whereNull('deleted_at')->find();
|
||||
if (!$share) {
|
||||
throw new \RuntimeException('分享不存在', 404);
|
||||
}
|
||||
if ((int) $share->uid !== $userId) {
|
||||
throw new \RuntimeException('无权限操作该分享', 403);
|
||||
}
|
||||
|
||||
$share->revoked_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$share->updated_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$share->save();
|
||||
|
||||
return ['revoked' => true];
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user