本次提交完成多项核心更新: 1. 新增闲情逸致功能模块,包含时间线、收藏标注、季节主题等基础框架 2. 替换hive为社区维护的hive_ce包,修复依赖兼容问题 3. 统一替换"开发中"提示为"当前设备不支持",优化用户提示文案 4. 新增多项功能开关与特性标志,统一管理不可用功能提示 5. 完善用户账户洞察系统,新增头像审核中状态检测 6. 优化TTS语音朗读服务,修复Android端引擎初始化问题 7. 重构知识图谱缩放手势逻辑,解决缩放不跟手问题 8. 新增精灵头像组件,替换默认聊天头像样式 9. 新增外部链接跳转确认弹窗,提升使用安全性 10. 升级后端API接口,新增签到配置获取与补签积分规则动态读取 11. 完善多语言翻译覆盖率限制,非中文语言仅显示最高50%进度 12. 新增HTTP缓存拦截器,优化网络请求性能 13. 新增恢复出厂设置选项,完善数据管理功能 同时修复了多处代码细节问题:简化字符串拼接、优化布局代码、移除多余代码等。
1513 lines
62 KiB
PHP
1513 lines
62 KiB
PHP
<?php
|
||
|
||
namespace app\api\controller;
|
||
|
||
use app\common\controller\Api;
|
||
use think\Config;
|
||
|
||
/**
|
||
* 用户中心接口
|
||
* @time 2026-04-29
|
||
* @name UserCenter控制器
|
||
* @description 用户中心API,含个人信息/签到/收藏/笔记/点赞/互动/数据面板/设备管理/会员/云空间等
|
||
* @lastUpdate v10.0.0 registerDevice新增ip_city/ip_range参数; devices返回ip_city/ip_range; 新增myDevices接口
|
||
*/
|
||
class UserCenter extends Api
|
||
{
|
||
protected $noNeedLogin = ['public_profile', 'signin_config'];
|
||
protected $noNeedRight = '*';
|
||
|
||
private static $secQuestions = [
|
||
1 => '您母亲的姓名是?',
|
||
2 => '您的第一只宠物叫什么?',
|
||
3 => '您就读的小学名称是?',
|
||
4 => '您的出生地是?',
|
||
5 => '您最喜欢的电影是?',
|
||
6 => '您最好朋友的名字是?',
|
||
7 => '您父亲的姓名是?',
|
||
8 => '您的童年昵称是?',
|
||
];
|
||
|
||
private static $rateLimitKey = 'api_rate_limit:';
|
||
private static $rateLimits = [
|
||
'signin' => ['max' => 10, 'window' => 60],
|
||
'favorite' => ['max' => 60, 'window' => 60],
|
||
'note' => ['max' => 40, 'window' => 60],
|
||
'profile' => ['max' => 30, 'window' => 60],
|
||
'like' => ['max' => 60, 'window' => 60],
|
||
'interaction' => ['max' => 60, 'window' => 60],
|
||
'stats' => ['max' => 30, 'window' => 60],
|
||
'devices' => ['max' => 30, 'window' => 60],
|
||
];
|
||
|
||
public function _initialize()
|
||
{
|
||
parent::_initialize();
|
||
if (!Config::get('fastadmin.usercenter')) {
|
||
$this->error(__('User center already closed'));
|
||
}
|
||
}
|
||
|
||
private function checkRateLimit($action)
|
||
{
|
||
if (!isset(self::$rateLimits[$action])) {
|
||
return true;
|
||
}
|
||
$config = self::$rateLimits[$action];
|
||
$key = self::$rateLimitKey . $action . ':' . $this->request->ip();
|
||
$cache = cache($key);
|
||
$count = $cache ? intval($cache) : 0;
|
||
if ($count >= $config['max']) {
|
||
$this->error('请求过于频繁,请稍后再试', null, ['retry_after' => $config['window']]);
|
||
}
|
||
cache($key, $count + 1, $config['window']);
|
||
return true;
|
||
}
|
||
|
||
private function sanitizeString($value, $maxLength = 255)
|
||
{
|
||
if (!is_string($value)) {
|
||
return '';
|
||
}
|
||
$value = trim($value);
|
||
$value = strip_tags($value);
|
||
$value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||
if (mb_strlen($value) > $maxLength) {
|
||
$value = mb_substr($value, 0, $maxLength);
|
||
}
|
||
return $value;
|
||
}
|
||
|
||
private function validateLength($value, $field, $min = 1, $max = 255)
|
||
{
|
||
$len = mb_strlen($value);
|
||
if ($len < $min || $len > $max) {
|
||
$this->error("{$field}长度须在{$min}-{$max}之间");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function detectMaliciousInput($value)
|
||
{
|
||
$patterns = [
|
||
'/<script\b[^>]*>/i',
|
||
'/javascript\s*:/i',
|
||
'/on\w+\s*=/i',
|
||
'/union\s+select/i',
|
||
'/insert\s+into/i',
|
||
'/delete\s+from/i',
|
||
'/update\s+\w+\s+set/i',
|
||
'/drop\s+table/i',
|
||
'/\.\.[\/\\\\]/i',
|
||
'/eval\s*\(/i',
|
||
];
|
||
foreach ($patterns as $pattern) {
|
||
if (preg_match($pattern, $value)) {
|
||
$this->error('输入内容包含非法字符');
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function validateInt($value, $field, $min = 0, $max = 2147483647)
|
||
{
|
||
$val = intval($value);
|
||
if ($val < $min || $val > $max) {
|
||
$this->error("{$field}数值无效");
|
||
}
|
||
return $val;
|
||
}
|
||
|
||
/**
|
||
* @name 获取用户信息
|
||
* @desc 获取当前登录用户信息,不常用字段压缩到extra
|
||
* @lastUpdate v9.0.0 重构返回结构,新增is_online/vip/cloud_space/devices字段,avatar优先使用avatar_url
|
||
*/
|
||
public function index()
|
||
{
|
||
$user = $this->auth->getUser();
|
||
$titleInfo = null;
|
||
if ($user->title_id) {
|
||
$titleInfo = db('user_title')->where('id', $user->title_id)->field('id,name,icon,color')->find();
|
||
}
|
||
|
||
$avatarUrl = isset($user->avatar_url) ? trim($user->avatar_url) : '';
|
||
$avatar = $avatarUrl ?: ($user->avatar ? cdnurl($user->avatar, true) : '');
|
||
|
||
$vipStartTime = isset($user->vip_start_time) ? intval($user->vip_start_time) : 0;
|
||
$vipEndTime = isset($user->vip_end_time) ? intval($user->vip_end_time) : 0;
|
||
$isVip = $vipEndTime > time();
|
||
$cloudTotal = isset($user->cloud_space_total) ? intval($user->cloud_space_total) : 819200;
|
||
$cloudUsed = isset($user->cloud_space_used) ? intval($user->cloud_space_used) : 0;
|
||
$lastActiveTime = isset($user->last_active_time) ? intval($user->last_active_time) : 0;
|
||
$isOnline = (time() - $lastActiveTime < 300) ? 1 : 0;
|
||
|
||
$onlineDevices = db('user_device')
|
||
->where('user_id', $user->id)
|
||
->where('is_online', 1)
|
||
->field('id,device_name,device_model,platform,app_name,ip,last_active_time')
|
||
->order('last_active_time', 'desc')
|
||
->select();
|
||
|
||
$secQuestionId = isset($user->sec_question) ? intval($user->sec_question) : 0;
|
||
$secQuestionText = ($secQuestionId > 0 && isset(self::$secQuestions[$secQuestionId])) ? self::$secQuestions[$secQuestionId] : '';
|
||
|
||
$data = [
|
||
'id' => $user->id,
|
||
'username' => $user->username,
|
||
'nickname' => $user->nickname,
|
||
'avatar' => $avatar,
|
||
'email' => $user->email,
|
||
'mobile' => $user->mobile,
|
||
'score' => $user->score,
|
||
'level' => $user->level ?: 1,
|
||
'exp' => $user->exp ?: 0,
|
||
'exp_to_next' => $this->_calcExpToNext(intval($user->exp ?: 0)),
|
||
'exp_progress' => $this->_calcExpProgress(intval($user->exp ?: 0)),
|
||
'title' => $titleInfo,
|
||
'signin_days' => $user->signin_days ?: 0,
|
||
'article_count' => $user->article_count ?: 0,
|
||
'bio' => $user->bio,
|
||
'is_online' => $isOnline,
|
||
'vip' => [
|
||
'is_vip' => $isVip,
|
||
'start_time' => $vipStartTime,
|
||
'end_time' => $vipEndTime,
|
||
'start_date' => $vipStartTime ? date('Y-m-d', $vipStartTime) : '',
|
||
'end_date' => $vipEndTime ? date('Y-m-d', $vipEndTime) : '',
|
||
],
|
||
'cloud_space' => [
|
||
'total' => $cloudTotal,
|
||
'used' => $cloudUsed,
|
||
'free' => max(0, $cloudTotal - $cloudUsed),
|
||
'total_human' => $this->formatBytes($cloudTotal),
|
||
'used_human' => $this->formatBytes($cloudUsed),
|
||
'usage_percent'=> $cloudTotal > 0 ? round($cloudUsed / $cloudTotal * 100, 1) : 0,
|
||
],
|
||
'devices' => $onlineDevices,
|
||
'extra' => [
|
||
'money' => $user->money,
|
||
'level' => $user->level ?: 1,
|
||
'exp' => $user->exp ?: 0,
|
||
'exp_to_next' => $this->_calcExpToNext(intval($user->exp ?: 0)),
|
||
'exp_progress' => $this->_calcExpProgress(intval($user->exp ?: 0)),
|
||
'level_title' => $this->_getLevelTitle(intval($user->level ?: 1)),
|
||
'note_limit' => $user->note_limit ?: 50,
|
||
'verification' => $user->verification,
|
||
'last_signin_date'=> $user->last_signin_date,
|
||
'sec_question' => [
|
||
'question_id' => $secQuestionId,
|
||
'question_text' => $secQuestionText,
|
||
],
|
||
],
|
||
];
|
||
$this->success('', $data);
|
||
}
|
||
|
||
/**
|
||
* @name 计算升级所需剩余EXP
|
||
* @desc 基于阶梯表直接计算距离下一级还需多少EXP
|
||
* @lastUpdate v10.2.0 从二分查找+pow公式改为阶梯表
|
||
*/
|
||
private function _calcExpToNext($exp)
|
||
{
|
||
$lv = array(1 => 0, 2 => 30, 3 => 100, 4 => 300, 5 => 800, 6 => 2000, 7 => 5000, 8 => 12000, 9 => 30000, 10 => 80000);
|
||
$currentLevel = \app\common\model\User::nextlevelByExp($exp);
|
||
if ($currentLevel >= 10) return 0;
|
||
$nextLevel = $currentLevel + 1;
|
||
return $lv[$nextLevel] - $exp;
|
||
}
|
||
|
||
/**
|
||
* @name 计算EXP进度
|
||
* @desc 基于阶梯表直接计算当前等级的EXP进度(0~1)
|
||
* @lastUpdate v10.2.0 从二分查找+pow公式改为阶梯表
|
||
*/
|
||
private function _calcExpProgress($exp)
|
||
{
|
||
$lv = array(1 => 0, 2 => 30, 3 => 100, 4 => 300, 5 => 800, 6 => 2000, 7 => 5000, 8 => 12000, 9 => 30000, 10 => 80000);
|
||
$currentLevel = \app\common\model\User::nextlevelByExp($exp);
|
||
if ($currentLevel >= 10) return 1.0;
|
||
$currentLevelExp = $lv[$currentLevel];
|
||
$nextLevelExp = $lv[$currentLevel + 1];
|
||
if ($nextLevelExp <= $currentLevelExp) return 1.0;
|
||
return round(($exp - $currentLevelExp) / ($nextLevelExp - $currentLevelExp), 4);
|
||
}
|
||
|
||
|
||
/**
|
||
* @name 获取等级称号
|
||
* @desc 根据等级返回对应称号文本
|
||
*/
|
||
private function _getLevelTitle($level)
|
||
{
|
||
$titles = [
|
||
1 => '新手', 2 => '学徒', 3 => '初学者', 4 => '进阶者', 5 => '学者',
|
||
6 => '达人', 7 => '专家', 8 => '大师', 9 => '宗师', 10 => '传说',
|
||
];
|
||
return isset($titles[$level]) ? $titles[$level] : '新手';
|
||
}
|
||
|
||
/**
|
||
* @name 格式化字节
|
||
* @desc 将字节数转为人类可读格式
|
||
*/
|
||
private function formatBytes($bytes, $precision = 2)
|
||
{
|
||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
$bytes = max($bytes, 0);
|
||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||
$pow = min($pow, count($units) - 1);
|
||
$bytes /= pow(1024, $pow);
|
||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||
}
|
||
|
||
/**
|
||
* @name 修改个人信息
|
||
* @desc 修改用户名/昵称/简介/头像URL
|
||
* @lastUpdate v9.0.0 avatar改为avatar_url(用户填写的URL优先)
|
||
*/
|
||
public function profile()
|
||
{
|
||
$this->checkRateLimit('profile');
|
||
$user = $this->auth->getUser();
|
||
$username = $this->request->post('username', '', 'trim,strip_tags');
|
||
$nickname = $this->request->post('nickname', '', 'trim,strip_tags');
|
||
$bio = $this->request->post('bio', '', 'trim');
|
||
$avatarUrl = $this->request->post('avatar_url', '', 'trim,strip_tags,htmlspecialchars');
|
||
|
||
if ($username) {
|
||
$this->validateLength($username, '用户名', 3, 30);
|
||
$this->detectMaliciousInput($username);
|
||
if (!preg_match('/^[a-zA-Z0-9_\x{4e00}-\x{9fa5}]+$/u', $username)) {
|
||
$this->error('用户名只能包含字母、数字、下划线和中文');
|
||
}
|
||
$exists = \app\common\model\User::where('username', $username)->where('id', '<>', $this->auth->id)->find();
|
||
if ($exists) {
|
||
$this->error(__('Username already exists'));
|
||
}
|
||
$user->username = $username;
|
||
}
|
||
if ($nickname) {
|
||
$this->validateLength($nickname, '昵称', 1, 50);
|
||
$this->detectMaliciousInput($nickname);
|
||
$exists = \app\common\model\User::where('nickname', $nickname)->where('id', '<>', $this->auth->id)->find();
|
||
if ($exists) {
|
||
$this->error(__('Nickname already exists'));
|
||
}
|
||
$user->nickname = $nickname;
|
||
}
|
||
if ($bio !== null) {
|
||
$this->validateLength($bio, '简介', 0, 500);
|
||
$user->bio = $bio;
|
||
}
|
||
if ($avatarUrl !== null) {
|
||
$this->validateLength($avatarUrl, '头像URL', 0, 500);
|
||
if ($avatarUrl && !filter_var($avatarUrl, FILTER_VALIDATE_URL) && !preg_match('/^https?:\/\//i', $avatarUrl)) {
|
||
$this->error('头像URL格式不正确');
|
||
}
|
||
$user->avatar_url = $avatarUrl;
|
||
}
|
||
$user->save();
|
||
$this->success();
|
||
}
|
||
|
||
/**
|
||
* @name 每日签到
|
||
* @desc 签到获取积分,连续签到有额外奖励
|
||
*/
|
||
public function signin()
|
||
{
|
||
$this->checkRateLimit('signin');
|
||
$userId = $this->auth->id;
|
||
$todayStart = strtotime(date('Y-m-d'));
|
||
$today = db('user_signin')->where('user_id', $userId)->where('createtime', '>=', $todayStart)->find();
|
||
if ($today) {
|
||
$this->error('今日已签到', ['continuous' => $today['continuous'], 'today_signed' => true]);
|
||
}
|
||
$yesterdayStart = strtotime(date('Y-m-d', strtotime('-1 day')));
|
||
$yesterday = db('user_signin')->where('user_id', $userId)->where('createtime', 'between', [$yesterdayStart, $todayStart - 1])->find();
|
||
$continuous = $yesterday ? ($yesterday['continuous'] + 1) : 1;
|
||
$coinReward = min($continuous * 2, 20);
|
||
$rule = db('coin_rule')->where('action', 'signin')->where('status', 'normal')->find();
|
||
if ($rule) {
|
||
$coinReward = $rule['amount'];
|
||
if ($continuous >= 7 && $rule7 = db('coin_rule')->where('action', 'signin_continuous_7')->where('status', 'normal')->find()) {
|
||
$coinReward += $rule7['amount'];
|
||
}
|
||
if ($continuous >= 30 && $rule30 = db('coin_rule')->where('action', 'signin_continuous_30')->where('status', 'normal')->find()) {
|
||
$coinReward += $rule30['amount'];
|
||
}
|
||
}
|
||
$data = [
|
||
'user_id' => $userId,
|
||
'date' => date('Y-m-d'),
|
||
'coin_reward' => $coinReward,
|
||
'continuous' => $continuous,
|
||
'createtime' => time(),
|
||
];
|
||
db('user_signin')->insert($data);
|
||
db('user')->where('id', $userId)->setInc('score', $coinReward);
|
||
db('user')->where('id', $userId)->update(['signin_days' => $continuous, 'last_signin_date' => date('Y-m-d')]);
|
||
$logData = [
|
||
'user_id' => $userId,
|
||
'coin_type' => 'score',
|
||
'amount' => $coinReward,
|
||
'before' => $this->auth->score,
|
||
'after' => $this->auth->score + $coinReward,
|
||
'action' => 'signin',
|
||
'remark' => '每日签到奖励(连续' . $continuous . '天)',
|
||
'createtime'=> time(),
|
||
];
|
||
db('coin_log')->insert($logData);
|
||
$signinExp = 5 + $continuous * 2;
|
||
\app\common\model\User::exp($signinExp, $userId, 'signin', '每日签到EXP');
|
||
\app\api\controller\Achievement::checkBadges($userId);
|
||
$this->success('签到成功', ['continuous' => $continuous, 'coin_reward' => $coinReward, 'exp_reward' => $signinExp, 'today_signed' => true]);
|
||
}
|
||
|
||
/**
|
||
* @name 签到日历
|
||
* @desc 获取指定月份的签到记录
|
||
*/
|
||
public function signin_calendar()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$month = trim($this->request->param('month', date('Y-m')));
|
||
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
||
$this->error('月份格式错误');
|
||
}
|
||
$startDate = strtotime($month . '-01');
|
||
$endDate = strtotime($month . '-01 +1 month');
|
||
$records = db('user_signin')
|
||
->where('user_id', $userId)
|
||
->where('createtime', 'between', [$startDate, $endDate - 1])
|
||
->field('id,date,coin_reward,continuous,createtime')
|
||
->order('date', 'asc')
|
||
->select();
|
||
$calendar = [];
|
||
foreach ($records as $r) {
|
||
$calendar[$r['date']] = $r;
|
||
}
|
||
$currentContinuous = 0;
|
||
$lastSignin = db('user_signin')->where('user_id', $userId)->order('date', 'desc')->find();
|
||
if ($lastSignin) {
|
||
$currentContinuous = $lastSignin['continuous'];
|
||
}
|
||
$this->success('', [
|
||
'month' => $month,
|
||
'calendar' => $calendar,
|
||
'current_continuous' => $currentContinuous,
|
||
'total_signins' => db('user_signin')->where('user_id', $userId)->count(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @name 签到配置
|
||
* @desc 获取补签消耗积分等配置信息
|
||
*/
|
||
public function signin_config()
|
||
{
|
||
$makeupRule = db('coin_rule')->where('action', 'signin_makeup')->where('status', 'normal')->find();
|
||
$makeupCost = $makeupRule ? intval($makeupRule['amount']) : 10;
|
||
$this->success('获取成功', ['makeup_cost' => $makeupCost]);
|
||
}
|
||
|
||
/**
|
||
* @name 补签
|
||
* @desc 消耗积分补签过去30天内的日期
|
||
*/
|
||
public function signin_makeup()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$date = $this->request->post('date', '', 'trim');
|
||
|
||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||
$this->error('日期格式错误');
|
||
}
|
||
|
||
$dateTimestamp = strtotime($date);
|
||
if ($dateTimestamp >= strtotime(date('Y-m-d'))) {
|
||
$this->error('只能补签过去的日期');
|
||
}
|
||
if ($dateTimestamp < strtotime('-30 days')) {
|
||
$this->error('只能补签30天内的日期');
|
||
}
|
||
|
||
$exists = db('user_signin')->where('user_id', $userId)->where('date', $date)->find();
|
||
if ($exists) {
|
||
$this->error('该日期已签到');
|
||
}
|
||
|
||
$makeupRule = db('coin_rule')->where('action', 'signin_makeup')->where('status', 'normal')->find();
|
||
$makeupCost = $makeupRule ? intval($makeupRule['amount']) : 10;
|
||
$user = db('user')->where('id', $userId)->find();
|
||
if ($user['score'] < $makeupCost) {
|
||
$this->error('积分不足,补签需要' . $makeupCost . '积分');
|
||
}
|
||
|
||
$prevDate = date('Y-m-d', strtotime($date . ' -1 day'));
|
||
$prevSignin = db('user_signin')->where('user_id', $userId)->where('date', $prevDate)->find();
|
||
$continuous = $prevSignin ? ($prevSignin['continuous'] + 1) : 1;
|
||
|
||
db('user_signin')->insert([
|
||
'user_id' => $userId,
|
||
'date' => $date,
|
||
'coin_reward' => 0,
|
||
'continuous' => $continuous,
|
||
'createtime' => time(),
|
||
]);
|
||
|
||
db('user')->where('id', $userId)->setDec('score', $makeupCost);
|
||
|
||
try {
|
||
db('coin_log')->insert([
|
||
'user_id' => $userId,
|
||
'coin_type' => 'score',
|
||
'amount' => -$makeupCost,
|
||
'before' => $user['score'],
|
||
'after' => $user['score'] - $makeupCost,
|
||
'action' => 'signin_makeup',
|
||
'remark' => '补签扣除: ' . $date,
|
||
'createtime' => time(),
|
||
]);
|
||
} catch (\Exception $e) {}
|
||
|
||
$this->success('补签成功', ['date' => $date, 'cost' => $makeupCost]);
|
||
}
|
||
|
||
/**
|
||
* @name 收藏操作
|
||
* @desc 添加/移除/检查/列表/分组/移动收藏
|
||
*/
|
||
public function favorite()
|
||
{
|
||
$this->checkRateLimit('favorite');
|
||
$userId = $this->auth->id;
|
||
$action = trim($this->request->param('action', 'add'));
|
||
$targetId = $this->request->param('target_id/d', 0);
|
||
$targetType = trim($this->request->param('target_type', 'article'));
|
||
$remark = $this->sanitizeString($this->request->param('remark', ''), 100);
|
||
$groupName = $this->sanitizeString($this->request->param('group_name', '默认'), 30);
|
||
$newGroup = $this->sanitizeString($this->request->param('new_group', ''), 30);
|
||
|
||
$allowedTypes = ['article', 'tool', 'page', 'poetry', 'cy', 'story', 'wisdom', 'hanzi', 'chengyu', 'cidian', 'saying', 'joke'];
|
||
$allowedActions = ['add', 'remove', 'check', 'list', 'count', 'groups', 'move'];
|
||
if (!in_array($action, $allowedActions)) {
|
||
$this->error('无效的操作类型');
|
||
}
|
||
if (!in_array($targetType, $allowedTypes) && !in_array($action, ['groups', 'count'])) {
|
||
$this->error('无效的收藏类型');
|
||
}
|
||
if (!in_array($action, ['list', 'count', 'groups']) && !$targetId) {
|
||
$this->error('参数错误');
|
||
}
|
||
if ($targetId) {
|
||
$this->validateInt($targetId, '目标ID', 1);
|
||
}
|
||
|
||
$where = ['user_id' => $userId, 'target_id' => $targetId, 'target_type' => $targetType];
|
||
|
||
if ($action == 'add') {
|
||
$exists = db('user_favorite')->where($where)->find();
|
||
if ($exists) {
|
||
$this->error('已收藏');
|
||
}
|
||
$where['createtime'] = time();
|
||
$where['remark'] = $remark;
|
||
$where['group_name'] = $groupName;
|
||
$favId = db('user_favorite')->insertGetId($where);
|
||
$this->success('收藏成功', ['id' => $favId]);
|
||
} elseif ($action == 'remove') {
|
||
db('user_favorite')->where($where)->delete();
|
||
$this->success('取消收藏');
|
||
} elseif ($action == 'check') {
|
||
$exists = db('user_favorite')->where($where)->find();
|
||
$this->success('', ['is_favorited' => !empty($exists)]);
|
||
} elseif ($action == 'list') {
|
||
$page = $this->request->param('page', 1, 'intval');
|
||
$limit = $this->request->param('limit', 15, 'intval');
|
||
$limit = min(max($limit, 1), 50);
|
||
$favWhere = function() use ($userId, $targetType) {
|
||
$q = db('user_favorite')->where('user_id', $userId);
|
||
if ($targetType) $q->where('target_type', $targetType);
|
||
return $q;
|
||
};
|
||
$total = $favWhere()->count();
|
||
$list = $favWhere()->page($page, $limit)->order('createtime', 'desc')->select();
|
||
$this->success('', ['list' => $list, 'total' => $total]);
|
||
} elseif ($action == 'count') {
|
||
$total = db('user_favorite')->where('user_id', $userId)->count();
|
||
$byType = db('user_favorite')->where('user_id', $userId)->group('target_type')->field('target_type, count(*) as count')->select();
|
||
$this->success('', ['total' => $total, 'by_type' => $byType]);
|
||
} elseif ($action == 'groups') {
|
||
$groups = db('user_favorite')
|
||
->where('user_id', $userId)
|
||
->group('group_name')
|
||
->field('group_name, count(*) as count')
|
||
->select();
|
||
$this->success('', ['groups' => $groups]);
|
||
} elseif ($action == 'move') {
|
||
if (!$targetId || !$newGroup) {
|
||
$this->error('参数错误');
|
||
}
|
||
$exists = db('user_favorite')->where($where)->find();
|
||
if (!$exists) {
|
||
$this->error('收藏不存在');
|
||
}
|
||
db('user_favorite')->where($where)->update(['group_name' => $newGroup]);
|
||
$this->success('移动成功');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 笔记操作
|
||
* @desc 笔记的增删改查,支持note_type区分笔记/清单/摘抄,支持关联内容
|
||
* @lastUpdate v7.9.0 新增source_type/source_id关联内容字段
|
||
*/
|
||
public function note()
|
||
{
|
||
$this->checkRateLimit('note');
|
||
$userId = $this->auth->id;
|
||
$action = trim($this->request->param('action', 'list'));
|
||
|
||
if ($action == 'list') {
|
||
$page = $this->request->param('page', 1, 'intval');
|
||
$limit = $this->request->param('limit', 15, 'intval');
|
||
$limit = min(max($limit, 1), 50);
|
||
$noteType = trim($this->request->param('note_type', ''));
|
||
$sourceType = trim($this->request->param('source_type', ''));
|
||
$sourceId = $this->request->param('source_id', 0, 'intval');
|
||
$noteWhere = function() use ($userId, $noteType, $sourceType, $sourceId) {
|
||
$q = db('user_note')->where('user_id', $userId)->where('status', 'normal');
|
||
if ($noteType) $q->where('note_type', $noteType);
|
||
if ($sourceType) $q->where('source_type', $sourceType);
|
||
if ($sourceId) $q->where('source_id', $sourceId);
|
||
return $q;
|
||
};
|
||
$total = $noteWhere()->count();
|
||
$list = $noteWhere()->page($page, $limit)->order('updatetime', 'desc')
|
||
->field('id,title,content,category,note_type,source_type,source_id,is_public,views,status,createtime,updatetime')
|
||
->select();
|
||
$this->success('', ['list' => $list, 'total' => $total]);
|
||
} elseif ($action == 'add') {
|
||
$title = $this->request->post('title', '', 'trim');
|
||
$content = $this->request->post('content', '', 'trim');
|
||
$category = $this->request->post('category', '', 'trim');
|
||
$isPublic = $this->request->post('is_public', 0, 'intval');
|
||
$noteType = $this->request->post('note_type', 'note', 'trim');
|
||
$sourceType = $this->request->post('source_type', '', 'trim');
|
||
$sourceId = $this->request->post('source_id', 0, 'intval');
|
||
|
||
if (!$title) {
|
||
$this->error('标题不能为空');
|
||
}
|
||
$this->validateLength($title, '标题', 1, 100);
|
||
$this->detectMaliciousInput($title);
|
||
|
||
$allowedNoteTypes = ['note', 'checklist', 'excerpt'];
|
||
if (!in_array($noteType, $allowedNoteTypes)) {
|
||
$noteType = 'note';
|
||
}
|
||
|
||
$noteLimit = db('user')->where('id', $userId)->value('note_limit') ?: 50;
|
||
$noteCount = db('user_note')->where('user_id', $userId)->where('status', 'normal')->count();
|
||
if ($noteCount >= $noteLimit) {
|
||
$this->error('笔记数量已达上限(' . $noteLimit . '条)');
|
||
}
|
||
|
||
$data = [
|
||
'user_id' => $userId,
|
||
'title' => $title,
|
||
'content' => $content,
|
||
'category' => $category ?: '默认',
|
||
'note_type' => $noteType,
|
||
'source_type' => $sourceType,
|
||
'source_id' => $sourceId,
|
||
'is_public' => $isPublic ? 1 : 0,
|
||
'status' => 'normal',
|
||
'createtime' => time(),
|
||
'updatetime' => time(),
|
||
];
|
||
$insertId = db('user_note')->insertGetId($data);
|
||
$this->success('添加成功', ['id' => $insertId]);
|
||
} elseif ($action == 'edit') {
|
||
$id = $this->request->post('id', 0, 'intval');
|
||
if (!$id) {
|
||
$this->error('参数错误');
|
||
}
|
||
$note = db('user_note')->where('id', $id)->where('user_id', $userId)->where('status', 'normal')->find();
|
||
if (!$note) {
|
||
$this->error('笔记不存在或已删除');
|
||
}
|
||
$update = ['updatetime' => time()];
|
||
$title = $this->request->post('title', '', 'trim');
|
||
$content = $this->request->post('content', '', 'trim');
|
||
$category = $this->request->post('category', '', 'trim');
|
||
$isPublic = $this->request->post('is_public');
|
||
$noteType = $this->request->post('note_type', '', 'trim');
|
||
$sourceType = $this->request->post('source_type', '', 'trim');
|
||
$sourceId = $this->request->post('source_id');
|
||
if ($title) {
|
||
$this->validateLength($title, '标题', 1, 100);
|
||
$this->detectMaliciousInput($title);
|
||
$update['title'] = $title;
|
||
}
|
||
if ($content !== null) {
|
||
$update['content'] = $content;
|
||
}
|
||
if ($category !== null) {
|
||
$update['category'] = $category;
|
||
}
|
||
if ($isPublic !== null) {
|
||
$update['is_public'] = $isPublic ? 1 : 0;
|
||
}
|
||
if ($noteType && in_array($noteType, ['note', 'checklist', 'excerpt'])) {
|
||
$update['note_type'] = $noteType;
|
||
}
|
||
if ($sourceType !== null) {
|
||
$update['source_type'] = $sourceType;
|
||
}
|
||
if ($sourceId !== null) {
|
||
$update['source_id'] = intval($sourceId);
|
||
}
|
||
db('user_note')->where('id', $id)->update($update);
|
||
$this->success('修改成功');
|
||
} elseif ($action == 'delete') {
|
||
$id = $this->request->post('id', 0, 'intval');
|
||
if (!$id) {
|
||
$this->error('参数错误');
|
||
}
|
||
db('user_note')->where('id', $id)->where('user_id', $userId)->update(['status' => 'hidden', 'updatetime' => time()]);
|
||
$this->success('删除成功');
|
||
} else {
|
||
$this->error('无效操作');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 通用互动操作
|
||
* @desc 支持所有互动类型,零新增表扩展,含IP记录
|
||
* @lastUpdate v7.9.0 增加ip记录,新增search/bookmark/collect操作
|
||
*/
|
||
public function interaction()
|
||
{
|
||
$this->checkRateLimit('interaction');
|
||
$userId = $this->auth->id;
|
||
$action = trim($this->request->param('action', ''));
|
||
$targetId = $this->request->param('target_id/d', 0);
|
||
$targetType = trim($this->request->param('target_type', ''));
|
||
$extra = trim($this->request->param('extra', ''));
|
||
$clientIp = $this->request->ip();
|
||
|
||
$allowedActions = [
|
||
'like', 'dislike', 'readlater', 'share', 'block',
|
||
'view', 'rating', 'comment',
|
||
'notify', 'tag', 'progress', 'preference',
|
||
'search', 'bookmark', 'collect',
|
||
'check', 'history', 'counts'
|
||
];
|
||
if (!in_array($action, $allowedActions)) {
|
||
$this->error('无效的操作类型,支持: ' . implode('/', $allowedActions));
|
||
}
|
||
|
||
$allowedTypes = ['article', 'tool', 'poetry', 'cy', 'story', 'wisdom', 'hanzi', 'chengyu', 'cidian', 'saying', 'joke', 'feed', 'page', 'user'];
|
||
$noTargetRequired = ['history', 'counts', 'notify', 'preference', 'search'];
|
||
if (!in_array($targetType, $allowedTypes) && !in_array($action, $noTargetRequired)) {
|
||
$this->error('无效的内容类型');
|
||
}
|
||
|
||
$requireTarget = ['like', 'dislike', 'readlater', 'share', 'block', 'view', 'rating', 'comment', 'check', 'tag', 'progress', 'bookmark', 'collect'];
|
||
if (in_array($action, $requireTarget)) {
|
||
if (!$targetId || !$targetType) {
|
||
$this->error('参数错误: target_id和target_type必填');
|
||
}
|
||
}
|
||
|
||
if ($action === 'check') {
|
||
$interactions = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->where('feed_id', $targetId)
|
||
->where('feed_type', $targetType)
|
||
->column('action');
|
||
$this->success('', ['actions' => $interactions]);
|
||
return;
|
||
}
|
||
|
||
if ($action === 'counts') {
|
||
if ($targetId && $targetType) {
|
||
$counts = db('feed_interaction')
|
||
->where('feed_id', $targetId)
|
||
->where('feed_type', $targetType)
|
||
->group('action')
|
||
->field('action, count(*) as count')
|
||
->select();
|
||
$this->success('', ['counts' => $counts]);
|
||
} else {
|
||
$myCounts = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->group('action')
|
||
->field('action, count(*) as count')
|
||
->select();
|
||
$this->success('', ['my_counts' => $myCounts]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($action === 'history') {
|
||
$page = $this->request->param('page', 1, 'intval');
|
||
$limit = $this->request->param('limit', 15, 'intval');
|
||
$limit = min(max($limit, 1), 50);
|
||
$filterAction = trim($this->request->param('filter_action', ''));
|
||
$filterType = trim($this->request->param('filter_type', ''));
|
||
$historyWhere = function() use ($userId, $filterAction, $filterType) {
|
||
$q = db('feed_interaction')->where('user_id', $userId);
|
||
if ($filterAction) $q->where('action', $filterAction);
|
||
if ($filterType) $q->where('feed_type', $filterType);
|
||
return $q;
|
||
};
|
||
$total = $historyWhere()->count();
|
||
$list = $historyWhere()->page($page, $limit)->order('createtime', 'desc')->select();
|
||
$this->success('', ['list' => $list, 'total' => $total]);
|
||
return;
|
||
}
|
||
|
||
$toggleActions = ['like', 'dislike', 'readlater', 'share', 'block', 'bookmark', 'collect'];
|
||
if (in_array($action, $toggleActions)) {
|
||
$where = ['user_id' => $userId, 'feed_id' => $targetId, 'feed_type' => $targetType, 'action' => $action];
|
||
$exists = db('feed_interaction')->where($where)->find();
|
||
if ($exists) {
|
||
db('feed_interaction')->where($where)->delete();
|
||
$this->success('取消' . $this->actionLabel($action), ['status' => 'removed', 'id' => $exists['id']]);
|
||
} else {
|
||
$where['createtime'] = time();
|
||
$where['ip'] = $clientIp;
|
||
if ($extra) {
|
||
$where['extra'] = $extra;
|
||
}
|
||
$insertId = db('feed_interaction')->insertGetId($where);
|
||
$this->success($this->actionLabel($action) . '成功', ['status' => 'added', 'id' => $insertId]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($action === 'view') {
|
||
$where = ['user_id' => $userId, 'feed_id' => $targetId, 'feed_type' => $targetType, 'action' => 'view'];
|
||
$today = date('Y-m-d');
|
||
$exists = db('feed_interaction')->where($where)->whereTime('createtime', 'between', [strtotime($today), strtotime($today . ' 23:59:59')])->find();
|
||
if (!$exists) {
|
||
$where['createtime'] = time();
|
||
$where['ip'] = $clientIp;
|
||
if ($extra) {
|
||
$where['extra'] = $extra;
|
||
}
|
||
db('feed_interaction')->insert($where);
|
||
}
|
||
$tableMap = ['article' => 'article', 'poetry' => 'poetry', 'story' => 'story', 'wisdom' => 'wisdom'];
|
||
if (isset($tableMap[$targetType])) {
|
||
try { db($tableMap[$targetType])->where('id', $targetId)->setInc('views'); } catch (\Exception $e) {}
|
||
}
|
||
db('user')->where('id', $userId)->update(['last_active_time' => time()]);
|
||
$this->success('浏览记录', ['status' => 'recorded']);
|
||
return;
|
||
}
|
||
|
||
if ($action === 'rating') {
|
||
$score = intval($extra);
|
||
if ($score < 1 || $score > 5) {
|
||
$this->error('评分须在1-5之间');
|
||
}
|
||
$where = ['user_id' => $userId, 'feed_id' => $targetId, 'feed_type' => $targetType, 'action' => 'rating'];
|
||
$exists = db('feed_interaction')->where($where)->find();
|
||
if ($exists) {
|
||
db('feed_interaction')->where($where)->update(['extra' => strval($score), 'createtime' => time(), 'ip' => $clientIp]);
|
||
$this->success('评分已更新', ['score' => $score]);
|
||
} else {
|
||
$where['extra'] = strval($score);
|
||
$where['createtime'] = time();
|
||
$where['ip'] = $clientIp;
|
||
db('feed_interaction')->insert($where);
|
||
$this->success('评分成功', ['score' => $score]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($action === 'comment') {
|
||
if (!$extra) {
|
||
$this->error('评论内容不能为空');
|
||
}
|
||
$this->validateLength($extra, '评论', 1, 1000);
|
||
$this->detectMaliciousInput($extra);
|
||
$data = [
|
||
'user_id' => $userId,
|
||
'feed_id' => $targetId,
|
||
'feed_type' => $targetType,
|
||
'action' => 'comment',
|
||
'extra' => $this->sanitizeString($extra, 1000),
|
||
'ip' => $clientIp,
|
||
'createtime' => time(),
|
||
];
|
||
$commentId = db('feed_interaction')->insertGetId($data);
|
||
$this->success('评论成功', ['id' => $commentId]);
|
||
return;
|
||
}
|
||
|
||
if ($action === 'notify') {
|
||
$notifyType = trim($this->request->param('notify_type', 'system'));
|
||
$allowedNotifyTypes = ['system', 'interaction', 'custom'];
|
||
if (!in_array($notifyType, $allowedNotifyTypes)) {
|
||
$notifyType = 'system';
|
||
}
|
||
$data = [
|
||
'user_id' => $userId,
|
||
'feed_id' => $targetId ?: 0,
|
||
'feed_type' => $targetType ?: 'system',
|
||
'action' => 'notify',
|
||
'extra' => json_encode([
|
||
'notify_type' => $notifyType,
|
||
'content' => $this->sanitizeString($extra, 500),
|
||
'is_read' => 0,
|
||
]),
|
||
'ip' => $clientIp,
|
||
'createtime' => time(),
|
||
];
|
||
db('feed_interaction')->insert($data);
|
||
$this->success('通知已记录');
|
||
return;
|
||
}
|
||
|
||
if ($action === 'tag') {
|
||
if (!$extra) {
|
||
$this->error('标签名不能为空');
|
||
}
|
||
$this->validateLength($extra, '标签', 1, 30);
|
||
$tagName = $this->sanitizeString($extra, 30);
|
||
$where = ['user_id' => $userId, 'feed_id' => $targetId, 'feed_type' => $targetType, 'action' => 'tag', 'extra' => $tagName];
|
||
$exists = db('feed_interaction')->where($where)->find();
|
||
if ($exists) {
|
||
db('feed_interaction')->where($where)->delete();
|
||
$this->success('标签已移除', ['status' => 'removed']);
|
||
} else {
|
||
$where['createtime'] = time();
|
||
$where['ip'] = $clientIp;
|
||
db('feed_interaction')->insert($where);
|
||
$this->success('标签已添加', ['status' => 'added']);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($action === 'progress') {
|
||
$progress = intval($extra);
|
||
if ($progress < 0 || $progress > 100) {
|
||
$this->error('进度须在0-100之间');
|
||
}
|
||
$where = ['user_id' => $userId, 'feed_id' => $targetId, 'feed_type' => $targetType, 'action' => 'progress'];
|
||
$exists = db('feed_interaction')->where($where)->find();
|
||
if ($exists) {
|
||
db('feed_interaction')->where($where)->update(['extra' => strval($progress), 'createtime' => time(), 'ip' => $clientIp]);
|
||
$this->success('进度已更新', ['progress' => $progress]);
|
||
} else {
|
||
$where['extra'] = strval($progress);
|
||
$where['createtime'] = time();
|
||
$where['ip'] = $clientIp;
|
||
db('feed_interaction')->insert($where);
|
||
$this->success('进度已记录', ['progress' => $progress]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($action === 'preference') {
|
||
if (!$extra) {
|
||
$this->error('偏好数据不能为空');
|
||
}
|
||
$prefData = $extra;
|
||
if (mb_strlen($prefData) > 2000) {
|
||
$this->error('偏好数据过长');
|
||
}
|
||
$where = ['user_id' => $userId, 'feed_id' => 0, 'feed_type' => 'user', 'action' => 'preference'];
|
||
$exists = db('feed_interaction')->where($where)->find();
|
||
if ($exists) {
|
||
db('feed_interaction')->where($where)->update(['extra' => $prefData, 'createtime' => time(), 'ip' => $clientIp]);
|
||
$this->success('偏好已更新');
|
||
} else {
|
||
$where['extra'] = $prefData;
|
||
$where['createtime'] = time();
|
||
$where['ip'] = $clientIp;
|
||
db('feed_interaction')->insert($where);
|
||
$this->success('偏好已保存');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($action === 'search') {
|
||
if (!$extra) {
|
||
$this->error('搜索关键词不能为空');
|
||
}
|
||
$this->validateLength($extra, '搜索词', 1, 100);
|
||
$keyword = $this->sanitizeString($extra, 100);
|
||
$where = ['user_id' => $userId, 'action' => 'search', 'extra' => $keyword];
|
||
$exists = db('feed_interaction')->where($where)->whereTime('createtime', '>=', strtotime('-1 hour'))->find();
|
||
if (!$exists) {
|
||
db('feed_interaction')->insert([
|
||
'user_id' => $userId,
|
||
'feed_id' => 0,
|
||
'feed_type' => $targetType ?: 'all',
|
||
'action' => 'search',
|
||
'extra' => $keyword,
|
||
'ip' => $clientIp,
|
||
'createtime' => time(),
|
||
]);
|
||
}
|
||
$this->success('搜索已记录');
|
||
return;
|
||
}
|
||
}
|
||
|
||
private function actionLabel($action)
|
||
{
|
||
$labels = [
|
||
'like' => '点赞',
|
||
'dislike' => '不喜欢',
|
||
'readlater' => '稍后读',
|
||
'share' => '分享',
|
||
'block' => '屏蔽',
|
||
];
|
||
return $labels[$action] ?? $action;
|
||
}
|
||
|
||
/**
|
||
* @name 点赞操作
|
||
* @desc 添加/移除/检查点赞
|
||
*/
|
||
public function like()
|
||
{
|
||
$this->checkRateLimit('like');
|
||
$userId = $this->auth->id;
|
||
$action = trim($this->request->post('action', 'add'));
|
||
$targetId = $this->request->param('target_id/d', 0);
|
||
$targetType = trim($this->request->param('target_type', 'article'));
|
||
|
||
$allowedTypes = ['article', 'tool', 'poetry', 'cy', 'story', 'wisdom', 'hanzi', 'saying', 'feed'];
|
||
if (!in_array($targetType, $allowedTypes)) {
|
||
$this->error('无效的类型');
|
||
}
|
||
if (!$targetId) {
|
||
$this->error('参数错误');
|
||
}
|
||
|
||
$where = ['user_id' => $userId, 'feed_id' => $targetId, 'feed_type' => $targetType, 'action' => 'like'];
|
||
|
||
if ($action === 'add') {
|
||
$exists = db('feed_interaction')->where($where)->find();
|
||
if ($exists) {
|
||
$this->error('已点赞');
|
||
}
|
||
$where['createtime'] = time();
|
||
db('feed_interaction')->insert($where);
|
||
|
||
$tableMap = ['article' => 'article', 'poetry' => 'poetry', 'cy' => 'cy', 'story' => 'story', 'wisdom' => 'wisdom'];
|
||
if (isset($tableMap[$targetType])) {
|
||
try { db($tableMap[$targetType])->where('id', $targetId)->setInc('likes'); } catch (\Exception $e) {}
|
||
}
|
||
$this->success('点赞成功');
|
||
} elseif ($action === 'remove') {
|
||
db('feed_interaction')->where($where)->delete();
|
||
$tableMap = ['article' => 'article', 'poetry' => 'poetry', 'cy' => 'cy', 'story' => 'story', 'wisdom' => 'wisdom'];
|
||
if (isset($tableMap[$targetType])) {
|
||
try { db($tableMap[$targetType])->where('id', $targetId)->setDec('likes'); } catch (\Exception $e) {}
|
||
}
|
||
$this->success('取消点赞');
|
||
} elseif ($action === 'check') {
|
||
$exists = db('feed_interaction')->where($where)->find();
|
||
$this->success('', ['is_liked' => !empty($exists)]);
|
||
} else {
|
||
$this->error('无效操作');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 用户公开主页
|
||
* @desc 获取用户公开信息(无需登录)
|
||
* @lastUpdate v9.0.0 avatar优先使用avatar_url,新增vip状态
|
||
*/
|
||
public function public_profile()
|
||
{
|
||
$uid = $this->request->param('uid', 0, 'intval');
|
||
if (!$uid) {
|
||
$uid = $this->request->param('user_id', 0, 'intval');
|
||
}
|
||
if (!$uid) {
|
||
$this->error('参数错误: uid或user_id必填');
|
||
}
|
||
|
||
$user = db('user')->where('id', $uid)->where('status', 'normal')->find();
|
||
if (!$user) {
|
||
$this->error('用户不存在');
|
||
}
|
||
|
||
$articleCount = db('article')->where('user_id', $uid)->where('status', 'normal')->count();
|
||
$signinDays = $user['signin_days'] ?: 0;
|
||
$favoriteCount = db('user_favorite')->where('user_id', $uid)->count();
|
||
$likeCount = db('feed_interaction')->where('user_id', $uid)->where('action', 'like')->count();
|
||
|
||
$avatarUrl = isset($user['avatar_url']) ? trim($user['avatar_url']) : '';
|
||
$avatar = $avatarUrl ?: ($user['avatar'] ? cdnurl($user['avatar'], true) : '');
|
||
|
||
$vipEndTime = isset($user['vip_end_time']) ? intval($user['vip_end_time']) : 0;
|
||
|
||
$data = [
|
||
'id' => $user['id'],
|
||
'username' => $user['username'],
|
||
'nickname' => $user['nickname'],
|
||
'avatar' => $avatar,
|
||
'bio' => $user['bio'] ?? '',
|
||
'score' => $user['score'],
|
||
'signin_days' => $signinDays,
|
||
'article_count' => $articleCount,
|
||
'favorite_count' => $favoriteCount,
|
||
'like_count' => $likeCount,
|
||
'is_vip' => $vipEndTime > time(),
|
||
'jointime' => $user['jointime'] ? date('Y-m-d', intval($user['jointime'])) : '-',
|
||
];
|
||
|
||
$this->success('成功', $data);
|
||
}
|
||
|
||
/**
|
||
* @name 数据面板
|
||
* @desc 用户个人数据可视化统计
|
||
*/
|
||
public function dashboard()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$user = db('user')->where('id', $userId)->find();
|
||
|
||
$signinCount = db('user_signin')->where('user_id', $userId)->count();
|
||
$favoriteCount = db('user_favorite')->where('user_id', $userId)->count();
|
||
$noteCount = db('user_note')->where('user_id', $userId)->where('status', 'normal')->count();
|
||
$likeCount = db('feed_interaction')->where('user_id', $userId)->where('action', 'like')->count();
|
||
$articleCount = db('article')->where('user_id', $userId)->where('status', 'normal')->count();
|
||
$commentCount = db('feed_interaction')->where('user_id', $userId)->where('action', 'comment')->count();
|
||
$viewCount = db('feed_interaction')->where('user_id', $userId)->where('action', 'view')->count();
|
||
$readlaterCount = db('feed_interaction')->where('user_id', $userId)->where('action', 'readlater')->count();
|
||
|
||
$last7Days = [];
|
||
for ($i = 6; $i >= 0; $i--) {
|
||
$date = date('Y-m-d', strtotime("-{$i} days"));
|
||
$signin = db('user_signin')->where('user_id', $userId)->where('date', $date)->find();
|
||
$last7Days[] = [
|
||
'date' => $date,
|
||
'signed' => !empty($signin),
|
||
'coin_reward' => $signin ? $signin['coin_reward'] : 0,
|
||
];
|
||
}
|
||
|
||
$data = [
|
||
'user_id' => $userId,
|
||
'score' => $user['score'],
|
||
'signin_days' => $user['signin_days'] ?: 0,
|
||
'signin_count' => $signinCount,
|
||
'favorite_count' => $favoriteCount,
|
||
'note_count' => $noteCount,
|
||
'like_count' => $likeCount,
|
||
'comment_count' => $commentCount,
|
||
'view_count' => $viewCount,
|
||
'readlater_count' => $readlaterCount,
|
||
'article_count' => $articleCount,
|
||
'last_7days' => $last7Days,
|
||
];
|
||
|
||
$this->success('', $data);
|
||
}
|
||
|
||
/**
|
||
* @name 活跃热力图
|
||
* @desc 用户活跃度热力图数据
|
||
*/
|
||
public function heatmap()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$year = trim($this->request->param('year', date('Y')));
|
||
if (!preg_match('/^\d{4}$/', $year)) {
|
||
$this->error('年份格式错误');
|
||
}
|
||
|
||
$startDate = strtotime($year . '-01-01');
|
||
$endDate = strtotime($year . '-12-31 23:59:59');
|
||
|
||
$signins = db('user_signin')
|
||
->where('user_id', $userId)
|
||
->where('createtime', 'between', [$startDate, $endDate])
|
||
->column('date');
|
||
|
||
$favorites = db('user_favorite')
|
||
->where('user_id', $userId)
|
||
->where('createtime', 'between', [$startDate, $endDate])
|
||
->column("FROM_UNIXTIME(createtime, '%Y-%m-%d') as date");
|
||
|
||
$heatmap = [];
|
||
$activityMap = [];
|
||
|
||
foreach ($signins as $d) {
|
||
$activityMap[$d] = ($activityMap[$d] ?? 0) + 1;
|
||
}
|
||
foreach ($favorites as $d) {
|
||
$activityMap[$d] = ($activityMap[$d] ?? 0) + 1;
|
||
}
|
||
|
||
foreach ($activityMap as $date => $count) {
|
||
$heatmap[] = ['date' => $date, 'count' => $count];
|
||
}
|
||
|
||
$this->success('', ['year' => $year, 'heatmap' => $heatmap]);
|
||
}
|
||
|
||
/**
|
||
* @name 金币记录
|
||
* @desc 获取金币变动记录
|
||
*/
|
||
public function coin()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$page = $this->request->param('page', 1, 'intval');
|
||
$limit = $this->request->param('limit', 15, 'intval');
|
||
$limit = min(max($limit, 1), 50);
|
||
|
||
$total = db('coin_log')->where('user_id', $userId)->count();
|
||
$list = db('coin_log')
|
||
->where('user_id', $userId)
|
||
->page($page, $limit)
|
||
->order('createtime', 'desc')
|
||
->field('id,coin_type,amount,before,after,action,remark,createtime')
|
||
->select();
|
||
|
||
$this->success('', ['list' => $list, 'total' => $total]);
|
||
}
|
||
|
||
/**
|
||
* @name 学习统计
|
||
* @desc 基于互动数据生成学习统计和用户画像
|
||
* @lastUpdate v7.8.0 新增
|
||
*/
|
||
public function stats()
|
||
{
|
||
$this->checkRateLimit('stats');
|
||
$userId = $this->auth->id;
|
||
|
||
$typeStats = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->where('action', 'in', ['view', 'like', 'rating', 'comment', 'readlater', 'share'])
|
||
->group('feed_type')
|
||
->field('feed_type, count(*) as count')
|
||
->order('count', 'desc')
|
||
->select();
|
||
|
||
$actionStats = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->group('action')
|
||
->field('action, count(*) as count')
|
||
->order('count', 'desc')
|
||
->select();
|
||
|
||
$topRated = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->where('action', 'rating')
|
||
->order('extra', 'desc')
|
||
->limit(10)
|
||
->field('feed_id, feed_type, extra as score, createtime')
|
||
->select();
|
||
|
||
$recentViews = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->where('action', 'view')
|
||
->order('createtime', 'desc')
|
||
->limit(10)
|
||
->field('feed_id, feed_type, createtime')
|
||
->select();
|
||
|
||
$totalViews = db('feed_interaction')->where('user_id', $userId)->where('action', 'view')->count();
|
||
$totalLikes = db('feed_interaction')->where('user_id', $userId)->where('action', 'like')->count();
|
||
$totalComments = db('feed_interaction')->where('user_id', $userId)->where('action', 'comment')->count();
|
||
$totalRatings = db('feed_interaction')->where('user_id', $userId)->where('action', 'rating')->count();
|
||
$avgRating = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->where('action', 'rating')
|
||
->avg('extra');
|
||
|
||
$weekStart = strtotime('-7 days');
|
||
$weekViews = db('feed_interaction')->where('user_id', $userId)->where('action', 'view')->where('createtime', '>=', $weekStart)->count();
|
||
$weekLikes = db('feed_interaction')->where('user_id', $userId)->where('action', 'like')->where('createtime', '>=', $weekStart)->count();
|
||
|
||
$preference = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->where('action', 'preference')
|
||
->value('extra');
|
||
|
||
$tags = db('feed_interaction')
|
||
->where('user_id', $userId)
|
||
->where('action', 'tag')
|
||
->group('extra')
|
||
->field('extra as tag, count(*) as count')
|
||
->order('count', 'desc')
|
||
->limit(20)
|
||
->select();
|
||
|
||
$data = [
|
||
'type_stats' => $typeStats,
|
||
'action_stats' => $actionStats,
|
||
'top_rated' => $topRated,
|
||
'recent_views' => $recentViews,
|
||
'summary' => [
|
||
'total_views' => $totalViews,
|
||
'total_likes' => $totalLikes,
|
||
'total_comments' => $totalComments,
|
||
'total_ratings' => $totalRatings,
|
||
'avg_rating' => round(floatval($avgRating), 1),
|
||
'week_views' => $weekViews,
|
||
'week_likes' => $weekLikes,
|
||
],
|
||
'preference' => $preference ? json_decode($preference, true) : null,
|
||
'tags' => $tags,
|
||
];
|
||
|
||
$this->success('', $data);
|
||
}
|
||
|
||
/**
|
||
* @name 调试信息
|
||
* @desc 获取用户调试信息(开发模式)
|
||
* @lastUpdate v9.0.0 新增vip/cloud_space/online字段
|
||
*/
|
||
public function debug()
|
||
{
|
||
$user = $this->auth->getUser();
|
||
$lastActiveTime = isset($user->last_active_time) ? intval($user->last_active_time) : 0;
|
||
$data = [
|
||
'id' => $user->id,
|
||
'username' => $user->username,
|
||
'email' => $user->email,
|
||
'mobile' => $user->mobile,
|
||
'score' => $user->score,
|
||
'status' => $user->status,
|
||
'is_test' => $user->is_test ?? 0,
|
||
'verification' => $user->verification,
|
||
'jointime' => $user->jointime,
|
||
'last_signin_date' => $user->last_signin_date,
|
||
'signin_days' => $user->signin_days,
|
||
'is_online' => (time() - $lastActiveTime < 300) ? 1 : 0,
|
||
'last_active_time' => $lastActiveTime,
|
||
'vip_start_time' => isset($user->vip_start_time) ? intval($user->vip_start_time) : 0,
|
||
'vip_end_time' => isset($user->vip_end_time) ? intval($user->vip_end_time) : 0,
|
||
'cloud_space_total' => isset($user->cloud_space_total) ? intval($user->cloud_space_total) : 819200,
|
||
'cloud_space_used' => isset($user->cloud_space_used) ? intval($user->cloud_space_used) : 0,
|
||
'avatar_url' => isset($user->avatar_url) ? $user->avatar_url : '',
|
||
];
|
||
$this->success('', $data);
|
||
}
|
||
|
||
/**
|
||
* @name 设备管理
|
||
* @desc 查看/删除/下线用户设备
|
||
* @lastUpdate v9.0.0 新增
|
||
*/
|
||
public function devices()
|
||
{
|
||
$this->checkRateLimit('devices');
|
||
$userId = $this->auth->id;
|
||
$action = trim($this->request->param('action', 'list'));
|
||
|
||
if ($action === 'list') {
|
||
$devices = db('user_device')
|
||
->where('user_id', $userId)
|
||
->order('last_active_time', 'desc')
|
||
->field('id,device_name,device_model,platform,app_name,ip,ip_city,ip_range,last_active_time,is_online,createtime')
|
||
->select();
|
||
foreach ($devices as &$d) {
|
||
$d['last_active_text'] = $d['last_active_time'] ? date('Y-m-d H:i:s', $d['last_active_time']) : '-';
|
||
$d['createtime_text'] = $d['createtime'] ? date('Y-m-d H:i:s', $d['createtime']) : '-';
|
||
}
|
||
unset($d);
|
||
$this->success('', ['devices' => $devices]);
|
||
} elseif ($action === 'remove') {
|
||
$deviceId = $this->request->param('device_id/d', 0);
|
||
if (!$deviceId) {
|
||
$this->error('device_id必填');
|
||
}
|
||
$device = db('user_device')->where('id', $deviceId)->where('user_id', $userId)->find();
|
||
if (!$device) {
|
||
$this->error('设备不存在');
|
||
}
|
||
db('user_device')->where('id', $deviceId)->delete();
|
||
$this->success('设备已移除');
|
||
} elseif ($action === 'offline') {
|
||
$deviceId = $this->request->param('device_id/d', 0);
|
||
if (!$deviceId) {
|
||
$this->error('device_id必填');
|
||
}
|
||
$device = db('user_device')->where('id', $deviceId)->where('user_id', $userId)->find();
|
||
if (!$device) {
|
||
$this->error('设备不存在');
|
||
}
|
||
db('user_device')->where('id', $deviceId)->update(['is_online' => 0, 'updatetime' => time()]);
|
||
$this->success('设备已下线');
|
||
} elseif ($action === 'offline_all') {
|
||
db('user_device')->where('user_id', $userId)->update(['is_online' => 0, 'updatetime' => time()]);
|
||
$this->success('所有设备已下线');
|
||
} elseif ($action === 'rename') {
|
||
$deviceId = $this->request->param('device_id/d', 0);
|
||
$deviceName = trim($this->request->param('device_name', ''));
|
||
if (!$deviceId) {
|
||
$this->error('device_id必填');
|
||
}
|
||
if (!$deviceName) {
|
||
$this->error('device_name必填');
|
||
}
|
||
$this->validateLength($deviceName, '设备名称', 1, 100);
|
||
$device = db('user_device')->where('id', $deviceId)->where('user_id', $userId)->find();
|
||
if (!$device) {
|
||
$this->error('设备不存在');
|
||
}
|
||
db('user_device')->where('id', $deviceId)->update([
|
||
'device_name' => $deviceName,
|
||
'updatetime' => time(),
|
||
]);
|
||
$this->success('设备已重命名');
|
||
} else {
|
||
$this->error('无效操作,支持: list/remove/offline/offline_all/rename');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 记录或更新设备信息
|
||
* @desc 登录后调用,记录当前设备信息
|
||
* @lastUpdate v9.0.0 新增
|
||
*/
|
||
public function registerDevice()
|
||
{
|
||
$this->checkRateLimit('devices');
|
||
$userId = $this->auth->id;
|
||
$deviceName = $this->request->post('device_name', '', 'trim');
|
||
$deviceModel = $this->request->post('device_model', '', 'trim');
|
||
$platform = $this->request->post('platform', '', 'trim');
|
||
$appName = $this->request->post('app_name', '', 'trim');
|
||
$deviceId = $this->request->post('device_id', '', 'trim');
|
||
$ipCity = $this->request->post('ip_city', '', 'trim');
|
||
$ipRange = $this->request->post('ip_range', '', 'trim');
|
||
$userAgent = $this->request->header('user-agent', '');
|
||
|
||
$this->validateLength($deviceName, '设备名称', 0, 100);
|
||
$this->validateLength($deviceModel, '设备型号', 0, 100);
|
||
$this->validateLength($platform, '登录平台', 0, 30);
|
||
$this->validateLength($appName, '应用名称', 0, 50);
|
||
$this->validateLength($ipCity, 'IP归属地', 0, 200);
|
||
$this->validateLength($ipRange, 'IP段范围', 0, 100);
|
||
|
||
$allowedPlatforms = ['ios', 'android', 'web', 'windows', 'mac', 'linux', 'other'];
|
||
if ($platform && !in_array(strtolower($platform), $allowedPlatforms)) {
|
||
$platform = 'other';
|
||
}
|
||
|
||
$ip = $this->request->ip();
|
||
$now = time();
|
||
|
||
if ($deviceId) {
|
||
$exists = db('user_device')->where('user_id', $userId)->where('device_id', $deviceId)->find();
|
||
if ($exists) {
|
||
$updateData = [
|
||
'device_name' => $deviceName ?: $exists['device_name'],
|
||
'device_model' => $deviceModel ?: $exists['device_model'],
|
||
'platform' => $platform ?: $exists['platform'],
|
||
'app_name' => $appName ?: $exists['app_name'],
|
||
'ip' => $ip,
|
||
'ip_city' => $ipCity ?: ($exists['ip_city'] ?? ''),
|
||
'ip_range' => $ipRange ?: ($exists['ip_range'] ?? ''),
|
||
'last_active_time' => $now,
|
||
'is_online' => 1,
|
||
'user_agent' => substr($userAgent, 0, 500),
|
||
'updatetime' => $now,
|
||
];
|
||
db('user_device')->where('id', $exists['id'])->update($updateData);
|
||
$this->success('设备已更新', ['device_id' => $exists['id']]);
|
||
}
|
||
}
|
||
|
||
$insertId = db('user_device')->insertGetId([
|
||
'user_id' => $userId,
|
||
'device_name' => $deviceName,
|
||
'device_model' => $deviceModel,
|
||
'platform' => $platform,
|
||
'app_name' => $appName,
|
||
'ip' => $ip,
|
||
'ip_city' => $ipCity,
|
||
'ip_range' => $ipRange,
|
||
'device_id' => $deviceId ?: md5($userId . $ip . $userAgent . $now),
|
||
'last_active_time' => $now,
|
||
'is_online' => 1,
|
||
'user_agent' => substr($userAgent, 0, 500),
|
||
'createtime' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
|
||
db('user')->where('id', $userId)->update([
|
||
'last_active_time' => $now,
|
||
'is_online' => 1,
|
||
]);
|
||
|
||
$this->success('设备已注册', ['device_id' => $insertId]);
|
||
}
|
||
|
||
/**
|
||
* @name 获取同账号在线设备(互传用)
|
||
* @desc 返回当前用户所有在线设备,排除当前设备
|
||
* @lastUpdate v10.0.0 新增
|
||
*/
|
||
public function myDevices()
|
||
{
|
||
$this->checkRateLimit('devices');
|
||
$userId = $this->auth->id;
|
||
$currentDeviceId = $this->request->param('device_id', '', 'trim');
|
||
|
||
$devices = db('user_device')
|
||
->where('user_id', $userId)
|
||
->where('is_online', 1)
|
||
->order('last_active_time', 'desc')
|
||
->field('id,device_id,device_name,device_model,platform,app_name,ip,ip_city,ip_range,last_active_time,is_online')
|
||
->select();
|
||
|
||
$this->success('ok', [
|
||
'currentDeviceId' => $currentDeviceId,
|
||
'devices' => $devices,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @name EXP变动记录
|
||
* @desc 获取当前用户EXP变动日志,分页返回
|
||
* @lastUpdate v10.1.0 新增
|
||
*/
|
||
public function expLog()
|
||
{
|
||
$page = $this->request->param('page', 1, 'intval');
|
||
$pagesize = $this->request->param('pagesize', 20, 'intval');
|
||
$pagesize = min($pagesize, 50);
|
||
$userId = $this->auth->id;
|
||
|
||
$list = db('user_exp_log')
|
||
->where('user_id', $userId)
|
||
->order('createtime', 'desc')
|
||
->page($page, $pagesize)
|
||
->select();
|
||
|
||
$total = db('user_exp_log')
|
||
->where('user_id', $userId)
|
||
->count();
|
||
|
||
$this->success('', [
|
||
'list' => $list,
|
||
'total' => $total,
|
||
'page' => $page,
|
||
'pagesize' => $pagesize,
|
||
]);
|
||
}
|
||
}
|