Files
mini_tp/app/smt/service/QuitCheckinService.php
T
2026-04-26 09:24:08 +08:00

549 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace app\smt\service;
use app\smt\model\User;
use DateInterval;
use think\facade\Db;
class QuitCheckinService
{
public function getProfile(array $user): array
{
$profile = $this->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;
}
}