feat: 花期 V1.0 纵向切片(暖色调设计系统 + 周期/情绪/会员全链路)

前端(uni-app · Vue3)
- 设计系统:uni.scss 品牌令牌(花红 #E8604C / 蜜橙 #F5A65B / 暖米白 #FFF9F5),
  App.vue 全局原子类(卡片/按钮/胶囊/单元格/空态/进度)
- 页面:登录、首页(聚合)、周期(花瓣日历+当日记录+预测+概览)、情绪(记录+趋势+日记+疗愈)、
  我的(资料/会员/隐私/提醒)、健康报告、会员套餐、内容详情
- 组件:hq-tabbar(自定义底栏)、bloom-calendar(阶段日历)
- 基础层:utils/{config,request,store,api,date}.js(统一 token、401 处理、接口集中定义)

后端(FastAdmin / ThinkPHP 5.0)
- 新增 api 控制器:Home / Cycle / Mood / Content / Member / Setting,User 增补 detail、updateprofile
- 新增服务层:CycleService(四阶段计算、预测、月历、汇总)、MemberService(权益判定)
- 新增表:fa_cycle / fa_cycle_log / fa_symptom_tag / fa_mood_log / fa_content /
  fa_content_category / fa_member_plan / fa_member_order / fa_user_setting
- 初始化数据:20 个症状标签、28 条内容(心语/科普/知识/食谱/运动/睡眠/痛经/冥想)、3 档会员

验证(全部通过)
- 接口全链路 48 项断言(登录→周期→首页→情绪→内容→会员→设置→资料)
- 前后端契约静态校验 24 个接口 0 偏差
- Vue SFC 编译 11/11、SCSS 编译 10/10
- 验证脚本见 scripts/verify/,报告见 docs/04-前端设计系统与联调验证.md
这个提交包含在:
linda
2026-11-05 22:54:02 +08:00
父节点 908b39519f
当前提交 5ea1b461f3
共有 35 个文件被更改,包括 7462 次插入 和 83 次删除

查看文件

@@ -0,0 +1,155 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\service\CycleService;
use app\common\service\MemberService;
use think\Db;
/**
* 内容接口(M2 心语/科普 + M5 健康建议 + 冥想音频)
*/
class Content extends Api
{
protected $noNeedLogin = [];
protected $noNeedRight = '*';
/**
* 内容列表
* GET type=jijing|mood|knowledge|advice|meditation phase=menstrual access=free
*/
public function index()
{
$type = $this->request->param('type', '');
$phase = $this->request->param('phase', '');
$categoryId = (int)$this->request->param('category_id', 0);
$page = max(1, (int)$this->request->param('page', 1));
$limit = min(50, max(1, (int)$this->request->param('limit', 10)));
// 未显式传阶段时,按当前周期阶段智能匹配
if (!$phase) {
$current = CycleService::current($this->auth->id);
$phase = $current['phase'] ?: '';
}
$query = Db::name('content')
->where('status', 'normal')
->where('verify', 'passed');
if ($type) {
$query->where('type', $type);
}
if ($categoryId) {
$query->where('category_id', $categoryId);
}
$all = $query->order('weigh', 'desc')->select();
// 阶段匹配优先:先取命中当前阶段(含 all)的内容,过少时回退全量,
// 避免某些阶段(如排卵期)内容过少导致列表空置
if ($phase) {
$matched = array_values(array_filter($all, function ($item) use ($phase) {
$phases = array_filter(explode(',', $item['phase']));
return in_array('all', $phases) || in_array($phase, $phases);
}));
if (count($matched) >= 3 || count($matched) === count($all)) {
$all = $matched;
} else {
$rest = array_values(array_filter($all, function ($item) use ($matched) {
return !in_array($item['id'], array_column($matched, 'id'));
}));
$all = array_merge($matched, $rest);
}
}
$total = count($all);
$list = array_slice($all, ($page - 1) * $limit, $limit);
$member = MemberService::status($this->auth->id);
$list = array_map(function ($item) use ($member) {
return [
'id' => (int)$item['id'],
'type' => $item['type'],
'title' => $item['title'],
'summary' => $item['summary'],
'emoji' => $item['cover_emoji'],
'media_type' => $item['media_type'],
'duration' => (int)$item['duration'],
'duration_text' => $item['duration'] ? floor($item['duration'] / 60) . ' 分钟' : '',
'access' => $item['access'],
'locked' => $item['access'] === 'member' && !$member['is_member'],
];
}, $list);
$this->success('', [
'list' => $list,
'total' => $total,
'page' => $page,
'limit' => $limit,
'has_more' => $page * $limit < $total,
'phase' => $phase,
'category' => $this->categoryList($type),
]);
}
/**
* 内容详情
* GET id=1
*/
public function detail()
{
$id = (int)$this->request->param('id', 0);
$item = Db::name('content')->where('id', $id)->where('status', 'normal')->find();
if (!$item) {
$this->error('内容不存在');
}
$member = MemberService::status($this->auth->id);
$locked = $item['access'] === 'member' && !$member['is_member'];
$this->success('', [
'id' => (int)$item['id'],
'type' => $item['type'],
'title' => $item['title'],
'summary' => $item['summary'],
'body' => $locked ? mb_substr($item['body'], 0, 80) . '…' : $item['body'],
'emoji' => $item['cover_emoji'],
'media_type' => $item['media_type'],
'duration' => (int)$item['duration'],
'access' => $item['access'],
'locked' => $locked,
'lock_tip' => $locked ? '该内容为会员专享,开通会员即可查看' : '',
'disclaimer' => $item['type'] === 'advice' || $item['type'] === 'knowledge'
? '以上内容仅供参考,不能替代医疗诊断。' : '',
]);
}
/**
* 分类
*/
public function categories()
{
$type = $this->request->param('type', '');
$query = Db::name('content_category')->where('status', 'normal');
if ($type) {
$query->where('type', $type);
}
$list = $query->order('weigh', 'desc')->select();
$this->success('', ['list' => $list]);
}
protected function categoryList($type)
{
if (!$type) {
return [];
}
$list = Db::name('content_category')
->where('type', $type)
->where('status', 'normal')
->order('weigh', 'desc')
->field('id,name')
->select();
return $list;
}
}

查看文件

@@ -0,0 +1,342 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\service\CycleService;
use app\common\service\MemberService;
use think\Db;
use think\Validate;
/**
* 周期管理接口(M1)
*/
class Cycle extends Api
{
protected $noNeedLogin = [];
protected $noNeedRight = '*';
/**
* 月历视图
* GET month=2026-11
*/
public function index()
{
$month = $this->request->param('month', date('Y-m'));
if (!Validate::regex($month, '/^\d{4}-\d{2}$/')) {
$month = date('Y-m');
}
$data = CycleService::calendar($this->auth->id, $month);
$this->success('', $data);
}
/**
* 今日周期状态
*/
public function current()
{
$this->success('', CycleService::current($this->auth->id));
}
/**
* 未来 3 个月预测
*/
public function predict()
{
$this->success('', CycleService::predict($this->auth->id, 3));
}
/**
* 记录 / 修改一次经期
* POST start_date=2026-11-01 end_date=2026-11-05
*/
public function save()
{
$userId = $this->auth->id;
$startDate = $this->request->post('start_date', '');
$endDate = $this->request->post('end_date', '');
if (!$startDate || !strtotime($startDate)) {
$this->error('请选择经期开始日期');
}
if ($endDate && strtotime($endDate) < strtotime($startDate)) {
$this->error('结束日期不能早于开始日期');
}
if (strtotime($startDate) > time() + 86400) {
$this->error('开始日期不能晚于今天');
}
$periodLength = $endDate ? (int)((strtotime($endDate) - strtotime($startDate)) / 86400) + 1 : 5;
if ($periodLength > 15) {
$this->error('经期天数超出合理范围');
}
$exist = Db::name('cycle')
->where('user_id', $userId)
->where('start_date', $startDate)
->whereNull('deletetime')
->find();
$params = CycleService::params($userId);
$data = [
'user_id' => $userId,
'start_date' => $startDate,
'end_date' => $endDate ?: null,
'period_length' => $periodLength,
'cycle_length' => $params['cycle_length'],
'ovulation_date' => date('Y-m-d', strtotime($startDate) + ($params['cycle_length'] - 14) * 86400),
'updatetime' => time(),
];
if ($exist) {
Db::name('cycle')->where('id', $exist['id'])->update($data);
$id = $exist['id'];
} else {
$data['createtime'] = time();
$id = Db::name('cycle')->insertGetId($data);
}
// 重新计算历史周期的平均长度(新周期写入后再回填)
$cycles = CycleService::cycles($userId, 6);
if (count($cycles) >= 2) {
$days = (int)((strtotime($cycles[0]['start_date']) - strtotime($cycles[1]['start_date'])) / 86400);
if ($days >= 18 && $days <= 60) {
Db::name('cycle')->where('id', $cycles[0]['id'])->update(['cycle_length' => $days]);
}
}
$this->success('记录成功', [
'id' => (int)$id,
'cycle' => CycleService::current($userId),
]);
}
/**
* 保存每日症状记录(同日覆盖)
* POST log_date, flow, pain_level, symptom_ids, mood, sleep_hours, sex, note
*/
public function log()
{
$userId = $this->auth->id;
$logDate = $this->request->post('log_date', date('Y-m-d'));
if (!strtotime($logDate)) {
$this->error('日期格式不正确');
}
if (strtotime($logDate) > strtotime('+1 day')) {
$this->error('不能记录未来日期');
}
$data = [
'user_id' => $userId,
'log_date' => $logDate,
'flow' => min(3, max(0, (int)$this->request->post('flow', 0))),
'pain_level' => min(5, max(0, (int)$this->request->post('pain_level', 0))),
'symptom_ids' => (string)$this->request->post('symptom_ids', ''),
'mood' => min(2, max(-2, (int)$this->request->post('mood', 0))),
'sex' => min(2, max(0, (int)$this->request->post('sex', 0))),
'note' => mb_substr((string)$this->request->post('note', ''), 0, 500),
'updatetime' => time(),
];
$sleep = $this->request->post('sleep_hours', '');
if ($sleep !== '' && is_numeric($sleep)) {
$data['sleep_hours'] = min(24, max(0, round((float)$sleep, 1)));
}
$exist = Db::name('cycle_log')
->where('user_id', $userId)
->where('log_date', $logDate)
->whereNull('deletetime')
->find();
if ($exist) {
Db::name('cycle_log')->where('id', $exist['id'])->update($data);
$id = $exist['id'];
} else {
$data['createtime'] = time();
$id = Db::name('cycle_log')->insertGetId($data);
}
$this->success('已保存', ['id' => (int)$id]);
}
/**
* 某天的记录详情
* GET date=2026-11-02
*/
public function loginfo()
{
$date = $this->request->param('date', date('Y-m-d'));
$log = Db::name('cycle_log')
->where('user_id', $this->auth->id)
->where('log_date', $date)
->whereNull('deletetime')
->find();
if ($log) {
$log['symptom_ids'] = $log['symptom_ids'] === '' ? [] : array_map('intval', explode(',', $log['symptom_ids']));
}
$this->success('', [
'date' => $date,
'log' => $log ?: null,
'tags' => $this->tagsData(),
]);
}
/**
* 症状标签(记录面板用)
*/
public function tags()
{
$this->success('', ['list' => $this->tagsData()]);
}
protected function tagsData()
{
$list = Db::name('symptom_tag')
->where('status', 'normal')
->where(function ($query) {
$query->where('is_preset', 1)->whereOr('user_id', $this->auth->id);
})
->order('weigh', 'desc')
->select();
return array_map(function ($item) {
return [
'id' => (int)$item['id'],
'name' => $item['name'],
'icon' => $item['icon'],
'category' => $item['category'],
];
}, $list);
}
/**
* 周期汇总
*/
public function summary()
{
$this->success('', CycleService::summary($this->auth->id));
}
/**
* 健康分析报告(基础免费,深度为会员权益)
* GET type=month|quarter|year month=2026-11
*/
public function report()
{
$userId = $this->auth->id;
$type = $this->request->param('type', 'month');
$month = $this->request->param('month', date('Y-m'));
$start = date('Y-m-01', strtotime($month . '-01'));
$end = date('Y-m-t', strtotime($month . '-01'));
$logs = Db::name('cycle_log')
->where('user_id', $userId)
->whereNull('deletetime')
->whereBetween('log_date', [$start, $end])
->select();
$painList = [];
$flowList = [];
$symptomCount = [];
$sleepTotal = 0;
$sleepCount = 0;
foreach ($logs as $log) {
if ($log['pain_level'] > 0) {
$painList[] = (int)$log['pain_level'];
}
if ($log['flow'] > 0) {
$flowList[] = (int)$log['flow'];
}
foreach (array_filter(explode(',', $log['symptom_ids'])) as $sid) {
$symptomCount[$sid] = isset($symptomCount[$sid]) ? $symptomCount[$sid] + 1 : 1;
}
if ($log['sleep_hours'] !== null && $log['sleep_hours'] > 0) {
$sleepTotal += (float)$log['sleep_hours'];
$sleepCount++;
}
}
arsort($symptomCount);
$topSymptomIds = array_slice(array_keys($symptomCount), 0, 5);
$tagNames = Db::name('symptom_tag')->whereIn('id', $topSymptomIds ?: [0])->column('name', 'id');
$topSymptoms = [];
foreach ($topSymptomIds as $sid) {
if (isset($tagNames[$sid])) {
$topSymptoms[] = ['name' => $tagNames[$sid], 'count' => $symptomCount[$sid]];
}
}
$moodLogs = Db::name('mood_log')
->where('user_id', $userId)
->whereNull('deletetime')
->whereBetween('log_date', [$start, $end])
->select();
$moodCount = [];
foreach ($moodLogs as $m) {
$moodCount[$m['mood']] = isset($moodCount[$m['mood']]) ? $moodCount[$m['mood']] + 1 : 1;
}
$summary = CycleService::summary($userId);
$deep = MemberService::can($userId, 'report.deep');
$this->success('', [
'type' => $type,
'month' => $month,
'range' => [$start, $end],
'summary' => $summary,
'stats' => [
'log_days' => count($logs),
'avg_pain' => $painList ? round(array_sum($painList) / count($painList), 1) : 0,
'avg_flow' => $flowList ? round(array_sum($flowList) / count($flowList), 1) : 0,
'avg_sleep' => $sleepCount ? round($sleepTotal / $sleepCount, 1) : 0,
'top_symptoms' => $topSymptoms,
'mood_count' => $moodCount,
'mood_days' => count($moodLogs),
],
'access' => [
'deep' => $deep,
'need_member' => !$deep,
'tip' => $deep ? '' : '开通会员可查看深度分析与导出报告',
],
'advice' => $this->advice($painList, $sleepCount ? $sleepTotal / $sleepCount : 0, $summary),
]);
}
protected function advice($painList, $avgSleep, $summary)
{
$list = [];
$avgPain = $painList ? array_sum($painList) / count($painList) : 0;
if ($avgPain >= 3) {
$list[] = '本期痛经程度偏高,建议热敷并记录是否影响日常活动;若持续加重请及时就医。';
}
if ($avgSleep > 0 && $avgSleep < 6.5) {
$list[] = '本期平均睡眠 ' . round($avgSleep, 1) . ' 小时,偏少。优先固定起床时间并减少睡前屏幕。';
}
if ($summary['regularity'] > 0 && $summary['regularity'] < 70) {
$list[] = '周期波动略大,建议连续记录 3 个周期后再评估;压力与作息是常见影响因素。';
}
if (!$list) {
$list[] = '本期整体状态平稳,保持规律记录会让预测更准确。';
}
$list[] = '以上内容仅供参考,不能替代医疗诊断。';
return $list;
}
/**
* 删除某次经期记录(软删除)
*/
public function remove()
{
$id = (int)$this->request->post('id', 0);
$row = Db::name('cycle')->where('id', $id)->where('user_id', $this->auth->id)->find();
if (!$row) {
$this->error('记录不存在');
}
Db::name('cycle')->where('id', $id)->update(['deletetime' => time()]);
$this->success('已删除');
}
}

查看文件

@@ -0,0 +1,217 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\service\CycleService;
use app\common\service\MemberService;
use think\Db;
/**
* 首页聚合接口
*
* 一次请求返回首页全部数据,减少端上串行请求,保证首屏流畅。
*/
class Home extends Api
{
protected $noNeedLogin = [];
protected $noNeedRight = '*';
public function index()
{
$userId = $this->auth->id;
$today = date('Y-m-d');
$hour = (int)date('H');
if ($hour < 6) {
$greeting = '夜深了';
} elseif ($hour < 11) {
$greeting = '早上好';
} elseif ($hour < 14) {
$greeting = '中午好';
} elseif ($hour < 18) {
$greeting = '下午好';
} else {
$greeting = '晚上好';
}
$cycle = CycleService::current($userId);
$loggedToday = Db::name('cycle_log')
->where('user_id', $userId)
->where('log_date', $today)
->whereNull('deletetime')
->count() > 0;
$this->success('', [
'greeting' => $greeting,
'nickname' => $this->auth->nickname ?: '亲爱的',
'today' => $today,
'date_text' => date('n月j日') . ' ' . $this->weekText($today),
'cycle' => $cycle,
'logged_today' => $loggedToday,
'phase_tip' => $this->phaseTip($cycle),
'jijing' => $this->pickJijing($cycle['phase'], $today),
'reminders' => $this->reminders($cycle, $loggedToday),
'mood' => $this->moodSummary($userId),
'member' => MemberService::status($userId),
]);
}
/**
* 按阶段/日期稳定挑选一条心语(同一天结果不变)
*/
protected function pickJijing($phase, $today)
{
$pool = Db::name('content')
->where('type', 'jijing')
->where('status', 'normal')
->where('verify', 'passed')
->order('weigh', 'desc')
->select();
if (!$pool) {
return null;
}
$matched = [];
foreach ($pool as $item) {
$phases = array_filter(explode(',', $item['phase']));
if (!$phase || in_array('all', $phases) || in_array($phase, $phases)) {
$matched[] = $item;
}
}
if (!$matched) {
$matched = $pool;
}
$index = crc32($today . ($phase ?: 'all')) % count($matched);
$item = $matched[$index];
return [
'id' => (int)$item['id'],
'title' => $item['title'],
'summary' => $item['summary'],
'body' => $item['body'],
'emoji' => $item['cover_emoji'],
'access' => $item['access'],
];
}
/**
* 阶段建议文案
*/
protected function phaseTip($cycle)
{
if (!$cycle['has_data']) {
return [
'title' => '先记录一次经期',
'desc' => '记录后即可获得阶段预测与个性化建议',
'action' => '去记录',
];
}
$map = [
'menstrual' => ['keyword' => '这几天适合放慢节奏', 'desc' => '注意保暖、补铁,减少高强度运动'],
'follicular' => ['keyword' => '能量回升期', 'desc' => '精力与情绪较好,适合安排计划与运动'],
'ovulation' => ['keyword' => '易孕期', 'desc' => '宫颈黏液变清亮,注意私处清洁与补水'],
'luteal' => ['keyword' => '黄体期', 'desc' => '情绪易波动,优先睡眠与温热饮食'],
];
$tip = $map[$cycle['phase']] ?: $map['follicular'];
return [
'title' => $tip['keyword'],
'desc' => $tip['desc'],
'action' => '',
];
}
/**
* 今日提醒(由周期与记录状态推导,非冗余推送)
*/
protected function reminders($cycle, $loggedToday)
{
$list = [];
if (!$loggedToday) {
$list[] = [
'type' => 'record',
'icon' => '✍️',
'title' => '今天还没有记录',
'desc' => '花 10 秒记录身体感受,预测会更准',
];
}
if ($cycle['has_data']) {
if ($cycle['is_period']) {
$list[] = [
'type' => 'care',
'icon' => '🌡',
'title' => '经期第 ' . $cycle['day'] . ' 天',
'desc' => '小腹保暖 + 温热饮食,避免冰饮',
];
} elseif ($cycle['is_fertile']) {
$list[] = [
'type' => 'fertile',
'icon' => '🌱',
'title' => '易孕期',
'desc' => '排卵期前后,注意休息与营养',
];
} elseif ($cycle['days_to_next'] !== null && $cycle['days_to_next'] <= 3 && $cycle['days_to_next'] >= 1) {
$list[] = [
'type' => 'period',
'icon' => '📅',
'title' => '经期还有 ' . $cycle['days_to_next'] . ' 天',
'desc' => '可以提前准备卫生用品与暖贴',
];
} elseif ($cycle['phase'] === 'luteal') {
$list[] = [
'type' => 'mood',
'icon' => '🍂',
'title' => '黄体期留意情绪',
'desc' => '敏感易累是生理性的,允许自己慢一点',
];
}
}
return $list;
}
/**
* 近 7 天情绪概览
*/
protected function moodSummary($userId)
{
$start = date('Y-m-d', strtotime('-6 day'));
$logs = Db::name('mood_log')
->where('user_id', $userId)
->whereNull('deletetime')
->where('log_date', '>=', $start)
->order('log_date', 'asc')
->select();
$scoreMap = ['happy' => 5, 'calm' => 4, 'low' => 2, 'anxious' => 2, 'angry' => 1];
$days = [];
for ($i = 6; $i >= 0; $i--) {
$d = date('Y-m-d', strtotime('-' . $i . ' day'));
$days[$d] = ['date' => $d, 'score' => null, 'mood' => ''];
}
foreach ($logs as $log) {
if (isset($days[$log['log_date']])) {
$days[$log['log_date']]['score'] = isset($scoreMap[$log['mood']]) ? $scoreMap[$log['mood']] : 3;
$days[$log['log_date']]['mood'] = $log['mood'];
}
}
$recorded = array_values(array_filter($days, function ($d) {
return $d['score'] !== null;
}));
$avg = $recorded ? round(array_sum(array_column($recorded, 'score')) / count($recorded), 1) : 0;
return [
'days' => array_values($days),
'recorded' => count($recorded),
'avg_score' => $avg,
'today_mood' => isset($days[date('Y-m-d')]) ? $days[date('Y-m-d')]['mood'] : '',
];
}
protected function weekText($date)
{
$weeks = ['日', '一', '二', '三', '四', '五', '六'];
return '星期' . $weeks[(int)date('w', strtotime($date))];
}
}

查看文件

@@ -0,0 +1,91 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\service\MemberService;
use fast\Random;
use think\Db;
/**
* 会员接口(M6 变现)
*/
class Member extends Api
{
protected $noNeedLogin = ['plans'];
protected $noNeedRight = '*';
/**
* 套餐列表(无需登录)
*/
public function plans()
{
$this->success('', ['list' => MemberService::plans()]);
}
/**
* 我的会员状态
*/
public function status()
{
$status = MemberService::status($this->auth->id);
$orders = Db::name('member_order')
->where('user_id', $this->auth->id)
->order('id', 'desc')
->limit(10)
->field('order_no,plan_code,amount,pay_status,createtime,start_time,expire_time')
->select();
foreach ($orders as &$order) {
$order['amount'] = (float)$order['amount'];
$order['create_date'] = $order['createtime'] ? date('Y-m-d H:i', (int)$order['createtime']) : '';
$order['status_text'] = ['unpaid' => '待支付', 'paid' => '已支付', 'refunded' => '已退款'][$order['pay_status']] ?? $order['pay_status'];
}
$this->success('', ['member' => $status, 'orders' => $orders]);
}
/**
* 创建会员订单
* POST plan_id=2 pay_type=wechat
*/
public function order()
{
$userId = $this->auth->id;
$planId = (int)$this->request->post('plan_id', 0);
$payType = $this->request->post('pay_type', 'wechat');
$plan = Db::name('member_plan')->where('id', $planId)->where('status', 'normal')->find();
if (!$plan) {
$this->error('套餐不存在或已下架');
}
if (!in_array($payType, ['wechat', 'alipay'])) {
$this->error('不支持的支付方式');
}
$orderNo = 'HQ' . date('YmdHis') . Random::numeric(4);
$now = time();
$id = Db::name('member_order')->insertGetId([
'order_no' => $orderNo,
'user_id' => $userId,
'plan_id' => (int)$plan['id'],
'plan_code' => $plan['code'],
'amount' => $plan['price'],
'pay_type' => $payType,
'pay_status' => 'unpaid',
'createtime' => $now,
'updatetime' => $now,
]);
$this->success('订单已创建', [
'order_id' => (int)$id,
'order_no' => $orderNo,
'amount' => (float)$plan['price'],
'plan' => [
'code' => $plan['code'],
'name' => $plan['name'],
],
'pay_type' => $payType,
'pay_hint' => '支付通道(微信/易支付)待接入,可由后台确认订单后开通权益',
'pay_status' => 'unpaid',
]);
}
}

查看文件

@@ -0,0 +1,187 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
use think\Validate;
/**
* 情绪疗愈接口(M2)
*/
class Mood extends Api
{
protected $noNeedLogin = [];
protected $noNeedRight = '*';
/** 情绪枚举 */
public static $moods = [
'happy' => ['text' => '开心', 'emoji' => '☀️', 'score' => 5, 'color' => '#F5A65B'],
'calm' => ['text' => '平静', 'emoji' => '🌤', 'score' => 4, 'color' => '#9BC1A6'],
'anxious' => ['text' => '焦虑', 'emoji' => '🌪', 'score' => 2, 'color' => '#E8A87C'],
'low' => ['text' => '低落', 'emoji' => '🌧', 'score' => 2, 'color' => '#8FA6C4'],
'angry' => ['text' => '烦躁', 'emoji' => '⚡', 'score' => 1, 'color' => '#E8604C'],
];
/**
* 记录情绪(一天可多条)
* POST log_date, mood, intensity, triggers, content
*/
public function save()
{
$userId = $this->auth->id;
$mood = (string)$this->request->post('mood', '');
if (!isset(self::$moods[$mood])) {
$this->error('请选择情绪');
}
$logDate = $this->request->post('log_date', date('Y-m-d'));
if (!strtotime($logDate)) {
$this->error('日期格式不正确');
}
if (strtotime($logDate) > strtotime('+1 day')) {
$this->error('不能记录未来日期');
}
$content = mb_substr((string)$this->request->post('content', ''), 0, 1000);
$id = Db::name('mood_log')->insertGetId([
'user_id' => $userId,
'log_date' => $logDate,
'mood' => $mood,
'intensity' => min(5, max(1, (int)$this->request->post('intensity', 3))),
'triggers' => mb_substr((string)$this->request->post('triggers', ''), 0, 255),
'content' => $content,
'createtime' => time(),
'updatetime' => time(),
]);
$this->success('已记录', [
'id' => (int)$id,
'mood' => self::$moods[$mood],
]);
}
/**
* 情绪列表
* GET limit=20 month=2026-11
*/
public function index()
{
$userId = $this->auth->id;
$limit = min(100, max(1, (int)$this->request->param('limit', 20)));
$month = $this->request->param('month', '');
$query = Db::name('mood_log')
->where('user_id', $userId)
->whereNull('deletetime');
if ($month && Validate::regex($month, '/^\d{4}-\d{2}$/')) {
$query->whereBetween('log_date', [$month . '-01', date('Y-m-t', strtotime($month . '-01'))]);
}
$list = $query->order('log_date', 'desc')->order('id', 'desc')->limit($limit)->select();
$list = array_map([$this, 'format'], $list);
$this->success('', ['list' => $list, 'moods' => $this->moodOptions()]);
}
/**
* 情绪趋势
* GET days=7|30
*/
public function trend()
{
$userId = $this->auth->id;
$days = (int)$this->request->param('days', 7);
$days = in_array($days, [7, 14, 30]) ? $days : 7;
$start = date('Y-m-d', strtotime('-' . ($days - 1) . ' day'));
$logs = Db::name('mood_log')
->where('user_id', $userId)
->whereNull('deletetime')
->where('log_date', '>=', $start)
->order('log_date', 'asc')
->select();
$bucket = [];
for ($i = $days - 1; $i >= 0; $i--) {
$d = date('Y-m-d', strtotime('-' . $i . ' day'));
$bucket[$d] = ['date' => $d, 'label' => date('n/j', strtotime($d)), 'score' => null, 'count' => 0, 'mood' => ''];
}
foreach ($logs as $log) {
if (!isset($bucket[$log['log_date']])) {
continue;
}
$score = self::$moods[$log['mood']]['score'] ?? 3;
$item = &$bucket[$log['log_date']];
$item['count']++;
$item['score'] = $item['score'] === null ? $score : round(($item['score'] * ($item['count'] - 1) + $score) / $item['count'], 1);
$item['mood'] = $log['mood'];
unset($item);
}
$recorded = array_values(array_filter($bucket, function ($v) {
return $v['score'] !== null;
}));
$avg = $recorded ? round(array_sum(array_column($recorded, 'score')) / count($recorded), 1) : 0;
// 主情绪统计
$count = [];
foreach ($logs as $log) {
$count[$log['mood']] = isset($count[$log['mood']]) ? $count[$log['mood']] + 1 : 1;
}
arsort($count);
$topMood = key($count) ?: '';
$this->success('', [
'days' => $days,
'list' => array_values($bucket),
'recorded' => count($recorded),
'avg_score' => $avg,
'top_mood' => $topMood ? array_merge(['key' => $topMood], self::$moods[$topMood]) : null,
'moods' => $this->moodOptions(),
]);
}
/**
* 删除一条情绪记录
*/
public function remove()
{
$id = (int)$this->request->post('id', 0);
$row = Db::name('mood_log')->where('id', $id)->where('user_id', $this->auth->id)->find();
if (!$row) {
$this->error('记录不存在');
}
Db::name('mood_log')->where('id', $id)->update(['deletetime' => time()]);
$this->success('已删除');
}
protected function moodOptions()
{
$list = [];
foreach (self::$moods as $key => $item) {
$list[] = array_merge(['key' => $key], $item);
}
return $list;
}
protected function format($log)
{
$mood = self::$moods[$log['mood']] ?? ['text' => '', 'emoji' => '', 'score' => 0, 'color' => ''];
return [
'id' => (int)$log['id'],
'log_date' => $log['log_date'],
'date_text' => date('n月j日', strtotime($log['log_date'])),
'week_text' => '周' . ['日', '一', '二', '三', '四', '五', '六'][(int)date('w', strtotime($log['log_date']))],
'mood' => $log['mood'],
'mood_text' => $mood['text'],
'emoji' => $mood['emoji'],
'color' => $mood['color'],
'intensity' => (int)$log['intensity'],
'triggers' => $log['triggers'],
'content' => $log['content'],
'time_text' => date('H:i', (int)$log['createtime']),
];
}
}

查看文件

@@ -0,0 +1,129 @@
<?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;
}
}

