文件
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

338 行
12 KiB
PHP

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

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

<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
/**
* 隐私与提醒设置(M6)
*/
class Setting extends Api
{
protected $noNeedLogin = [];
protected $noNeedRight = '*';
protected $defaultRemind = [
'period' => true, // 经期前提醒
'ovulation' => true, // 排卵日提醒
'record' => true, // 每日记录提醒
'care' => true, // 关怀提醒(家人共护)
'time' => '21:00',
];
/**
* 读取设置
*/
public function index()
{
$setting = Db::name('user_setting')->where('user_id', $this->auth->id)->find();
if (!$setting) {
$setting = [
'app_lock' => 'none',
'local_only' => 0,
'remind_json' => json_encode($this->defaultRemind),
];
}
$remind = json_decode($setting['remind_json'], true) ?: $this->defaultRemind;
$remind = array_merge($this->defaultRemind, $remind);
$this->success('', [
'app_lock' => $setting['app_lock'],
'has_lock_code' => !empty($setting['app_lock_value']),
'local_only' => (int)$setting['local_only'],
'remind' => $remind,
]);
}
/**
* 保存设置
* POST app_lock=none|password|biometric, app_lock_code=1234, local_only=0|1, remind={"period":true,...}
*/
public function save()
{
$userId = $this->auth->id;
$exist = Db::name('user_setting')->where('user_id', $userId)->find();
$data = ['updatetime' => time()];
$appLock = $this->request->post('app_lock', null);
if ($appLock !== null) {
if (!in_array($appLock, ['none', 'password', 'biometric'])) {
$this->error('应用锁类型不正确');
}
$data['app_lock'] = $appLock;
}
$code = (string)$this->request->post('app_lock_code', '');
if ($code !== '') {
if (!preg_match('/^\d{4,6}$/', $code)) {
$this->error('应用锁密码需为 4-6 位数字');
}
// 仅存哈希,不落明文
$data['app_lock_value'] = md5($code . 'huāqī');
}
$localOnly = $this->request->post('local_only', null);
if ($localOnly !== null) {
$data['local_only'] = (int)!!$localOnly;
}
$remind = $this->readRemind();
if ($remind !== null) {
$merged = array_merge($this->defaultRemind, array_intersect_key($remind, $this->defaultRemind));
if (isset($merged['time']) && !preg_match('/^\d{2}:\d{2}$/', $merged['time'])) {
$merged['time'] = '21:00';
}
$data['remind_json'] = json_encode($merged, JSON_UNESCAPED_UNICODE);
}
if ($exist) {
Db::name('user_setting')->where('id', $exist['id'])->update($data);
} else {
$data['user_id'] = $userId;
$data['createtime'] = time();
Db::name('user_setting')->insert($data);
}
$this->success('已保存');
}
/**
* 读取提醒配置:支持 remind_xxx 独立字段(推荐,兼容 request filter)与 remind JSON
* @return array|null
*/
protected function readRemind()
{
$keys = ['period', 'ovulation', 'record', 'care', 'time'];
$result = [];
$hasField = false;
foreach ($keys as $key) {
$value = $this->request->post('remind_' . $key, null);
if ($value !== null) {
$hasField = true;
$result[$key] = $key === 'time' ? (string)$value : (bool)(int)$value;
}
}
if ($hasField) {
return $result;
}
// 兼容 JSON 传参(注意:全局 filter 会把引号转义,需还原)
$json = $this->request->post('remind', null);
if ($json === null || $json === '') {
return null;
}
$arr = is_array($json) ? $json : json_decode(htmlspecialchars_decode((string)$json, ENT_QUOTES), true);
return is_array($arr) ? $arr : null;
}
/**
* 自定义提醒列表(M1/M6 智能提醒)
*/
public function reminderList()
{
$rows = \think\Db::name('reminder')
->where('user_id', $this->auth->id)
->whereNull('deletetime')
->order('enabled desc, remind_time asc')
->select();
$this->success('', ['list' => array_map([$this, 'formatReminder'], $rows)]);
}
/**
* 新增 / 修改提醒
* POST id, title, type, remind_time, repeat_rule, remind_date, enabled
*/
public function reminderSave()
{
$userId = $this->auth->id;
$id = (int)$this->request->post('id', 0);
$title = mb_substr(trim((string)$this->request->post('title', '')), 0, 60);
if (!$title) {
$this->error('请填写提醒内容');
}
$time = (string)$this->request->post('remind_time', '21:00');
if (!preg_match('/^\d{2}:\d{2}$/', $time)) {
$this->error('提醒时间格式不正确');
}
$repeat = $this->request->post('repeat_rule', 'daily');
if (!in_array($repeat, ['daily', 'once', 'weekly'], true)) {
$repeat = 'daily';
}
$date = (string)$this->request->post('remind_date', '');
$data = [
'user_id' => $userId,
'title' => $title,
'type' => $this->request->post('type', 'custom'),
'remind_time' => $time,
'repeat_rule' => $repeat,
'remind_date' => $date && strtotime($date) ? $date : null,
'enabled' => (int)$this->request->post('enabled', 1),
'updatetime' => time(),
];
if ($id) {
$row = \think\Db::name('reminder')->where('id', $id)->where('user_id', $userId)->find();
if (!$row) {
$this->error('提醒不存在');
}
\think\Db::name('reminder')->where('id', $id)->update($data);
} else {
$data['createtime'] = time();
$id = \think\Db::name('reminder')->insertGetId($data);
}
$this->success('已保存', ['id' => (int)$id]);
}
/**
* 删除提醒
*/
public function reminderRemove()
{
$id = (int)$this->request->post('id', 0);
$row = \think\Db::name('reminder')->where('id', $id)->where('user_id', $this->auth->id)->find();
if (!$row) {
$this->error('提醒不存在');
}
\think\Db::name('reminder')->where('id', $id)->update(['deletetime' => time()]);
$this->success('已删除');
}
/**
* 数据备份:创建快照(本地/云端同步的前提能力)
*/
public function backupCreate()
{
$userId = $this->auth->id;
$payload = $this->collectData($userId);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
$id = \think\Db::name('user_backup')->insertGetId([
'user_id' => $userId,
'name' => '备份 ' . date('Y-m-d H:i'),
'size' => strlen($json),
'payload' => $json,
'createtime' => time(),
]);
// 仅保留最近 5 份,避免存储膨胀
$ids = \think\Db::name('user_backup')
->where('user_id', $userId)
->order('id', 'desc')
->limit(100)
->column('id');
if (count($ids) > 5) {
\think\Db::name('user_backup')->whereIn('id', array_slice($ids, 5))->delete();
}
$this->success('备份完成', [
'id' => (int)$id,
'size' => strlen($json),
'counts' => $payload['counts'],
]);
}
/**
* 备份列表
*/
public function backupList()
{
$rows = \think\Db::name('user_backup')
->where('user_id', $this->auth->id)
->order('id', 'desc')
->field('id,name,size,createtime')
->select();
$list = array_map(function ($row) {
return [
'id' => (int)$row['id'],
'name' => $row['name'],
'size' => (int)$row['size'],
'size_text' => round($row['size'] / 1024, 1) . ' KB',
'date' => date('Y-m-d H:i', (int)$row['createtime']),
];
}, $rows);
$this->success('', ['list' => $list]);
}
/**
* 从备份恢复(仅恢复本人数据,先清理同范围记录)
*/
public function backupRestore()
{
$userId = $this->auth->id;
$id = (int)$this->request->post('id', 0);
$row = \think\Db::name('user_backup')->where('id', $id)->where('user_id', $userId)->find();
if (!$row) {
$this->error('备份不存在');
}
$payload = json_decode($row['payload'], true);
if (!is_array($payload)) {
$this->error('备份内容损坏');
}
$restored = [];
$map = [
'cyle' => 'cycle',
];
$this->restoreTable('cycle', $payload['cycle'] ?? [], $userId, $restored);
$this->restoreTable('cycle_log', $payload['cycle_log'] ?? [], $userId, $restored);
$this->restoreTable('mood_log', $payload['mood_log'] ?? [], $userId, $restored);
$this->restoreTable('bbt_record', $payload['bbt_record'] ?? [], $userId, $restored);
$this->restoreTable('task', $payload['task'] ?? [], $userId, $restored);
$this->restoreTable('memory', $payload['memory'] ?? [], $userId, $restored);
$this->success('恢复完成', ['restored' => $restored]);
}
protected function collectData($userId)
{
$data = [
'cycle' => \think\Db::name('cycle')->where('user_id', $userId)->whereNull('deletetime')->select(),
'cycle_log' => \think\Db::name('cycle_log')->where('user_id', $userId)->whereNull('deletetime')->select(),
'mood_log' => \think\Db::name('mood_log')->where('user_id', $userId)->whereNull('deletetime')->select(),
'bbt_record' => \think\Db::name('bbt_record')->where('user_id', $userId)->select(),
'task' => \think\Db::name('task')->where('user_id', $userId)->whereNull('deletetime')->select(),
'memory' => \think\Db::name('memory')->where('user_id', $userId)->whereNull('deletetime')->select(),
'reminder' => \think\Db::name('reminder')->where('user_id', $userId)->whereNull('deletetime')->select(),
'setting' => \think\Db::name('user_setting')->where('user_id', $userId)->find(),
];
$data['counts'] = [
'cycle' => count($data['cycle']),
'cycle_log' => count($data['cycle_log']),
'mood_log' => count($data['mood_log']),
'bbt' => count($data['bbt_record']),
'task' => count($data['task']),
'memory' => count($data['memory']),
];
$data['version'] = 'v1';
$data['created'] = date('Y-m-d H:i:s');
return $data;
}
protected function restoreTable($table, $rows, $userId, &$restored)
{
if (!is_array($rows)) {
return;
}
\think\Db::name($table)->where('user_id', $userId)->delete();
foreach ($rows as $row) {
unset($row['id']);
$row['user_id'] = $userId;
\think\Db::name($table)->insert($row);
}
$restored[$table] = count($rows);
}
protected function formatReminder($row)
{
return [
'id' => (int)$row['id'],
'title' => $row['title'],
'type' => $row['type'],
'remind_time' => $row['remind_time'],
'repeat_rule' => $row['repeat_rule'],
'remind_date' => $row['remind_date'],
'enabled' => (int)$row['enabled'],
];
}
}