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

339 行
11 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 fast\Random;
use think\Db;
/**
* 家人共护接口(M4)
*
* 绑定(≤5 人)/ 关怀提醒 / 话术库 / 爱心积分 / 私密沟通空间 / 家庭健康视图
*/
class Family extends Api
{
protected $noNeedLogin = [];
protected $noNeedRight = '*';
/** 最多绑定人数 */
const MAX_BIND = 5;
/**
* 家庭总览
*/
public function index()
{
$userId = $this->auth->id;
$binds = Db::name('family_bind')
->where('user_id', $userId)
->where('status', 'normal')
->order('id', 'desc')
->select();
$this->success('', [
'binds' => array_map([$this, 'formatBind'], $binds),
'max' => self::MAX_BIND,
'can_invite' => count($binds) < self::MAX_BIND,
'points' => $this->pointsData($userId),
'scripts' => ContentService::lists($userId, ['type' => 'care', 'limit' => 3]),
'care_today' => $this->careToday($userId),
'member' => MemberService::status($userId),
'script_locked' => !MemberService::can($userId, 'family.script.full'),
]);
}
/**
* 生成邀请(二维码/链接)
* POST relation=partner|parent|friend, member_name
*/
public function invite()
{
$userId = $this->auth->id;
$count = Db::name('family_bind')
->where('user_id', $userId)
->whereIn('status', ['pending', 'normal'])
->count();
if ($count >= self::MAX_BIND) {
$this->error('最多绑定 ' . self::MAX_BIND . ' 位家人');
}
$relation = $this->request->post('relation', 'partner');
if (!in_array($relation, ['partner', 'parent', 'friend'], true)) {
$relation = 'partner';
}
$code = strtoupper(Random::alnum(8));
$id = Db::name('family_bind')->insertGetId([
'user_id' => $userId,
'member_name' => mb_substr((string)$this->request->post('member_name', ''), 0, 30),
'relation' => $relation,
'scope' => 'remind,care',
'invite_code' => $code,
'status' => 'pending',
'createtime' => time(),
'updatetime' => time(),
]);
$this->success('邀请已生成', [
'id' => (int)$id,
'invite_code' => $code,
'invite_text' => '邀请你成为我花期里的守护者,绑定码:' . $code,
'expire_days' => 7,
]);
}
/**
* 确认绑定(家人端输入邀请码)
* POST invite_code
*/
public function confirm()
{
$userId = $this->auth->id;
$code = strtoupper(trim((string)$this->request->post('invite_code', '')));
if (!$code) {
$this->error('请输入绑定码');
}
$bind = Db::name('family_bind')->where('invite_code', $code)->find();
if (!$bind) {
$this->error('绑定码不存在或已失效');
}
if ((int)$bind['user_id'] === (int)$userId) {
$this->error('不能与自己绑定');
}
if ($bind['status'] === 'normal') {
$this->error('该邀请已被使用');
}
if ($bind['createtime'] < time() - 7 * 86400) {
$this->error('绑定码已过期,请让对方重新生成');
}
Db::name('family_bind')->where('id', $bind['id'])->update([
'member_user_id' => $userId,
'status' => 'normal',
'confirm_time' => time(),
'updatetime' => time(),
]);
$this->success('绑定成功', ['bind_id' => (int)$bind['id']]);
}
/**
* 解绑
*/
public function remove()
{
$id = (int)$this->request->post('id', 0);
$row = Db::name('family_bind')
->where('id', $id)
->where(function ($query) {
$query->where('user_id', $this->auth->id)->whereOr('member_user_id', $this->auth->id);
})
->find();
if (!$row) {
$this->error('绑定不存在');
}
Db::name('family_bind')->where('id', $id)->update(['status' => 'removed', 'updatetime' => time()]);
$this->success('已解绑,对方将无法再看到你的提醒');
}
/**
* 话术库(暖心话术,完整库为会员权益)
*/
public function scripts()
{
$isMember = MemberService::can($this->auth->id, 'family.script.full');
$data = ContentService::lists($this->auth->id, ['type' => 'care', 'limit' => $isMember ? 30 : 4]);
$data['locked'] = !$isMember;
$data['tip'] = $isMember ? '' : '开通会员可查看完整话术库';
$this->success('', $data);
}
/**
* 沟通空间:留言列表
* GET bind_id
*/
public function messages()
{
$bindId = (int)$this->request->param('bind_id', 0);
$bind = $this->checkBind($bindId);
$list = Db::name('care_message')
->where('bind_id', $bind['id'])
->order('id', 'desc')
->limit(50)
->select();
$list = array_map(function ($row) {
return [
'id' => (int)$row['id'],
'type' => $row['type'],
'content' => $row['content'],
'mine' => (int)$row['user_id'] === (int)$this->auth->id,
'time_text' => date('m-d H:i', (int)$row['createtime']),
];
}, $list);
$this->success('', ['bind' => $this->formatBind($bind), 'list' => array_reverse($list)]);
}
/**
* 沟通空间:发送留言
*/
public function messageSend()
{
$bindId = (int)$this->request->post('bind_id', 0);
$bind = $this->checkBind($bindId);
$content = mb_substr(trim((string)$this->request->post('content', '')), 0, 500);
if (!$content) {
$this->error('留言不能为空');
}
$id = Db::name('care_message')->insertGetId([
'bind_id' => $bind['id'],
'user_id' => $this->auth->id,
'type' => 'text',
'content' => $content,
'createtime' => time(),
]);
$this->success('已发送', ['id' => (int)$id]);
}
/**
* 记录一次关怀行动(每日一次,+2 积分)
*/
public function careAction()
{
$userId = $this->auth->id;
if ($this->careToday($userId)) {
$this->success('今天已经记录过关怀啦', ['points' => $this->pointsData($userId)]);
}
Db::name('point_log')->insert([
'user_id' => $userId,
'action' => 'care_daily',
'point' => 2,
'ref_id' => 0,
'remark' => '今日关怀行动',
'createtime' => time(),
]);
$this->success('+2 爱心积分', ['points' => $this->pointsData($userId)]);
}
/**
* 积分明细
*/
public function points()
{
$userId = $this->auth->id;
$log = Db::name('point_log')
->where('user_id', $userId)
->order('id', 'desc')
->limit(30)
->select();
$list = array_map(function ($row) {
return [
'id' => (int)$row['id'],
'action' => $row['action'],
'point' => (int)$row['point'],
'remark' => $row['remark'],
'date' => date('Y-m-d H:i', (int)$row['createtime']),
];
}, $log);
$this->success('', ['points' => $this->pointsData($userId), 'list' => $list]);
}
/**
* 家庭健康视图:谁在看护、当前阶段、共享待办
*/
public function healthView()
{
$userId = $this->auth->id;
$cycle = CycleService::current($userId);
$binds = Db::name('family_bind')
->where(function ($query) use ($userId) {
$query->where('user_id', $userId)->whereOr('member_user_id', $userId);
})
->where('status', 'normal')
->select();
$tasks = Db::name('task')
->where('user_id', $userId)
->where('scope', 'family')
->whereNull('deletetime')
->where('done', 0)
->select();
$this->success('', [
'cycle' => [
'has_data' => $cycle['has_data'],
'phase_text' => $cycle['phase_text'],
'day' => $cycle['day'],
'next_start' => $cycle['next_start'],
'days_to_next' => $cycle['days_to_next'],
],
'caregivers' => array_map([$this, 'formatBind'], $binds),
'tasks' => array_map(function ($row) {
return ['id' => (int)$row['id'], 'title' => $row['title'], 'due_date' => $row['due_date']];
}, $tasks),
'privacy_note' => '家人仅可见阶段与提醒,看不到你的原始症状与情绪日记',
]);
}
// ---------------- 内部方法 ----------------
protected function checkBind($bindId)
{
$userId = $this->auth->id;
$bind = Db::name('family_bind')
->where('id', $bindId)
->where('status', 'normal')
->where(function ($query) use ($userId) {
$query->where('user_id', $userId)->whereOr('member_user_id', $userId);
})
->find();
if (!$bind) {
$this->error('绑定关系不存在或已解除');
}
return $bind;
}
protected function formatBind($row)
{
$relationText = ['partner' => '伴侣', 'parent' => '父母', 'friend' => '朋友'];
return [
'id' => (int)$row['id'],
'name' => $row['member_name'] ?: '待确认',
'relation' => $row['relation'],
'relation_text' => isset($relationText[$row['relation']]) ? $relationText[$row['relation']] : '家人',
'scope' => $row['scope'],
'status' => $row['status'],
'invite_code' => $row['status'] === 'pending' ? $row['invite_code'] : '',
'confirmed' => $row['status'] === 'normal',
];
}
protected function pointsData($userId)
{
$total = (int)Db::name('point_log')->where('user_id', $userId)->sum('point');
$todayStart = strtotime(date('Y-m-d'));
$today = (int)Db::name('point_log')->where('user_id', $userId)->where('createtime', '>=', $todayStart)->sum('point');
return [
'total' => $total,
'today' => $today,
'can_earn' => $this->careToday($userId) ? 0 : 2,
'rules' => ['每日关怀行动 +2', '完成备孕任务 +2', '连续陪伴 7 天 +10'],
];
}
protected function careToday($userId)
{
return Db::name('point_log')
->where('user_id', $userId)
->where('action', 'care_daily')
->where('createtime', '>=', strtotime(date('Y-m-d')))
->count() > 0;
}
}