查看文件

@@ -353,4 +353,71 @@ class User extends Api
$this->error($this->auth->getError());
}
}
/**
* 我的资料(花期 APP 个人中心)
* GET
*/
public function detail()
{
$user = \think\Db::name('user')
->where('id', $this->auth->id)
->field('id,username,nickname,avatar,mobile,gender,birthday,bio')
->find();
$setting = \think\Db::name('user_setting')->where('user_id', $this->auth->id)->find();
$summary = \app\common\service\CycleService::summary($this->auth->id);
$this->success('', [
'user' => [
'id' => (int)$user['id'],
'username' => $user['username'],
'nickname' => $user['nickname'],
'avatar' => $user['avatar'],
'mobile' => $user['mobile'],
'gender' => (int)$user['gender'],
'birthday' => $user['birthday'],
'bio' => $user['bio'],
],
'summary' => $summary,
'member' => \app\common\service\MemberService::status($this->auth->id),
'privacy' => [
'app_lock' => $setting ? $setting['app_lock'] : 'none',
'local_only' => $setting ? (int)$setting['local_only'] : 0,
],
]);
}
/**
* 更新资料
* POST nickname, avatar, gender, birthday, bio
*/
public function updateprofile()
{
$data = [];
foreach (['nickname', 'avatar', 'bio'] as $field) {
$value = $this->request->post($field, null);
if ($value !== null) {
$data[$field] = mb_substr((string)$value, 0, $field === 'bio' ? 100 : 50);
}
}
$gender = $this->request->post('gender', null);
if ($gender !== null) {
$data['gender'] = (int)$gender === 2 ? 2 : ((int)$gender === 1 ? 1 : 0);
}
$birthday = $this->request->post('birthday', '');
if ($birthday) {
if (!strtotime($birthday)) {
$this->error('生日格式不正确');
}
$data['birthday'] = date('Y-m-d', strtotime($birthday));
}
if (!$data) {
$this->error('没有需要更新的内容');
}
$data['updatetime'] = time();
\think\Db::name('user')->where('id', $this->auth->id)->update($data);
$this->success('已更新');
}
}

