后端(新增 7 控制器 + 2 服务 + 17 张表) - Prepare:排卵预测、BBT(双向体温识别)、试纸识别(规则引擎+额度)、备孕任务、同房指导 - Family:邀请码绑定(≤5)、话术库、爱心积分、私密留言、家庭健康视图 - Health:按周期阶段输出饮食/运动/睡眠/痛经四类建议 - Relation:关系测评、引导卡、冲突工具、课程下单、双人任务(联动记忆)、纪念日 - Parenting:宝宝档案、成长记录、月龄活动匹配、共育任务、育儿情绪、疫苗儿保排期 - Consult:咨询师(资质/定价/时段)、预约下单/取消、量表测评(4 套)、危机干预转介 - Setting:自定义提醒 CRUD、数据备份快照与恢复(保留 5 份) - Mood 增补急救包;Home 提醒合并自定义提醒 - 服务层:ContentService(统一内容/锁态/购买态)、AssessmentService(计分分级+危机识别) 前端(新增 8 页面) - 备孕、家人共护、健康建议、两性关系、亲子教育、心理咨询、测评执行页、情绪急救包 - 首页快捷入口扩至 8 个模块;我的页新增提醒中心与数据备份 - utils/api.js 扩至 84 个接口 数据(可重复执行脚本) - 03_modules.sql:fa_reminder/bbt/lh_test/task/family_bind/care_message/point_log/memory/ assessment(+record)/course_order/baby/growth_record/vaccine/counselor/consult_order/user_backup - 04_module_seed.sql:话术6/引导卡6/冲突3/急救4/同房3/课程6/活动6/育儿情绪4/知识7、 4 套量表、17 项疫苗儿保、3 位咨询师 验证(全部通过) - 接口全链路 82 项断言(M1–M9,含高风险测评危机干预、备份恢复) - 契约校验 84 接口 0 偏差;Vue SFC 19/19、SCSS 18/18 编译通过 - 修复 3 个真实缺陷:action 内调用 action 导致响应被劫持、coTasks 返回数组、 咨询时段跨年错误
436 行
15 KiB
PHP
436 行
15 KiB
PHP
<?php
|
||
|
||
namespace app\api\controller;
|
||
|
||
use app\common\controller\Api;
|
||
use app\common\service\ContentService;
|
||
use app\common\service\CycleService;
|
||
use app\common\service\MemberService;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 科学备孕接口(M3)
|
||
*
|
||
* 排卵预测 / 基础体温 / 试纸识别 / 备孕任务 / 知识库 / 同房指导
|
||
* 说明:试纸识别当前为「规则识别」,接口已按 CNN 服务形态预留 algo 字段,
|
||
* 接入推理服务后只需替换 recognize() 内部实现,前端无需改动。
|
||
*/
|
||
class Prepare extends Api
|
||
{
|
||
protected $noNeedLogin = [];
|
||
protected $noNeedRight = '*';
|
||
|
||
/** 每月免费识别额度 */
|
||
const FREE_LH_QUOTA = 5;
|
||
|
||
/**
|
||
* 备孕总览(一次请求返回预测 + 体温 + 试纸趋势 + 任务进度)
|
||
*/
|
||
public function index()
|
||
{
|
||
$userId = $this->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 . ' 次',
|
||
];
|
||
}
|
||
}
|