setTime(0, 0, 0); } public static function parseDate(?string $value, string $field = 'date'): ?DateTimeImmutable { $text = trim((string) $value); if ($text === '') { return null; } $dt = DateTimeImmutable::createFromFormat(self::DATE_LAYOUT, $text, self::tz()); if (!$dt || $dt->format(self::DATE_LAYOUT) !== $text) { throw new \RuntimeException(sprintf('%s 格式错误,应为 YYYY-MM-DD', $field), 400); } return $dt->setTime(0, 0, 0); } public static function parseDateTime(?string $value, string $field = 'datetime'): ?DateTimeImmutable { $text = trim((string) $value); if ($text === '') { return null; } $dt = DateTimeImmutable::createFromFormat(self::DATETIME_LAYOUT, $text, self::tz()); if ($dt && $dt->format(self::DATETIME_LAYOUT) === $text) { return $dt; } try { return (new DateTimeImmutable($text))->setTimezone(self::tz()); } catch (\Throwable $e) { throw new \RuntimeException(sprintf('%s 格式错误,应为 YYYY-MM-DD HH:MM:SS 或 RFC3339', $field), 400); } } public static function toDateTime($value): DateTimeImmutable { if ($value instanceof DateTimeImmutable) { return $value->setTimezone(self::tz()); } if ($value instanceof DateTimeInterface) { return (new DateTimeImmutable($value->format(DateTimeInterface::ATOM)))->setTimezone(self::tz()); } if (is_int($value)) { return (new DateTimeImmutable('@' . $value))->setTimezone(self::tz()); } if (is_numeric($value) && (string) (int) $value === (string) $value) { return (new DateTimeImmutable('@' . (int) $value))->setTimezone(self::tz()); } $text = trim((string) $value); if ($text === '') { return self::now(); } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $text)) { return new DateTimeImmutable($text . ' 00:00:00', self::tz()); } return (new DateTimeImmutable($text, self::tz()))->setTimezone(self::tz()); } public static function toDbDate($value): ?string { if ($value === null || trim((string) $value) === '') { return null; } return self::dateOnly($value)->format(self::DATE_LAYOUT); } public static function toDbDateTime($value): ?string { if ($value === null || trim((string) $value) === '') { return null; } return self::toDateTime($value)->format(self::DATETIME_LAYOUT); } public static function formatRfc3339($value): string { if ($value === null || trim((string) $value) === '') { return ''; } return self::toDateTime($value)->format(DateTimeInterface::RFC3339); } public static function formatClock($value): string { if ($value === null || trim((string) $value) === '') { return ''; } return self::toDateTime($value)->format('H:i'); } public static function parseHHMM(string $value): int { $text = trim($value); if (!preg_match('/^\d{2}:\d{2}$/', $text)) { throw new \RuntimeException('作息时间格式错误,应为 HH:MM', 400); } [$hour, $minute] = array_map('intval', explode(':', $text)); if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) { throw new \RuntimeException('作息时间格式错误,应为 HH:MM', 400); } return $hour * 60 + $minute; } public static function awakeMinutes(string $wakeUpTime, string $sleepTime): int { $wake = trim($wakeUpTime); $sleep = trim($sleepTime); if ($wake === '' || $sleep === '') { return 16 * 60; } $wakeMin = self::parseHHMM($wake); $sleepMin = self::parseHHMM($sleep); if ($wakeMin === $sleepMin) { return 24 * 60; } if ($sleepMin > $wakeMin) { return $sleepMin - $wakeMin; } return (24 * 60 - $wakeMin) + $sleepMin; } public static function baselineIntervalMinutes(int $awakeMinutes, int $baselineCigsPerDay): int { if ($awakeMinutes <= 0 || $baselineCigsPerDay <= 0) { return 0; } return max(1, intdiv($awakeMinutes, $baselineCigsPerDay)); } public static function normalizedMode(?string $mode): string { $value = trim((string) $mode); return $value === 'quit' ? 'quit' : 'record'; } public static function jsonArray($value): array { if (is_array($value)) { return array_values(array_filter(array_map(function ($item) { return trim((string) $item); }, $value), static function ($item) { return $item !== ''; })); } if ($value === null || $value === '') { return []; } $decoded = json_decode((string) $value, true); return is_array($decoded) ? self::jsonArray($decoded) : []; } public static function jsonEncodeArray(array $items): string { return json_encode(self::jsonArray($items), JSON_UNESCAPED_UNICODE); } public static function formatLog(array $row): array { return [ 'id' => (int) ($row['id'] ?? 0), 'smoke_time' => self::formatRfc3339($row['smoke_time'] ?? null), 'smoke_at' => self::formatRfc3339($row['smoke_at'] ?? null), 'remark' => (string) ($row['remark'] ?? ''), 'reason_tags' => self::jsonArray($row['reason_tags'] ?? []), 'createtime' => isset($row['createtime']) ? (int) $row['createtime'] : 0, 'updatetime' => isset($row['updatetime']) ? (int) $row['updatetime'] : 0, 'deletetime' => isset($row['deletetime']) && $row['deletetime'] !== null ? (int) $row['deletetime'] : null, 'level' => (int) ($row['level'] ?? 1), 'num' => (int) ($row['num'] ?? 1), ]; } public static function logEventAt(array $row): ?DateTimeImmutable { if (!empty($row['smoke_at'])) { return self::toDateTime($row['smoke_at']); } if (!empty($row['createtime'])) { return self::toDateTime((int) $row['createtime']); } if (!empty($row['smoke_time'])) { return self::dateOnly($row['smoke_time']); } return null; } public static function weekRange(DateTimeImmutable $anchor): array { $weekday = (int) $anchor->format('N'); $start = self::dateOnly($anchor)->sub(new DateInterval('P' . ($weekday - 1) . 'D')); return [$start, $start->add(new DateInterval('P6D'))]; } public static function daysBetweenInclusive(DateTimeImmutable $start, DateTimeImmutable $end): int { $start = self::dateOnly($start); $end = self::dateOnly($end); if ($end < $start) { return 0; } return (int) $start->diff($end)->days + 1; } public static function maskNickname(string $value): string { $text = trim($value); if ($text === '') { return '戒烟用户'; } $length = mb_strlen($text); if ($length <= 1) { return '戒烟用户'; } if ($length === 2) { return mb_substr($text, 0, 1) . '*'; } return mb_substr($text, 0, 1) . str_repeat('*', $length - 2) . mb_substr($text, -1); } public static function deriveUserSegment(int $baselineCigsPerDay, float $smokingYears): string { if ($baselineCigsPerDay >= 20 || $smokingYears >= 10) { return 'heavy'; } if ($baselineCigsPerDay >= 10 || $smokingYears >= 3) { return 'moderate'; } return 'newbie'; } public static function truncate(string $value, int $max): string { if ($max <= 0 || mb_strlen($value) <= $max) { return $value; } return mb_substr($value, 0, $max); } }