查看文件

@@ -0,0 +1,328 @@
<?php
namespace app\common\service;
use think\Db;
/**
* 周期计算服务
*
* 统一为「周期管理 / 情绪疗愈 / 健康建议 / 首页聚合」提供四阶段计算与预测能力,
* 避免各控制器重复实现。所有日期均为 Y-m-d 字符串。
*/
class CycleService
{
const PHASE_MENSTRUAL = 'menstrual';
const PHASE_FOLLICULAR = 'follicular';
const PHASE_OVULATION = 'ovulation';
const PHASE_LUTEAL = 'luteal';
public static $phaseText = [
'menstrual' => '经期',
'follicular' => '卵泡期',
'ovulation' => '排卵期',
'luteal' => '黄体期',
];
/** 各阶段的品牌色(与前端设计令牌保持一致) */
public static $phaseColor = [
'menstrual' => '#E8604C',
'follicular' => '#F5A65B',
'ovulation' => '#F0796A',
'luteal' => '#C79A7B',
];
/**
* 取用户周期列表(按开始日倒序)
*/
public static function cycles($userId, $limit = 12)
{
return Db::name('cycle')
->where('user_id', $userId)
->whereNull('deletetime')
->order('start_date', 'desc')
->limit($limit)
->select();
}
/**
* 周期参数(周期长度 / 经期长度),支持用户自定义后取历史均值
*/
public static function params($userId)
{
$cycles = self::cycles($userId, 6);
$cycleLength = 28;
$periodLength = 5;
if ($cycles) {
// 用最近 6 个周期的平均长度
$lengths = [];
foreach ($cycles as $c) {
if ($c['cycle_length'] > 0) {
$lengths[] = (int)$c['cycle_length'];
}
}
$diff = [];
for ($i = 0; $i < count($cycles) - 1; $i++) {
$days = (strtotime($cycles[$i]['start_date']) - strtotime($cycles[$i + 1]['start_date'])) / 86400;
if ($days >= 18 && $days <= 60) {
$diff[] = (int)$days;
}
}
if ($diff) {
$cycleLength = (int)round(array_sum($diff) / count($diff));
} elseif ($lengths) {
$cycleLength = (int)round(array_sum($lengths) / count($lengths));
}
$periodLength = (int)$cycles[0]['period_length'] ?: 5;
}
return ['cycle_length' => $cycleLength, 'period_length' => $periodLength];
}
/**
* 当前周期状态(首页 / 记录页核心数据)
*/
public static function current($userId, $today = null)
{
$today = $today ?: date('Y-m-d');
$now = strtotime($today);
$cycles = self::cycles($userId);
$params = self::params($userId);
$base = [
'has_data' => false,
'today' => $today,
'phase' => null,
'phase_text' => '',
'phase_color' => '',
'day' => 0,
'cycle_length' => $params['cycle_length'],
'period_length' => $params['period_length'],
'start_date' => null,
'next_start' => null,
'days_to_next' => null,
'ovulation' => null,
'fertile_start' => null,
'fertile_end' => null,
'is_fertile' => false,
'is_period' => false,
'progress' => 0,
];
if (!$cycles) {
return $base;
}
// 找到覆盖今天的周期(start <= today 且 today < start + cycleLength)
$active = null;
foreach ($cycles as $c) {
$start = strtotime($c['start_date']);
if ($start <= $now && $now < $start + $params['cycle_length'] * 86400) {
$active = $c;
break;
}
}
// 今天早于最新周期开始日(补录历史)时,退化使用最新周期推算
if (!$active) {
$active = $cycles[0];
}
$start = strtotime($active['start_date']);
$day = (int)floor(($now - $start) / 86400) + 1;
$phase = self::phaseByDay($day, $params['cycle_length'], $params['period_length']);
$ovulationTs = $start + ($params['cycle_length'] - 14) * 86400;
$nextStartTs = $start + $params['cycle_length'] * 86400;
$daysToNext = (int)ceil(($nextStartTs - $now) / 86400);
$result = array_merge($base, [
'has_data' => true,
'phase' => $phase,
'phase_text' => self::$phaseText[$phase],
'phase_color' => self::$phaseColor[$phase],
'day' => max($day, 1),
'start_date' => $active['start_date'],
'next_start' => date('Y-m-d', $nextStartTs),
'days_to_next' => $daysToNext,
'ovulation' => date('Y-m-d', $ovulationTs),
'fertile_start' => date('Y-m-d', $ovulationTs - 5 * 86400),
'fertile_end' => date('Y-m-d', $ovulationTs + 1 * 86400),
'is_period' => $phase === self::PHASE_MENSTRUAL,
'is_fertile' => $now >= $ovulationTs - 5 * 86400 && $now <= $ovulationTs + 1 * 86400,
'progress' => min(100, (int)round(max($day, 1) / $params['cycle_length'] * 100)),
]);
return $result;
}
/**
* 根据周期内第几天计算阶段
*/
public static function phaseByDay($day, $cycleLength = 28, $periodLength = 5)
{
$ovulationDay = max(1, $cycleLength - 14);
if ($day <= $periodLength) {
return self::PHASE_MENSTRUAL;
}
if ($day < $ovulationDay - 1) {
return self::PHASE_FOLLICULAR;
}
if ($day <= $ovulationDay + 1) {
return self::PHASE_OVULATION;
}
return self::PHASE_LUTEAL;
}
/**
* 未来 N 个月的经期/排卵预测
*/
public static function predict($userId, $months = 3)
{
$cycles = self::cycles($userId, 1);
$params = self::params($userId);
$list = [];
if (!$cycles) {
return ['list' => $list, 'cycle_length' => $params['cycle_length'], 'need_record' => true];
}
$start = strtotime($cycles[0]['start_date']);
$today = strtotime(date('Y-m-d'));
$limit = strtotime('+' . $months . ' month');
$i = 1;
while (true) {
$nextStart = $start + $i * $params['cycle_length'] * 86400;
if ($nextStart > $limit) {
break;
}
$ovulation = $nextStart + ($params['cycle_length'] - 14) * 86400;
$list[] = [
'start' => date('Y-m-d', $nextStart),
'end' => date('Y-m-d', $nextStart + ($params['period_length'] - 1) * 86400),
'ovulation' => date('Y-m-d', $ovulation),
'fertile_start' => date('Y-m-d', $ovulation - 5 * 86400),
'fertile_end' => date('Y-m-d', $ovulation + 86400),
'days_from_now' => (int)ceil(($nextStart - $today) / 86400),
];
$i++;
}
return ['list' => $list, 'cycle_length' => $params['cycle_length'], 'need_record' => false];
}
/**
* 月历:某月每一天的阶段信息
*/
public static function calendar($userId, $month)
{
$first = strtotime($month . '-01');
$daysInMonth = (int)date('t', $first);
$today = date('Y-m-d');
$cycles = self::cycles($userId, 24);
$params = self::params($userId);
$predict = self::predict($userId, 4);
$logs = Db::name('cycle_log')
->where('user_id', $userId)
->whereNull('deletetime')
->whereBetween('log_date', [$month . '-01', $month . '-' . $daysInMonth])
->column('log_date');
$list = [];
for ($d = 1; $d <= $daysInMonth; $d++) {
$date = date('Y-m-d', $first + ($d - 1) * 86400);
$ts = strtotime($date);
$phase = null;
$inPeriod = false;
$isPredicted = false;
// 1) 真实周期覆盖
foreach ($cycles as $c) {
$cStart = strtotime($c['start_date']);
if ($ts >= $cStart && $ts < $cStart + $params['cycle_length'] * 86400) {
$day = (int)floor(($ts - $cStart) / 86400) + 1;
$phase = self::phaseByDay($day, $params['cycle_length'], $params['period_length']);
$inPeriod = $day <= $params['period_length'];
break;
}
}
// 2) 预测经期
if (!$phase) {
foreach ($predict['list'] as $p) {
if ($date >= $p['start'] && $date <= $p['end']) {
$phase = self::PHASE_MENSTRUAL;
$inPeriod = true;
$isPredicted = true;
break;
}
if ($date >= $p['fertile_start'] && $date <= $p['fertile_end']) {
$phase = self::PHASE_OVULATION;
$isPredicted = true;
break;
}
}
}
$list[] = [
'date' => $date,
'day' => $d,
'week' => (int)date('w', $ts),
'phase' => $phase ?: 'unknown',
'phase_text' => $phase ? self::$phaseText[$phase] : '',
'color' => $phase ? self::$phaseColor[$phase] : '',
'in_period' => $inPeriod,
'is_predicted' => $isPredicted,
'has_log' => in_array($date, $logs),
'is_today' => $date === $today,
];
}
return [
'month' => $month,
'list' => $list,
'today' => $today,
'cycle' => self::current($userId),
];
}
/**
* 周期汇总(平均长度、规律性评分、记录量)
*/
public static function summary($userId)
{
$cycles = self::cycles($userId, 12);
$params = self::params($userId);
$diffs = [];
for ($i = 0; $i < count($cycles) - 1; $i++) {
$days = (int)((strtotime($cycles[$i]['start_date']) - strtotime($cycles[$i + 1]['start_date'])) / 86400);
if ($days >= 18 && $days <= 60) {
$diffs[] = $days;
}
}
$variance = 0;
if ($diffs) {
$avg = array_sum($diffs) / count($diffs);
foreach ($diffs as $d) {
$variance += pow($d - $avg, 2);
}
$variance = sqrt($variance / count($diffs));
}
// 规律性:标准差越小分越高(0 天 → 100 分,≥5 天 → 60 分以下)
$regularity = $diffs ? max(50, (int)round(100 - $variance * 8)) : 0;
$regularityText = $regularity >= 85 ? '非常规律' : ($regularity >= 70 ? '较为规律' : ($regularity > 0 ? '略有波动' : '数据不足'));
return [
'cycle_length' => $params['cycle_length'],
'period_length' => $params['period_length'],
'regularity' => $regularity,
'regularity_text' => $regularityText,
'cycle_count' => count($cycles),
'log_count' => Db::name('cycle_log')->where('user_id', $userId)->whereNull('deletetime')->count(),
'mood_count' => Db::name('mood_log')->where('user_id', $userId)->whereNull('deletetime')->count(),
'last_start' => $cycles ? $cycles[0]['start_date'] : null,
];
}
}

