getCachedByType((int) $user['id'], self::TYPE_DAILY_ADVICE, $adviceDate, $promptVersion); if ($cached) { return $cached; } if (!$this->isAllowed($user, $adviceDate)) { throw new \RuntimeException('需要会员或观看广告解锁后才可生成建议', 403); } [$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $adviceDate); [$content, $meta] = $this->generateAdviceText($snapshot); return $this->saveAdviceRecord( (int) $user['id'], self::TYPE_DAILY_ADVICE, $adviceDate, $promptVersion, $snapshotJson, $content, $meta ); } public function unlock(array $user, DateTimeImmutable $unlockDate): array { $unlockDate = Support::dateOnly($unlockDate); $now = Support::now(); $existing = SmokeAIAdviceUnlock::where('uid', (int) $user['id']) ->where('unlock_date', $unlockDate->format(Support::DATE_LAYOUT)) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->find(); if (!$existing) { $existing = new SmokeAIAdviceUnlock(); $existing->uid = (int) $user['id']; $existing->unlock_date = $unlockDate->format(Support::DATE_LAYOUT); $existing->createtime = $now->getTimestamp(); } $existing->ad_watched_at = $now->format(Support::DATETIME_LAYOUT); $existing->updatetime = $now->getTimestamp(); $existing->save(); return [ 'unlocked' => true, 'date' => $unlockDate->format(Support::DATE_LAYOUT), ]; } public function getCachedByType(int $userId, string $type, DateTimeImmutable $date, string $promptVersion): ?array { $row = SmokeAIAdvice::where('uid', $userId) ->where('type', $type) ->where('advice_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT)) ->where('prompt_version', $promptVersion) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->find(); if (!$row) { return null; } return $this->formatAdviceRecord($row->toArray()); } public function getOrGenerateDailySummary(array $user, DateTimeImmutable $summaryDate, string $promptVersion = 'v1'): array { $cached = $this->getCachedByType((int) $user['id'], self::TYPE_DAILY_SUMMARY, $summaryDate, $promptVersion); if ($cached) { return $cached; } if (!$this->isAllowed($user, $summaryDate)) { throw new \RuntimeException('需要会员或观看广告解锁后才可生成总结', 403); } [$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $summaryDate, true); [$content, $meta] = $this->generateDailySummaryText($snapshot); return $this->saveAdviceRecord( (int) $user['id'], self::TYPE_DAILY_SUMMARY, $summaryDate, $promptVersion, $snapshotJson, $content, $meta ); } public function getCachedNextSmoke(array $user, DateTimeImmutable $planDate, string $promptVersion = 'v1'): ?array { $advice = SmokeAIAdvice::where('uid', (int) $user['id']) ->where('type', self::TYPE_NEXT_SMOKE) ->where('advice_date', Support::dateOnly($planDate)->format(Support::DATE_LAYOUT)) ->where('prompt_version', $promptVersion) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->order('id', 'desc') ->find(); if (!$advice) { return null; } $suggestion = $this->buildNextSuggestionFromCache($advice); if ($this->shouldRefreshNextCache($suggestion, $planDate)) { return null; } return $suggestion; } public function getOrGenerateNextSmoke( array $user, DateTimeImmutable $asOf, DateTimeImmutable $planDate, string $promptVersion, array $defaultSuggestion ): array { $planDate = Support::dateOnly($planDate); $cached = $this->getCachedNextSmoke($user, $planDate, $promptVersion); if ($cached) { return $cached; } if (!$this->isAllowed($user, $planDate)) { throw new \RuntimeException('需要观看广告解锁后才可生成', 403); } $recent3Days = $this->loadRecent3Days((int) $user['id'], $planDate); $profile = $this->loadProfileContext((int) $user['id']); [$content, $nodes, $notBeforeAt, $suggestedAt, $meta] = $this->generateNextSmokeSuggestion($asOf, $planDate, $defaultSuggestion, $recent3Days, $profile); $advice = SmokeAIAdvice::where('uid', (int) $user['id']) ->where('type', self::TYPE_NEXT_SMOKE) ->where('advice_date', $planDate->format(Support::DATE_LAYOUT)) ->where('prompt_version', $promptVersion) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->find(); $snapshot = [ 'as_of' => $asOf->format(DATE_ATOM), 'plan_date' => $planDate->format(Support::DATE_LAYOUT), 'default_suggestion' => $defaultSuggestion, 'profile' => $profile, 'recent_3_days' => $recent3Days, ]; $snapshotJson = json_encode($snapshot, JSON_UNESCAPED_UNICODE); $now = time(); if (!$advice) { $advice = new SmokeAIAdvice(); $advice->uid = (int) $user['id']; $advice->type = self::TYPE_NEXT_SMOKE; $advice->advice_date = $planDate->format(Support::DATE_LAYOUT); $advice->prompt_version = $promptVersion; $advice->createtime = $now; } $advice->provider = $meta['provider']; $advice->model = $meta['model']; $advice->input_snapshot = $snapshotJson; $advice->advice = $content; $advice->tokens_in = $meta['tokens_in']; $advice->tokens_out = $meta['tokens_out']; $advice->updatetime = $now; $advice->save(); SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id)->delete(); $this->saveNextNodes((int) $user['id'], (int) $advice->id, $planDate, $notBeforeAt, $suggestedAt, $nodes); return [ 'plan_date' => $planDate->format(Support::DATE_LAYOUT), 'not_before_at' => $notBeforeAt->format(DATE_ATOM), 'suggested_at' => $suggestedAt->format(DATE_ATOM), 'time_nodes' => $nodes, 'advice' => $content, 'prompt_version' => $promptVersion, 'model' => $meta['model'], 'provider' => $meta['provider'], ]; } private function saveAdviceRecord( int $userId, string $type, DateTimeImmutable $date, string $promptVersion, string $snapshotJson, string $content, array $meta ): array { $now = time(); $row = new SmokeAIAdvice(); $row->uid = $userId; $row->type = $type; $row->advice_date = Support::dateOnly($date)->format(Support::DATE_LAYOUT); $row->prompt_version = $promptVersion; $row->provider = $meta['provider']; $row->model = $meta['model']; $row->input_snapshot = $snapshotJson; $row->advice = $content; $row->tokens_in = $meta['tokens_in']; $row->tokens_out = $meta['tokens_out']; $row->createtime = $now; $row->updatetime = $now; $row->save(); return $this->formatAdviceRecord($row->toArray()); } private function formatAdviceRecord(array $row): array { return [ 'id' => (int) ($row['id'] ?? 0), 'uid' => (int) ($row['uid'] ?? 0), 'type' => (string) ($row['type'] ?? ''), 'date' => (string) ($row['advice_date'] ?? ''), 'advice_date' => (string) ($row['advice_date'] ?? ''), 'prompt_version' => (string) ($row['prompt_version'] ?? ''), 'provider' => (string) ($row['provider'] ?? ''), 'model' => (string) ($row['model'] ?? ''), 'advice' => (string) ($row['advice'] ?? ''), 'content' => (string) ($row['advice'] ?? ''), ]; } private function buildSnapshot(int $userId, DateTimeImmutable $date, bool $todayMessage = false): array { $rows = Db::connect('mysql')->name('fa_smoke_log') ->where('uid', $userId) ->where('smoke_time', Support::dateOnly($date)->format(Support::DATE_LAYOUT)) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC') ->order('id', 'asc') ->select() ->toArray(); if (empty($rows)) { throw new \RuntimeException($todayMessage ? '今天还没有抽烟记录,无法生成总结' : '该日期没有抽烟记录,无法生成建议', 400); } $total = 0; $nodes = []; foreach ($rows as $row) { $total += (int) ($row['num'] ?? 0); $eventAt = Support::logEventAt($row); $nodes[] = [ 'time' => $eventAt ? $eventAt->format('H:i') : '', 'num' => (int) ($row['num'] ?? 0), 'level' => (int) ($row['level'] ?? 1), 'remark' => (string) ($row['remark'] ?? ''), ]; } $profile = $this->loadProfileContext($userId); $snapshot = [ 'date' => Support::dateOnly($date)->format(Support::DATE_LAYOUT), 'total_num' => $total, 'nodes' => $nodes, 'profile' => $profile, ]; return [$snapshot, json_encode($snapshot, JSON_UNESCAPED_UNICODE)]; } private function loadProfileContext(int $userId): ?array { $profile = SmokeUserProfile::findByUid($userId); if (!$profile) { return null; } $wakeUpTime = trim((string) $profile->wake_up_time); $sleepTime = trim((string) $profile->sleep_time); $awakeMinutes = 16 * 60; try { $awakeMinutes = Support::awakeMinutes($wakeUpTime, $sleepTime); } catch (\Throwable $e) { } return [ 'baseline_cigs_per_day' => (int) $profile->baseline_cigs_per_day, 'smoking_years' => (float) $profile->smoking_years, 'pack_price_cent' => (int) $profile->pack_price_cent, 'smoke_motivations' => Support::jsonArray($profile->smoke_motivations), 'quit_motivations' => Support::jsonArray($profile->quit_motivations), 'wake_up_time' => $wakeUpTime, 'sleep_time' => $sleepTime, 'awake_minutes' => $awakeMinutes, 'baseline_interval_minutes' => Support::baselineIntervalMinutes($awakeMinutes, (int) $profile->baseline_cigs_per_day), 'user_segment' => Support::deriveUserSegment((int) $profile->baseline_cigs_per_day, (float) $profile->smoking_years), ]; } private function isAllowed(array $user, DateTimeImmutable $date): bool { return $this->hasActiveMembership($user) || $this->isUnlocked((int) $user['id'], $date); } private function hasActiveMembership(array $user): bool { $count = UserMembership::where('mini_program_id', (int) $user['mini_program_id']) ->where('user_id', (int) $user['id']) ->where('status', 'active') ->where('ends_at', '>', Support::now()->format(Support::DATETIME_LAYOUT)) ->whereNull('deleted_at') ->count(); return (int) $count > 0; } private function isUnlocked(int $userId, DateTimeImmutable $date): bool { return SmokeAIAdviceUnlock::where('uid', $userId) ->where('unlock_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT)) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->find() !== null; } private function generateAdviceText(array $snapshot): array { $fallback = $this->buildFallbackAdvice($snapshot); if (!$this->hasAiConfig()) { return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]]; } $systemPrompt = trim('你是一名专业的戒烟教练。请基于用户昨天的抽烟记录,输出中文建议:先给出 1-3 条模式分析,再给出至少 5 条今天的具体行动建议,最后补充一个 60 秒顶住烟瘾的应对流程。'); $userPrompt = '用户昨日数据(JSON):' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE); try { $resp = $this->callChat([ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $userPrompt], ]); return [$resp['content'], $resp]; } catch (\Throwable $e) { return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]]; } } private function generateDailySummaryText(array $snapshot): array { $fallback = $this->buildFallbackDailySummary($snapshot); if (!$this->hasAiConfig()) { return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]]; } $systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSON,字段为 summary、highlights、suggestion。内容基于用户当天抽烟数据,语气鼓励。'); $userPrompt = '用户今日数据(JSON):' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE); try { $resp = $this->callChat([ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $userPrompt], ]); $jsonText = $this->extractJson($resp['content']); if ($jsonText !== '') { return [$jsonText, $resp]; } } catch (\Throwable $e) { } return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]]; } private function generateNextSmokeSuggestion( DateTimeImmutable $asOf, DateTimeImmutable $planDate, array $defaultSuggestion, array $recent3Days, ?array $profile ): array { [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt] = $this->buildFallbackNextSmoke($asOf, $planDate, $defaultSuggestion, $profile); if (!$this->hasAiConfig()) { return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]]; } $input = [ 'as_of' => $asOf->format(DATE_ATOM), 'plan_date' => $planDate->format(Support::DATE_LAYOUT), 'default_suggestion' => $defaultSuggestion, 'profile' => $profile, 'recent_3_days' => $recent3Days, ]; $systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSON:not_before_at、suggested_at、time_nodes、advice。所有时间必须属于 plan_date,time_nodes 用 HH:MM。'); $userPrompt = '输入(JSON):' . PHP_EOL . json_encode($input, JSON_UNESCAPED_UNICODE); try { $resp = $this->callChat([ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $userPrompt], ]); $jsonText = $this->extractJson($resp['content']); if ($jsonText !== '') { $decoded = json_decode($jsonText, true); if (is_array($decoded)) { $notBeforeAt = $this->parseFlexibleTime((string) ($decoded['not_before_at'] ?? ''), $planDate, $fallbackNotBeforeAt); $suggestedAt = $this->parseFlexibleTime((string) ($decoded['suggested_at'] ?? ''), $planDate, $fallbackSuggestedAt); if ($suggestedAt < $notBeforeAt) { $suggestedAt = $notBeforeAt; } $nodes = $this->normalizeNodes($decoded['time_nodes'] ?? [], $planDate, $notBeforeAt, $profile); return [ trim((string) ($decoded['advice'] ?? $fallbackContent)), $nodes, $notBeforeAt, $suggestedAt, $resp, ]; } } } catch (\Throwable $e) { } return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]]; } private function buildFallbackAdvice(array $snapshot): string { $total = (int) ($snapshot['total_num'] ?? 0); $nodes = $snapshot['nodes'] ?? []; $first = $nodes[0]['time'] ?? ''; $last = $nodes[count($nodes) - 1]['time'] ?? ''; $quitMotivation = $snapshot['profile']['quit_motivations'][0] ?? '把状态调整回来'; $lines = [ sprintf('昨天你一共记录了 %d 支烟。%s%s', $total, $first !== '' ? '第一支大约在 ' . $first . ',' : '', $last !== '' ? '最后一支在 ' . $last . '。' : ''), '今天先盯住最容易失守的一个时段,把第一根或最顺手的一根至少延后 10 分钟。', '每次想抽的时候先喝半杯水,站起来走 30-60 秒,再决定要不要点烟。', '如果是饭后或社交触发,提前准备口香糖、无糖饮料或离开吸烟环境 3 分钟。', '把今天的目标改成“少一支也算赢”,不要追求一次性完美。', sprintf('情绪上来时,重复提醒自己:%s。', $quitMotivation), '60 秒应对流程:先深呼吸 4 次,然后喝水 3 口,再拖延 1 分钟,通常烟瘾峰值会先过去。', ]; return implode("\n", $lines); } private function buildFallbackDailySummary(array $snapshot): string { $total = (int) ($snapshot['total_num'] ?? 0); $nodes = $snapshot['nodes'] ?? []; $times = array_values(array_filter(array_map(static function ($item) { return trim((string) ($item['time'] ?? '')); }, $nodes))); $highlights = []; if (!empty($times)) { $highlights[] = sprintf('今天首支烟时间大约在 %s,最后一次记录在 %s。', $times[0], $times[count($times) - 1]); } $highlights[] = sprintf('今日总量为 %d 支,后续可重点观察最容易连续抽烟的时段。', $total); $highlights[] = '如果明天只盯住一个节点,优先尝试把最早或最顺手的一支往后拖 10 分钟。'; return json_encode([ 'summary' => sprintf('今天共记录 %d 支烟,整体节奏已经被你清楚地记录下来,这本身就是建立改变的第一步。接下来重点不是苛责自己,而是抓住一个最容易多抽的时段做微调。', $total), 'highlights' => array_slice($highlights, 0, 3), 'suggestion' => '明天先只做一个动作:把最容易点上的那一支延后 10 分钟,并在这段时间用喝水或起身走动替代。', ], JSON_UNESCAPED_UNICODE); } private function buildFallbackNextSmoke( DateTimeImmutable $asOf, DateTimeImmutable $planDate, array $defaultSuggestion, ?array $profile ): array { $notBeforeAt = !empty($defaultSuggestion['next_smoke_at']) ? Support::toDateTime((string) $defaultSuggestion['next_smoke_at']) : $asOf->add(new DateInterval('PT5M')); if (Support::dateOnly($planDate) > Support::dateOnly($asOf)) { $notBeforeAt = Support::dateOnly($planDate)->setTime(7, 0); if ($profile && !empty($profile['wake_up_time'])) { $wakeMin = Support::parseHHMM((string) $profile['wake_up_time']); $notBeforeAt = Support::dateOnly($planDate)->setTime(intdiv($wakeMin, 60), $wakeMin % 60); } } $suggestedAt = $notBeforeAt; $interval = max(20, min(120, (int) ($defaultSuggestion['interval_minutes'] ?? 60))); $nodes = []; $cursor = $suggestedAt; for ($i = 0; $i < 4; $i++) { if (Support::dateOnly($cursor) != Support::dateOnly($planDate)) { break; } $nodes[] = $cursor->format('H:i'); $cursor = $cursor->add(new DateInterval('PT' . max(15, (int) round($interval * 0.7)) . 'M')); } $content = sprintf('先按默认节奏走,建议至少等到 %s。如果这段时间烟瘾明显上来,先用喝水、深呼吸或短暂走动顶一轮,再决定要不要抽。', $suggestedAt->format('H:i')); return [$content, array_values(array_unique($nodes)), $notBeforeAt, $suggestedAt]; } private function loadRecent3Days(int $userId, DateTimeImmutable $planDate): array { $today = Support::dateOnly(); $end = $planDate > $today ? $today : $planDate; $start = $end->modify('-2 day'); $rows = 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)') ->order('smoke_time', 'asc') ->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC') ->order('id', 'asc') ->select() ->toArray(); $grouped = []; foreach ($rows as $row) { $day = (string) ($row['smoke_time'] ?? ''); if (!isset($grouped[$day])) { $grouped[$day] = ['date' => $day, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []]; } $isResisted = (int) ($row['level'] ?? 1) === 0 && (int) ($row['num'] ?? 1) === 0; if ($isResisted) { $grouped[$day]['resisted_count']++; } else { $grouped[$day]['total_num'] += (int) ($row['num'] ?? 0); } $eventAt = Support::logEventAt($row); $grouped[$day]['nodes'][] = [ 'time' => $eventAt ? $eventAt->format('H:i') : '', 'num' => (int) ($row['num'] ?? 0), 'level' => (int) ($row['level'] ?? 1), 'is_resisted' => $isResisted, 'remark' => Support::truncate((string) ($row['remark'] ?? ''), 80), ]; } $result = []; for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) { $key = $cursor->format(Support::DATE_LAYOUT); $result[] = $grouped[$key] ?? ['date' => $key, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []]; } return $result; } private function saveNextNodes(int $userId, int $adviceId, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, DateTimeImmutable $suggestedAt, array $nodes): void { $rows = [ [ 'uid' => $userId, 'plan_date' => $planDate->format(Support::DATE_LAYOUT), 'ai_advice_id' => $adviceId, 'node_type' => 'not_before', 'node_at' => $notBeforeAt->format(Support::DATETIME_LAYOUT), 'createtime' => time(), 'updatetime' => time(), ], [ 'uid' => $userId, 'plan_date' => $planDate->format(Support::DATE_LAYOUT), 'ai_advice_id' => $adviceId, 'node_type' => 'suggested', 'node_at' => $suggestedAt->format(Support::DATETIME_LAYOUT), 'createtime' => time(), 'updatetime' => time(), ], ]; foreach ($nodes as $node) { $nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $suggestedAt); $rows[] = [ 'uid' => $userId, 'plan_date' => $planDate->format(Support::DATE_LAYOUT), 'ai_advice_id' => $adviceId, 'node_type' => 'node', 'node_at' => $nodeAt->format(Support::DATETIME_LAYOUT), 'createtime' => time(), 'updatetime' => time(), ]; } Db::connect('mysql')->name('fa_smoke_ai_next_smoke')->insertAll($rows); } private function buildNextSuggestionFromCache(SmokeAIAdvice $advice): array { $rows = SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id) ->whereRaw('(deletetime IS NULL OR deletetime = 0)') ->order('node_at', 'asc') ->select() ->all(); $notBeforeAt = ''; $suggestedAt = ''; $nodes = []; foreach ($rows as $row) { if ((string) $row->node_type === 'not_before') { $notBeforeAt = Support::formatRfc3339((string) $row->node_at); } elseif ((string) $row->node_type === 'suggested') { $suggestedAt = Support::formatRfc3339((string) $row->node_at); } elseif ((string) $row->node_type === 'node') { $nodes[] = Support::formatClock((string) $row->node_at); } } return [ 'plan_date' => (string) $advice->advice_date, 'not_before_at' => $notBeforeAt, 'suggested_at' => $suggestedAt !== '' ? $suggestedAt : $notBeforeAt, 'time_nodes' => $nodes, 'advice' => (string) $advice->advice, 'prompt_version' => (string) $advice->prompt_version, 'model' => (string) $advice->model, 'provider' => (string) $advice->provider, ]; } private function shouldRefreshNextCache(array $suggestion, DateTimeImmutable $planDate): bool { $nodes = $suggestion['time_nodes'] ?? []; if (empty($nodes)) { return true; } $suggestedAt = trim((string) ($suggestion['suggested_at'] ?? '')); if ($suggestedAt === '') { return true; } $suggested = $this->parseFlexibleTime($suggestedAt, $planDate, Support::now()); $today = Support::dateOnly(); if (Support::dateOnly($planDate) == $today && $suggested <= Support::now()->add(new DateInterval('PT2M'))) { return true; } return false; } private function normalizeNodes(array $nodes, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, ?array $profile): array { $seen = []; $result = []; foreach ($nodes as $node) { try { $nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $notBeforeAt); } catch (\Throwable $e) { continue; } if ($nodeAt < $notBeforeAt) { continue; } if ($profile && !empty($profile['wake_up_time']) && !empty($profile['sleep_time'])) { $nodeAt = $this->adjustToWakeIfInSleep($nodeAt, (string) $profile['wake_up_time'], (string) $profile['sleep_time']); } if (Support::dateOnly($nodeAt) != Support::dateOnly($planDate)) { continue; } $label = $nodeAt->format('H:i'); if (isset($seen[$label])) { continue; } $seen[$label] = true; $result[] = $label; if (count($result) >= 6) { break; } } return $result; } 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 parseFlexibleTime(string $value, DateTimeImmutable $planDate, DateTimeImmutable $fallback): DateTimeImmutable { $text = trim($value); if ($text === '') { return $fallback; } try { if (preg_match('/^\d{2}:\d{2}$/', $text)) { $minutes = Support::parseHHMM($text); return Support::dateOnly($planDate)->setTime(intdiv($minutes, 60), $minutes % 60); } return Support::toDateTime($text); } catch (\Throwable $e) { return $fallback; } } private function hasAiConfig(): bool { return trim((string) env('AI_BASE_URL', '')) !== '' && trim((string) env('AI_API_KEY', '')) !== '' && trim((string) env('AI_MODEL', '')) !== ''; } private function callChat(array $messages): array { $baseUrl = rtrim((string) env('AI_BASE_URL', ''), '/'); $apiKey = trim((string) env('AI_API_KEY', '')); $model = trim((string) env('AI_MODEL', '')); $timeout = max(5, (int) env('AI_TIMEOUT_SECONDS', 15)); $payload = json_encode([ 'model' => $model, 'messages' => $messages, 'temperature' => 0.7, ], JSON_UNESCAPED_UNICODE); $ch = curl_init($baseUrl . '/chat/completions'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, ]); $response = curl_exec($ch); $error = curl_error($ch); $statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($response === false || $response === '' || $error) { throw new \RuntimeException('AI 请求失败', 502); } if ($statusCode !== 200) { throw new \RuntimeException('AI 服务响应异常', 502); } $decoded = json_decode($response, true); $content = trim((string) ($decoded['choices'][0]['message']['content'] ?? '')); if ($content === '') { throw new \RuntimeException('AI 返回内容为空', 502); } return [ 'content' => $content, 'provider' => 'openai-compatible', 'model' => (string) ($decoded['model'] ?? $model), 'tokens_in' => isset($decoded['usage']['prompt_tokens']) ? (int) $decoded['usage']['prompt_tokens'] : null, 'tokens_out' => isset($decoded['usage']['completion_tokens']) ? (int) $decoded['usage']['completion_tokens'] : null, ]; } private function extractJson(string $value): string { $start = strpos($value, '{'); $end = strrpos($value, '}'); if ($start === false || $end === false || $end <= $start) { return ''; } return substr($value, $start, $end - $start + 1); } }