文件
huaqiapp/server/application/api/controller/Prepare.php
linda d243ece39c feat: 补齐 M1–M9 全部 47 个功能点并打通前后端
后端(新增 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 返回数组、
  咨询时段跨年错误
2026-11-05 23:27:21 +08:00

436 行
15 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?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 . ' 次',
];
}
}