查看文件

@@ -0,0 +1,122 @@
<?php
namespace app\common\service;
use think\Db;
/**
* 会员权益服务
*
* 会员判定统一出口:订单表 fa_member_order 中「已支付 + 未过期」的最近一条即当前会员。
* 权益标识与 docs/01-产品功能模块清单.md 第 12 节权益矩阵保持一致。
*/
class MemberService
{
/** 权益标识 → 中文名(用于前端展示) */
public static $benefitText = [
'report.deep' => '深度健康报告',
'report.annual' => '年度报告精装版',
'prepare.plan' => '精准备孕计划',
'prepare.intercourse' => '同房指导',
'lhtest.quota' => '试纸识别额度',
'content.theme' => '专属心语主题包',
'meditation.deep' => '深度冥想课程',
'sync.multi' => '多设备同步',
'course.discount' => '课程折扣',
'consult.discount' => '咨询折扣',
'identity.lifetime' => '终身会员标识',
];
/**
* 当前有效会员订单
*/
public static function activeOrder($userId)
{
$now = time();
return Db::name('member_order')
->where('user_id', $userId)
->where('pay_status', 'paid')
->where(function ($query) use ($now) {
$query->where('expire_time', 0)->whereOr('expire_time', '>', $now);
})
->order('id', 'desc')
->find();
}
/**
* 会员状态
*/
public static function status($userId)
{
$order = self::activeOrder($userId);
if (!$order) {
return [
'is_member' => false,
'plan_code' => '',
'plan_name' => '',
'expire_time' => 0,
'days_left' => 0,
'forever' => false,
'benefits' => [],
'benefit_texts' => [],
];
}
$plan = Db::name('member_plan')->where('code', $order['plan_code'])->find();
$benefits = $plan && $plan['benefits'] ? json_decode($plan['benefits'], true) : [];
$forever = (int)$order['expire_time'] === 0;
$daysLeft = $forever ? 0 : max(0, (int)ceil(($order['expire_time'] - time()) / 86400));
return [
'is_member' => true,
'plan_code' => $order['plan_code'],
'plan_name' => $plan ? $plan['name'] : '',
'expire_time' => (int)$order['expire_time'],
'expire_date' => $forever ? '永久' : date('Y-m-d', (int)$order['expire_time']),
'days_left' => $daysLeft,
'forever' => $forever,
'benefits' => $benefits ?: [],
'benefit_texts' => self::texts($benefits ?: []),
];
}
/**
* 是否拥有某个权益
*/
public static function can($userId, $benefit)
{
$status = self::status($userId);
return $status['is_member'] && in_array($benefit, $status['benefits']);
}
/**
* 权益标识转中文
*/
public static function texts($benefits)
{
$list = [];
foreach ((array)$benefits as $b) {
$list[] = isset(self::$benefitText[$b]) ? self::$benefitText[$b] : $b;
}
return $list;
}
/**
* 套餐列表
*/
public static function plans()
{
$list = Db::name('member_plan')
->where('status', 'normal')
->order('weigh', 'desc')
->select();
foreach ($list as &$item) {
$benefits = $item['benefits'] ? json_decode($item['benefits'], true) : [];
$item['benefit_texts'] = self::texts($benefits);
$item['benefits'] = $benefits ?: [];
$item['price'] = (float)$item['price'];
$item['origin_price'] = (float)$item['origin_price'];
}
return $list;
}
}