auth->id; $cycle = CycleService::current($userId); $member = MemberService::status($userId); $bbt = $this->bbtListData($userId, 14); $lh = Db::name('lh_test') ->where('user_id', $userId) ->order('log_date', 'desc') ->limit(10) ->select(); $tasks = Db::name('task') ->where('user_id', $userId) ->where('scope', 'prepare') ->whereNull('deletetime') ->order('done asc, id desc') ->limit(10) ->select(); $ovulation = $cycle['ovulation'] ?: null; $daysToOvulation = null; if ($ovulation) { $daysToOvulation = (int)round((strtotime($ovulation) - strtotime(date('Y-m-d'))) / 86400); } $this->success('', [ 'cycle' => $cycle, 'ovulation' => $ovulation, 'fertile' => $cycle['has_data'] ? [$cycle['fertile_start'], $cycle['fertile_end']] : [], 'days_to_ovulation' => $daysToOvulation, 'predict' => CycleService::predict($userId, 3), 'bbt' => $bbt, 'lh' => array_map([$this, 'formatLh'], $lh), 'lh_quota' => $this->quotaInfo($userId), 'tasks' => array_map([$this, 'formatTask'], $tasks), 'member' => $member, 'plan_locked' => !MemberService::can($userId, 'prepare.plan'), 'intercourse_locked' => !MemberService::can($userId, 'prepare.intercourse'), ]); } /** * 基础体温:保存 * POST log_date temperature measure_time */ public function bbtSave() { $userId = $this->auth->id; $logDate = $this->request->post('log_date', date('Y-m-d')); $temperature = (float)$this->request->post('temperature', 0); if (!strtotime($logDate)) { $this->error('日期格式不正确'); } if ($temperature < 34 || $temperature > 42) { $this->error('体温需在 34.0 - 42.0 ℃ 之间'); } $data = [ 'user_id' => $userId, 'log_date' => $logDate, 'temperature' => round($temperature, 2), 'measure_time' => (string)$this->request->post('measure_time', ''), 'device' => (string)$this->request->post('device', 'manual'), 'createtime' => time(), ]; $exist = Db::name('bbt_record')->where('user_id', $userId)->where('log_date', $logDate)->find(); if ($exist) { Db::name('bbt_record')->where('id', $exist['id'])->update($data); } else { Db::name('bbt_record')->insert($data); } $this->success('已记录', ['bbt' => $this->bbtListData($userId, 14)]); } /** * 基础体温曲线 * GET days=14 */ public function bbtList() { $days = (int)$this->request->param('days', 14); $days = in_array($days, [7, 14, 30]) ? $days : 14; $this->success('', $this->bbtListData($this->auth->id, $days)); } /** * 试纸识别 * POST test_type=lh|hcg, log_date, image, level=0-4 * level:肉眼等级(0 极浅 / 1 弱阳 / 2 阳性 / 3 强阳 / 4 未测),用于规则识别 */ public function lhtest() { $userId = $this->auth->id; $testType = $this->request->post('test_type', 'lh') === 'hcg' ? 'hcg' : 'lh'; $logDate = $this->request->post('log_date', date('Y-m-d')); $image = (string)$this->request->post('image', ''); $level = (int)$this->request->post('level', 2); if (!strtotime($logDate)) { $this->error('日期格式不正确'); } $quota = $this->quotaInfo($userId); if (!$quota['can_use']) { $this->error('本月免费识别次数已用完,开通会员可继续使用'); } $recognized = $this->recognize($level, $testType); $isFreeQuota = $quota['is_free'] ? 1 : 0; $id = Db::name('lh_test')->insertGetId([ 'user_id' => $userId, 'test_type' => $testType, 'log_date' => $logDate, 'image' => mb_substr($image, 0, 255), 'result' => $recognized['result'], 'confidence' => $recognized['confidence'], 'algo' => $recognized['algo'], 'is_free_quota' => $isFreeQuota, 'createtime' => time(), ]); // 强弱转强的趋势判断 $recent = Db::name('lh_test') ->where('user_id', $userId) ->where('test_type', $testType) ->order('id', 'desc') ->limit(7) ->select(); $trend = []; foreach (array_reverse($recent) as $row) { $trend[] = [ 'date' => $row['log_date'], 'result' => $row['result'], 'label' => $this->resultLabel($row['result']), ]; } $this->success('识别完成', [ 'id' => (int)$id, 'result' => $recognized['result'], 'result_text' => $this->resultLabel($recognized['result']), 'confidence' => $recognized['confidence'], 'algo' => $recognized['algo'], 'advice' => $recognized['advice'], 'trend' => $trend, 'quota' => $this->quotaInfo($userId), 'disclaimer' => '识别结果仅供参考,请以说明书与医生判断为准。', ]); } /** * 试纸记录列表 */ public function lhtestList() { $list = Db::name('lh_test') ->where('user_id', $this->auth->id) ->order('log_date', 'desc') ->limit(30) ->select(); $this->success('', [ 'list' => array_map([$this, 'formatLh'], $list), 'quota' => $this->quotaInfo($this->auth->id), ]); } /** * 备孕任务:列表 */ public function taskList() { $this->success('', ['list' => $this->tasks('prepare')]); } /** * 备孕任务:新增 / 修改 * POST id, title, category, due_date, share_partner */ public function taskSave() { $userId = $this->auth->id; $id = (int)$this->request->post('id', 0); $title = mb_substr(trim((string)$this->request->post('title', '')), 0, 80); if (!$title) { $this->error('请填写任务内容'); } $dueDate = $this->request->post('due_date', ''); $data = [ 'user_id' => $userId, 'scope' => $this->request->post('scope', 'prepare'), 'title' => $title, 'category' => $this->request->post('category', 'check'), 'due_date' => $dueDate && strtotime($dueDate) ? $dueDate : null, 'share_partner' => (int)$this->request->post('share_partner', 0), 'updatetime' => time(), ]; if ($id) { $row = Db::name('task')->where('id', $id)->where('user_id', $userId)->find(); if (!$row) { $this->error('任务不存在'); } Db::name('task')->where('id', $id)->update($data); } else { $data['createtime'] = time(); $id = Db::name('task')->insertGetId($data); // 新增备孕任务赠送积分(M4 联动) if ($data['scope'] === 'prepare') { Db::name('point_log')->insert([ 'user_id' => $userId, 'action' => 'prepare_task', 'point' => 2, 'ref_id' => $id, 'remark' => '新建备孕任务', 'createtime' => time(), ]); } } $this->success('已保存', ['id' => (int)$id, 'list' => $this->tasks($data['scope'])]); } /** * 备孕任务:完成 / 取消完成 */ public function taskDone() { $userId = $this->auth->id; $id = (int)$this->request->post('id', 0); $row = Db::name('task')->where('id', $id)->where('user_id', $userId)->find(); if (!$row) { $this->error('任务不存在'); } $done = $row['done'] ? 0 : 1; Db::name('task')->where('id', $id)->update([ 'done' => $done, 'done_time' => $done ? time() : null, 'updatetime' => time(), ]); $this->success($done ? '已完成' : '已取消', ['done' => $done, 'list' => $this->tasks($row['scope'])]); } /** * 备孕任务:删除 */ public function taskRemove() { $userId = $this->auth->id; $id = (int)$this->request->post('id', 0); $row = Db::name('task')->where('id', $id)->where('user_id', $userId)->find(); if (!$row) { $this->error('任务不存在'); } Db::name('task')->where('id', $id)->update(['deletetime' => time()]); $this->success('已删除', ['list' => $this->tasks($row['scope'])]); } /** * 同房指导(会员) */ public function guide() { if (!MemberService::can($this->auth->id, 'prepare.intercourse')) { $this->success('', ['locked' => true, 'tip' => '同房指导为会员权益,开通后可见', 'list' => []]); } $data = ContentService::lists($this->auth->id, ['type' => 'guide', 'limit' => 10]); $data['locked'] = false; $this->success('', $data); } // ---------------- 内部方法 ---------------- protected function tasks($scope) { $list = Db::name('task') ->where('user_id', $this->auth->id) ->where('scope', $scope) ->whereNull('deletetime') ->order('done asc, id desc') ->select(); return array_map([$this, 'formatTask'], $list); } protected function formatTask($row) { return [ 'id' => (int)$row['id'], 'title' => $row['title'], 'scope' => $row['scope'], 'category' => $row['category'], 'due_date' => $row['due_date'], 'done' => (int)$row['done'], 'share_partner' => (int)$row['share_partner'], ]; } protected function bbtListData($userId, $days) { $start = date('Y-m-d', strtotime('-' . ($days - 1) . ' day')); $rows = Db::name('bbt_record') ->where('user_id', $userId) ->where('log_date', '>=', $start) ->order('log_date', 'asc') ->select(); $map = []; foreach ($rows as $row) { $map[$row['log_date']] = (float)$row['temperature']; } $list = []; $values = []; for ($i = $days - 1; $i >= 0; $i--) { $date = date('Y-m-d', strtotime('-' . $i . ' day')); $value = isset($map[$date]) ? $map[$date] : null; if ($value !== null) { $values[] = $value; } $list[] = ['date' => $date, 'label' => date('n/j', strtotime($date)), 'temperature' => $value]; } $avg = $values ? round(array_sum($values) / count($values), 2) : 0; $biphasic = false; $phaseNote = ''; if (count($values) >= 6) { $min = min($values); $max = max($values); $biphasic = ($max - $min) >= 0.3; $phaseNote = $biphasic ? '检测到双向体温(温差 ' . round($max - $min, 2) . ' ℃),提示可能已排卵' : '暂未出现明显双向体温,继续在固定时间测量'; } else { $phaseNote = '至少连续记录 6 天才能判断双向体温'; } return [ 'days' => $days, 'list' => $list, 'avg' => $avg, 'biphasic' => $biphasic, 'note' => $phaseNote, 'count' => count($values), ]; } /** * 试纸识别(规则引擎版,预留 CNN 替换点) */ protected function recognize($level, $testType) { $level = min(4, max(0, (int)$level)); $map = [ 0 => ['result' => 'negative', 'confidence' => 88.5, 'advice' => '暂未出现阳性,建议明天同一时间复测。'], 1 => ['result' => 'weak', 'confidence' => 90.2, 'advice' => '出现弱阳,建议每天同一时间连续检测。'], 2 => ['result' => 'positive', 'confidence' => 93.6, 'advice' => '已出现阳性,未来 24-48 小时可能排卵。'], 3 => ['result' => 'strong', 'confidence' => 95.1, 'advice' => '强阳提示 LH 峰值,建议今天与明天安排同房。'], 4 => ['result' => 'unknown', 'confidence' => 60.0, 'advice' => '图片不够清晰,建议在自然光下重拍试纸。'], ]; $hit = $map[$level]; if ($testType === 'hcg' && in_array($level, [2, 3], true)) { $hit['advice'] = '早孕试纸出现阳性,建议到医院抽血确认。'; } $hit['algo'] = 'rule'; return $hit; } protected function resultLabel($result) { $map = ['negative' => '阴性', 'weak' => '弱阳', 'positive' => '阳性', 'strong' => '强阳', 'unknown' => '未识别']; return isset($map[$result]) ? $map[$result] : $result; } protected function formatLh($row) { return [ 'id' => (int)$row['id'], 'test_type' => $row['test_type'], 'log_date' => $row['log_date'], 'result' => $row['result'], 'result_text' => $this->resultLabel($row['result']), 'confidence' => (float)$row['confidence'], 'algo' => $row['algo'], ]; } protected function quotaInfo($userId) { $member = MemberService::can($userId, 'lhtest.quota'); $used = Db::name('lh_test') ->where('user_id', $userId) ->where('is_free_quota', 1) ->where('log_date', '>=', date('Y-m-01')) ->count(); $limit = $member ? 50 : self::FREE_LH_QUOTA; return [ 'is_member' => $member, 'used' => $used, 'limit' => $limit, 'left' => max(0, $limit - $used), 'is_free' => $used < self::FREE_LH_QUOTA, 'can_use' => $used < $limit, 'tip' => $member ? '会员每月 50 次识别额度' : '免费用户每月 ' . self::FREE_LH_QUOTA . ' 次', ]; } }