loadOrInitProfile((int) $user['id']); return $this->formatProfile($profile, $user); } public function upsertProfile(array $user, array $data): array { $uid = (int) $user['id']; $profile = $this->loadOrInitProfile($uid); $updates = ['updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)]; if (isset($data['quit_start_date']) && trim((string) $data['quit_start_date']) !== '') { $updates['quit_start_date'] = Support::parseDate((string) $data['quit_start_date'], 'quit_start_date')->format(Support::DATE_LAYOUT); } if (array_key_exists('pack_price_cent', $data)) { $updates['pack_price_cent'] = max(0, (int) $data['pack_price_cent']); } if (array_key_exists('baseline_cigs_per_day', $data)) { $updates['baseline_cigs_per_day'] = max(0, (int) $data['baseline_cigs_per_day']); } if (array_key_exists('motivation', $data)) { $updates['motivation'] = Support::truncate(trim((string) $data['motivation']), 200); } if (array_key_exists('notify_time', $data)) { $notifyTime = trim((string) $data['notify_time']); if ($notifyTime !== '') { $this->assertHHMM($notifyTime); } $updates['notify_time'] = $notifyTime !== '' ? $notifyTime : '21:00'; } Db::connect('mysql')->name('fa_quit_checkin_profile')->where('uid', $uid)->whereNull('deleted_at')->update($updates); return $this->formatProfile(array_merge($profile, $updates), $user); } public function home(int $uid): array { $today = Support::dateOnly()->format(Support::DATE_LAYOUT); return [ 'daily_status' => $this->dailyStatus($uid, $today), 'summary' => $this->summary($uid), 'goal' => $this->activeGoal($uid), 'badges' => [], ]; } public function checkin(int $uid, array $data): array { $date = Support::parseDate((string) ($data['date'] ?? ''), 'date') ?: Support::dateOnly(); $dateText = $date->format(Support::DATE_LAYOUT); $now = Support::now()->format(Support::DATETIME_LAYOUT); $note = Support::truncate(trim((string) ($data['note'] ?? '')), 200); $db = Db::connect('mysql'); $existing = $db->name('fa_quit_checkin_daily_status')->where('uid', $uid)->where('date', $dateText)->whereNull('deleted_at')->find(); $payload = [ 'uid' => $uid, 'date' => $dateText, 'status' => 'checked_in', 'check_in_at' => $now, 'relapsed_at' => null, 'relapse_num' => 0, 'reason' => '', 'note' => $note, 'updated_at' => $now, ]; if ($existing) { $db->name('fa_quit_checkin_daily_status')->where('id', (int) $existing['id'])->update($payload); } else { $payload['created_at'] = $now; $db->name('fa_quit_checkin_daily_status')->insert($payload); } return ['daily_status' => $this->dailyStatus($uid, $dateText), 'summary' => $this->summary($uid)]; } public function listDreamPresets(): array { return Db::connect('mysql')->name('fa_dream_goal_preset') ->where('is_active', 1) ->whereNull('deleted_at') ->order('sort_order asc,id asc') ->select() ->toArray(); } public function listRewardGoals(int $uid, string $status): array { $query = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('uid', $uid)->whereNull('deleted_at'); if (in_array($status, ['active', 'completed', 'archived'], true)) { $query->where('status', $status); } $rows = $query->order('id desc')->select()->toArray(); $saved = $this->summary($uid)['saved_money_cent']; $items = array_map(fn (array $row): array => $this->formatGoal($row, $saved), $rows); return ['items' => $items, 'total' => count($items)]; } public function createRewardGoal(int $uid, array $data): array { $title = Support::truncate(trim((string) ($data['title'] ?? '')), 64); $amount = (int) ($data['target_amount_cent'] ?? 0); if ($title === '' || $amount <= 0) { throw new \RuntimeException('目标名称和金额不能为空', 400); } $now = Support::now()->format(Support::DATETIME_LAYOUT); $id = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->insertGetId([ 'uid' => $uid, 'title' => $title, 'target_amount_cent' => $amount, 'cover_image' => Support::truncate(trim((string) ($data['cover_image'] ?? '')), 500), 'status' => 'active', 'created_at' => $now, 'updated_at' => $now, ]); return $this->formatGoal($this->findGoal($uid, (int) $id), $this->summary($uid)['saved_money_cent']); } public function updateRewardGoal(int $uid, int $id, array $data): array { $goal = $this->findGoal($uid, $id); $updates = ['updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)]; if (array_key_exists('title', $data)) { $title = Support::truncate(trim((string) $data['title']), 64); if ($title !== '') { $updates['title'] = $title; } } if (array_key_exists('target_amount_cent', $data)) { $amount = (int) $data['target_amount_cent']; if ($amount > 0) { $updates['target_amount_cent'] = $amount; } } if (array_key_exists('cover_image', $data)) { $updates['cover_image'] = Support::truncate(trim((string) $data['cover_image']), 500); } if (array_key_exists('status', $data) && in_array((string) $data['status'], ['active', 'completed', 'archived'], true)) { $updates['status'] = (string) $data['status']; $updates['completed_at'] = $updates['status'] === 'completed' ? $updates['updated_at'] : null; } Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('id', (int) $goal['id'])->update($updates); return $this->formatGoal($this->findGoal($uid, $id), $this->summary($uid)['saved_money_cent']); } public function createSupervisorInvite(int $ownerUID, int $days): array { $days = $days > 0 ? min($days, 30) : 7; $now = Support::now(); $token = bin2hex(random_bytes(16)); $expireAt = $now->add(new DateInterval('P' . $days . 'D'))->format(Support::DATETIME_LAYOUT); Db::connect('mysql')->name('fa_quit_checkin_supervisor_invite')->insert([ 'owner_uid' => $ownerUID, 'token' => $token, 'expire_at' => $expireAt, 'created_at' => $now->format(Support::DATETIME_LAYOUT), 'updated_at' => $now->format(Support::DATETIME_LAYOUT), ]); return ['token' => $token, 'expire_at' => Support::formatRfc3339($expireAt)]; } public function bindSupervisorInvite(int $supervisorUID, string $token): array { $token = trim($token); if ($token === '') { throw new \RuntimeException('请求参数错误', 400); } $db = Db::connect('mysql'); $invite = $db->name('fa_quit_checkin_supervisor_invite')->where('token', $token)->whereNull('deleted_at')->find(); if (!$invite) { throw new \RuntimeException('邀请不存在', 400); } if (!empty($invite['used_at']) || !empty($invite['used_by_uid'])) { throw new \RuntimeException('邀请已被使用', 400); } if (Support::toDateTime($invite['expire_at']) < Support::now()) { throw new \RuntimeException('邀请已过期', 400); } $ownerUID = (int) $invite['owner_uid']; if ($ownerUID === $supervisorUID) { throw new \RuntimeException('不能绑定自己', 400); } $exists = $db->name('fa_quit_checkin_supervisor_binding') ->where('owner_uid', $ownerUID) ->where('supervisor_uid', $supervisorUID) ->where('status', 'active') ->whereNull('deleted_at') ->find(); if ($exists) { throw new \RuntimeException('已绑定,无需重复操作', 400); } $count = (int) $db->name('fa_quit_checkin_supervisor_binding') ->where('owner_uid', $ownerUID) ->where('status', 'active') ->whereNull('deleted_at') ->count(); if ($count >= 3) { throw new \RuntimeException('对方监督人已达上限(最多 3 人)', 400); } $now = Support::now()->format(Support::DATETIME_LAYOUT); $db->startTrans(); try { $db->name('fa_quit_checkin_supervisor_binding')->insert([ 'owner_uid' => $ownerUID, 'supervisor_uid' => $supervisorUID, 'status' => 'active', 'created_at' => $now, 'updated_at' => $now, ]); $db->name('fa_quit_checkin_supervisor_invite')->where('id', (int) $invite['id'])->update([ 'used_at' => $now, 'used_by_uid' => $supervisorUID, 'updated_at' => $now, ]); $db->commit(); } catch (\Throwable $e) { $db->rollback(); throw $e; } return ['ok' => true]; } public function supervisorOverview(int $supervisorUID): array { $bindings = Db::connect('mysql')->name('fa_quit_checkin_supervisor_binding') ->where('supervisor_uid', $supervisorUID) ->where('status', 'active') ->whereNull('deleted_at') ->select() ->toArray(); $items = []; foreach ($bindings as $binding) { $ownerUID = (int) $binding['owner_uid']; $owner = User::findActiveById($ownerUID); if (!$owner) { continue; } $items[] = [ 'owner' => $this->userSummary($owner), 'home' => $this->home($ownerUID), ]; } return ['items' => $items]; } public function supervisorStatus(int $ownerUID): array { $bindings = Db::connect('mysql')->name('fa_quit_checkin_supervisor_binding') ->where('owner_uid', $ownerUID) ->where('status', 'active') ->whereNull('deleted_at') ->select() ->toArray(); $items = []; foreach ($bindings as $binding) { $user = User::findActiveById((int) $binding['supervisor_uid']); if ($user) { $items[] = $this->userSummary($user); } } return ['items' => $items]; } public function revokeSupervisorBinding(int $actorUID, array $data): array { $ownerUID = (int) ($data['owner_uid'] ?? 0); $supervisorUID = (int) ($data['supervisor_uid'] ?? 0); if ($ownerUID <= 0 || $supervisorUID <= 0 || ($actorUID !== $ownerUID && $actorUID !== $supervisorUID)) { throw new \RuntimeException('解除失败', 400); } Db::connect('mysql')->name('fa_quit_checkin_supervisor_binding') ->where('owner_uid', $ownerUID) ->where('supervisor_uid', $supervisorUID) ->where('status', 'active') ->update(['status' => 'revoked', 'updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)]); return ['ok' => true]; } public function getReminderSettings(int $ownerUID): array { $row = $this->loadOrInitReminderSetting($ownerUID); return [ 'enabled' => (bool) $row['enabled'], 'notify_time' => (string) $row['notify_time'], 'max_per_day' => (int) $row['max_per_day'], ]; } public function updateReminderSettings(int $ownerUID, array $data): array { $this->loadOrInitReminderSetting($ownerUID); $updates = ['updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)]; if (array_key_exists('enabled', $data)) { $updates['enabled'] = (bool) $data['enabled'] ? 1 : 0; } if (array_key_exists('notify_time', $data)) { $notifyTime = trim((string) $data['notify_time']); $this->assertHHMM($notifyTime); $updates['notify_time'] = $notifyTime; } if (array_key_exists('max_per_day', $data)) { $max = (int) $data['max_per_day']; if ($max < 0 || $max > 10) { throw new \RuntimeException('保存提醒设置失败', 400); } $updates['max_per_day'] = $max; } Db::connect('mysql')->name('fa_quit_checkin_supervisor_reminder_setting') ->where('owner_uid', $ownerUID) ->whereNull('deleted_at') ->update($updates); return $this->getReminderSettings($ownerUID); } public function runReminders(int $supervisorUID): array { $db = Db::connect('mysql'); $today = Support::dateOnly()->format(Support::DATE_LAYOUT); $now = Support::now()->format(Support::DATETIME_LAYOUT); $bindings = $db->name('fa_quit_checkin_supervisor_binding') ->where('supervisor_uid', $supervisorUID) ->where('status', 'active') ->whereNull('deleted_at') ->select() ->toArray(); $created = 0; $skipped = 0; foreach ($bindings as $binding) { $ownerUID = (int) $binding['owner_uid']; $setting = $this->loadOrInitReminderSetting($ownerUID); if (!(bool) $setting['enabled'] || (int) $setting['max_per_day'] <= 0 || !$this->isAfterNotifyTime((string) $setting['notify_time'])) { $skipped++; continue; } $status = $db->name('fa_quit_checkin_daily_status')->where('uid', $ownerUID)->where('date', $today)->whereNull('deleted_at')->find(); if ($status && in_array((string) $status['status'], ['checked_in', 'relapsed'], true)) { $skipped++; continue; } $count = (int) $db->name('fa_quit_checkin_supervisor_reminder_log') ->where('owner_uid', $ownerUID) ->where('supervisor_uid', $supervisorUID) ->where('reminder_date', $today) ->whereNull('deleted_at') ->count(); if ($count >= (int) $setting['max_per_day']) { $skipped++; continue; } $db->name('fa_quit_checkin_supervisor_reminder_log')->insert([ 'owner_uid' => $ownerUID, 'supervisor_uid' => $supervisorUID, 'reminder_date' => $today, 'reminder_at' => $now, 'type' => 'missed_checkin', 'status' => 'stubbed', 'channel' => 'stub', 'message' => '提醒:戒烟用户今天还没打卡', 'created_at' => $now, 'updated_at' => $now, ]); $created++; } return ['created' => $created, 'skipped' => $skipped]; } private function dailyStatus(int $uid, string $date): array { $row = Db::connect('mysql')->name('fa_quit_checkin_daily_status')->where('uid', $uid)->where('date', $date)->whereNull('deleted_at')->find(); if (!$row) { return ['date' => $date, 'status' => 'pending', 'checkin_at' => null, 'relapsed_at' => null, 'relapse_num' => null, 'note' => null]; } return [ 'date' => (string) $row['date'], 'status' => (string) $row['status'], 'checkin_at' => !empty($row['check_in_at']) ? Support::formatRfc3339($row['check_in_at']) : null, 'relapsed_at' => !empty($row['relapsed_at']) ? Support::formatRfc3339($row['relapsed_at']) : null, 'relapse_num' => isset($row['relapse_num']) ? (int) $row['relapse_num'] : null, 'note' => $row['note'] !== null ? (string) $row['note'] : null, ]; } private function summary(int $uid): array { $profile = Db::connect('mysql')->name('fa_quit_checkin_profile')->where('uid', $uid)->whereNull('deleted_at')->find(); $start = Support::dateOnly($profile['quit_start_date'] ?? null); $days = max(0, (int) $start->diff(Support::dateOnly())->days + 1); $baseline = (int) ($profile['baseline_cigs_per_day'] ?? 0); $packPrice = (int) ($profile['pack_price_cent'] ?? 0); $relapses = (int) Db::connect('mysql')->name('fa_quit_checkin_daily_status')->where('uid', $uid)->where('status', 'relapsed')->whereNull('deleted_at')->count(); $streak = max(0, $days - $relapses); $avoided = $streak * $baseline; $saved = $baseline > 0 ? (int) round($avoided * ($packPrice / 20)) : 0; return [ 'current_streak_days' => $streak, 'max_streak_days' => $streak, 'milestone_days' => 7, 'days_to_next_milestone' => max(0, 7 - $streak), 'saved_money_cent' => $saved, 'avoided_cigs' => $avoided, 'avoided_cigs_mode' => 'baseline', 'health_recovery_percent' => min(100, $streak * 2), 'hp_current' => isset($profile['hp_current']) ? (int) $profile['hp_current'] : 100, 'hp_change_today' => 0, ]; } private function activeGoal(int $uid): ?array { $row = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('uid', $uid)->where('status', 'active')->whereNull('deleted_at')->order('id desc')->find(); if (!$row) { return null; } $saved = $this->summary($uid)['saved_money_cent']; return $this->formatGoal($row, $saved); } private function formatGoal(array $row, int $saved): array { $target = max(1, (int) $row['target_amount_cent']); return [ 'id' => (int) $row['id'], 'user_id' => (int) $row['uid'], 'title' => (string) $row['title'], 'target_amount_cent' => (int) $row['target_amount_cent'], 'current_amount_cent' => min($saved, (int) $row['target_amount_cent']), 'progress_percent' => min(100, (int) floor($saved * 100 / $target)), 'cover_image' => (string) ($row['cover_image'] ?? ''), 'status' => (string) $row['status'], 'completed_at' => !empty($row['completed_at']) ? Support::formatRfc3339($row['completed_at']) : null, 'created_at' => Support::formatRfc3339($row['created_at'] ?? null), ]; } private function findGoal(int $uid, int $id): array { $row = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('id', $id)->where('uid', $uid)->whereNull('deleted_at')->find(); if (!$row) { throw new \RuntimeException('目标不存在', 404); } return $row; } private function loadOrInitProfile(int $uid): array { $db = Db::connect('mysql'); $row = $db->name('fa_quit_checkin_profile')->where('uid', $uid)->whereNull('deleted_at')->find(); if ($row) { return $row; } $now = Support::now()->format(Support::DATETIME_LAYOUT); $payload = [ 'uid' => $uid, 'quit_start_date' => Support::dateOnly()->format(Support::DATE_LAYOUT), 'pack_price_cent' => 0, 'baseline_cigs_per_day' => 0, 'motivation' => '', 'notify_time' => '21:00', 'reset_rule' => 'any_relapse_reset', 'hp_current' => 100, 'created_at' => $now, 'updated_at' => $now, ]; $payload['id'] = $db->name('fa_quit_checkin_profile')->insertGetId($payload); return $payload; } private function formatProfile(array $row, array $user): array { return [ 'user_id' => (int) $user['id'], 'nickname' => (string) ($user['nickname'] ?? ''), 'avatar_url' => (string) ($user['avatar_url'] ?? ''), 'quit_start_date' => (string) ($row['quit_start_date'] ?? ''), 'pack_price_cent' => (int) ($row['pack_price_cent'] ?? 0), 'baseline_cigs_per_day' => (int) ($row['baseline_cigs_per_day'] ?? 0), 'motivation' => (string) ($row['motivation'] ?? ''), 'notify_time' => (string) ($row['notify_time'] ?? '21:00'), 'reset_rule' => (string) ($row['reset_rule'] ?? 'any_relapse_reset'), 'created_at' => Support::formatRfc3339($row['created_at'] ?? null), 'updated_at' => Support::formatRfc3339($row['updated_at'] ?? null), ]; } private function loadOrInitReminderSetting(int $ownerUID): array { $db = Db::connect('mysql'); $row = $db->name('fa_quit_checkin_supervisor_reminder_setting')->where('owner_uid', $ownerUID)->whereNull('deleted_at')->find(); if ($row) { return $row; } $now = Support::now()->format(Support::DATETIME_LAYOUT); $payload = ['owner_uid' => $ownerUID, 'enabled' => 0, 'notify_time' => '21:00', 'max_per_day' => 1, 'channel_hint' => 'stub', 'created_at' => $now, 'updated_at' => $now]; $payload['id'] = $db->name('fa_quit_checkin_supervisor_reminder_setting')->insertGetId($payload); return $payload; } private function userSummary(User $user): array { return ['user_id' => (int) $user->id, 'nickname' => (string) $user->nick_name, 'avatar_url' => (string) $user->avatar_url]; } private function assertHHMM(string $value): void { if (!preg_match('/^\d{2}:\d{2}$/', $value)) { throw new \RuntimeException('时间格式错误,应为 HH:MM', 400); } [$hour, $minute] = array_map('intval', explode(':', $value)); if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) { throw new \RuntimeException('时间格式错误,应为 HH:MM', 400); } } private function isAfterNotifyTime(string $notifyTime): bool { $notifyTime = $notifyTime !== '' ? $notifyTime : '21:00'; $this->assertHHMM($notifyTime); return Support::now()->format('H:i') >= $notifyTime; } }