// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\wxchat\controller\api; use addon\wxchat\model\WxchatFollow; use think\facade\Db; use think\facade\Request; use ywxapp\controller\FrontendBase; use ywxapp\model\MemberUser as UserModel; use ywxapp\model\MemberProfile as ProfileModel; /** * Member 类 * * @author ywxapp */ class Member extends FrontendBase { protected $noNeedLogin = []; protected $noNeedVerify = ['*']; protected function initialize() {} /** * 当前用户交友档案 */ public function profile() { $uid = $this->auth->model->uid; $profile = ProfileModel::where('uid', $uid)->find(); if (! $profile) { $this->error('资料不存在'); } $this->success($profile); } /** * 查看他人公开资料(资料卡页用) * 返回:档案字段 + nickname/avatar/account + 计算后的 age + 是否关注 + 动态数 + 相册 */ public function detail() { $targetUid = (int) Request::get('uid'); if ($targetUid <= 0) { $this->error('参数错误'); } $profile = ProfileModel::where('uid', $targetUid)->find(); if (! $profile) { $this->error('用户不存在'); } $user = Db::name('member')->where('uid', $targetUid) ->field('uid,nickname,avatar,account,status')->find(); $pc = ! empty($profile->privacy_config) ? json_decode($profile->privacy_config, true) : []; $pc = is_array($pc) ? $pc : $this->defaultPrivacy(); $data = $profile->toArray(); $data['nickname'] = $user['nickname'] ?? ''; $data['avatar'] = $user['avatar'] ?? ''; $data['account'] = $user['account'] ?? ''; $data['age'] = $profile->birthday ? (int) floor((time() - strtotime($profile->birthday)) / 31557600) : 0; if (! empty($pc['hide_age'])) { unset($data['age']); } // 关注状态(基于 wxchat_follows) $meUid = $this->auth->model->uid; $followed = WxchatFollow::where('uid', $meUid) ->where('fid', $targetUid) ->where('status', 1) ->count(); $data['is_followed'] = $followed > 0 ? 1 : 0; $data['is_online'] = ($profile->online_status == 1) || (! empty($profile->last_active_at) && time() - (int) $profile->last_active_at < 300); // 动态数(表可能未初始化,容错) try { $momentCount = Db::name('wxchat_moments')->where('user_id', $targetUid)->count(); } catch (\Throwable $e) { $momentCount = 0; } $data['moment_count'] = $momentCount; // 照片墙:优先用 user.avatar(profile 表无头像列) $data['photos'] = ! empty($user['avatar']) ? [$user['avatar']] : []; $this->success($data); } /** * 更新交友档案(定位 / 同城 / 择偶条件) */ public function updateProfile() { $uid = $this->auth->model->uid; $allow = [ 'gender', 'birthday', 'latitude', 'longitude', 'geo_hash', 'resideprovince', 'residecity', 'residedist', 'height', 'weight', 'lookingfor', 'affectivestatus', 'education', 'occupation', 'revenue', 'bloodtype', 'signature', 'bio', 'address', ]; $data = Request::post(); $update = array_intersect_key($data, array_flip($allow)); if (! empty($update['birthday'])) { $update['constellation'] = $this->calcConstellation($update['birthday']); $update['zodiac'] = $this->calcZodiac($update['birthday']); } if (! empty($update['latitude']) && ! empty($update['longitude'])) { $update['geo_hash'] = $this->geoHash((float) $update['latitude'], (float) $update['longitude']); } ProfileModel::where('uid', $uid)->update($update); $this->success([], '更新成功'); } /** * 同城智能匹配(基于经纬度距离排序) */ public function nearby() { $uid = $this->auth->model->uid; $me = ProfileModel::where('uid', $uid)->field('latitude,longitude,residecity')->find(); $lat = (float) Request::get('lat', $me['latitude'] ?? 0); $lng = (float) Request::get('lng', $me['longitude'] ?? 0); $radius = (float) Request::get('radius', 10); $gender = (int) Request::get('gender', 0); $ageMin = (int) Request::get('age_min', 0); $ageMax = (int) Request::get('age_max', 0); $limit = (int) Request::get('limit', 20); $page = (int) Request::get('page', 1); if (! $lat || ! $lng) { $this->error('请先更新你的定位'); } $dist = "(6371 * acos(cos(radians({$lat})) * cos(radians(p.latitude)) " . "* cos(radians(p.longitude) - radians({$lng})) " . "+ sin(radians({$lat})) * sin(radians(p.latitude))))"; $query = Db::name('member_profile')->alias('p') ->join('user u', 'u.uid = p.uid') ->where('p.uid', '<>', $uid) ->whereNotNull('p.latitude') ->whereNotNull('p.longitude') ->where('p.latitude', '<>', 0) ->where('p.longitude', '<>', 0) ->field([ 'p.uid', 'u.nickname', 'u.avatar', 'p.gender', 'p.birthday', 'p.resideprovince', 'p.residecity', 'p.residedist', 'p.height', 'p.weight', 'p.bio', 'p.online_status', 'p.last_active_at', 'p.privacy_config', Db::raw("{$dist} AS distance"), ]); if ($gender) { $query->where('p.gender', $gender); } if ($ageMin || $ageMax) { $lo = max(1, $ageMin); $hi = max($lo, $ageMax ?: 100); $query->whereRaw('TIMESTAMPDIFF(YEAR, p.birthday, CURDATE()) BETWEEN ? AND ?', [$lo, $hi]); } $query->whereRaw("{$dist} <= ?", [$radius]); $query->order('distance', 'asc'); $list = $query->paginate($limit, false, ['page' => $page]); $this->success($this->formatUsers($list)); } /** * 本地同城(按居住城市匹配,无定位也能用) */ public function local() { $uid = $this->auth->model->uid; $me = ProfileModel::where('uid', $uid)->field('residecity,geo_hash')->find(); $city = Request::get('city', $me['residecity'] ?? ''); $gender = (int) Request::get('gender', 0); $ageMin = (int) Request::get('age_min', 0); $ageMax = (int) Request::get('age_max', 0); $limit = (int) Request::get('limit', 20); $page = (int) Request::get('page', 1); $query = Db::name('member_profile')->alias('p') ->join('user u', 'u.uid = p.uid') ->where('p.uid', '<>', $uid) ->field([ 'p.uid', 'u.nickname', 'u.avatar', 'p.gender', 'p.birthday', 'p.resideprovince', 'p.residecity', 'p.residedist', 'p.height', 'p.weight', 'p.bio', 'p.online_status', 'p.last_active_at', 'p.privacy_config', ]); if ($city) { $query->where('p.residecity', $city); } elseif (! empty($me['geo_hash'])) { $query->where('p.geo_hash', 'like', substr($me['geo_hash'], 0, 4) . '%'); } else { $this->success(['list' => [], 'total' => 0, 'pages' => 0]); return; } if ($gender) { $query->where('p.gender', $gender); } if ($ageMin || $ageMax) { $lo = max(1, $ageMin); $hi = max($lo, $ageMax ?: 100); $query->whereRaw('TIMESTAMPDIFF(YEAR, p.birthday, CURDATE()) BETWEEN ? AND ?', [$lo, $hi]); } $query->order('p.last_active_at', 'desc'); $list = $query->paginate($limit, false, ['page' => $page]); $this->success($this->formatUsers($list)); } /** * 24 小时在线列表(实时在线活跃用户) */ public function online() { $uid = $this->auth->model->uid; $gender = (int) Request::get('gender', 0); $limit = (int) Request::get('limit', 50); $page = (int) Request::get('page', 1); $query = Db::name('member_profile')->alias('p') ->join('user u', 'u.uid = p.uid') ->where('p.uid', '<>', $uid) ->where(function ($q) { $q->where('p.online_status', 1) ->whereOr('p.last_active_at', '>', time() - 300); }) ->field([ 'p.uid', 'u.nickname', 'u.avatar', 'p.gender', 'p.birthday', 'p.resideprovince', 'p.residecity', 'p.height', 'p.weight', 'p.bio', 'p.online_status', 'p.last_active_at', 'p.privacy_config', ]); if ($gender) { $query->where('p.gender', $gender); } $query->order('p.last_active_at', 'desc'); $list = $query->paginate($limit, false, ['page' => $page]); $this->success($this->formatUsers($list)); } /** * 心跳:上报在线状态 */ public function heartbeat() { $uid = $this->auth->model->uid; ProfileModel::where('uid', $uid)->update([ 'last_active_at' => time(), 'online_status' => 1, ]); $this->success(['online' => 1], 'ok'); } /** * 统一格式化用户列表(附带距离/年龄/在线) */ private function formatUsers($paginate) { $items = $paginate->items(); foreach ($items as &$it) { $it['age'] = $it['birthday'] ? (int) floor((time() - strtotime($it['birthday'])) / 31557600) : 0; $it['is_online'] = $it['online_status'] == 1 || (! empty($it['last_active_at']) && time() - (int) $it['last_active_at'] < 300); if (isset($it['distance'])) { $it['distance'] = round((float) $it['distance'], 2); } // 应用对方隐私设置:隐藏其年龄/在线/距离 $pc = ! empty($it['privacy_config']) ? json_decode($it['privacy_config'], true) : []; $pc = is_array($pc) ? $pc : []; if (! empty($pc['hide_age'])) { unset($it['age']); } if (! empty($pc['hide_online'])) { $it['is_online'] = false; } if (! empty($pc['hide_distance']) && isset($it['distance'])) { unset($it['distance']); } unset($it['online_status'], $it['last_active_at'], $it['birthday'], $it['privacy_config']); } return [ 'list' => $items, 'total' => $paginate->total(), 'pages' => $paginate->lastPage(), ]; } /** * 计算星座 */ private function calcConstellation($date) { $ts = strtotime($date); if (! $ts) { return ''; } $m = (int) date('m', $ts); $d = (int) date('d', $ts); $map = [ [1, 20, '摩羯座'], [2, 19, '水瓶座'], [3, 21, '双鱼座'], [4, 20, '白羊座'], [5, 21, '金牛座'], [6, 22, '双子座'], [7, 23, '巨蟹座'], [8, 23, '狮子座'], [9, 23, '处女座'], [10, 24, '天秤座'], [11, 23, '天蝎座'], [12, 22, '射手座'], [13, 0, '摩羯座'], ]; foreach ($map as [$mm, $dd, $name]) { if ($m < $mm || ($m == $mm && $d < $dd)) { return $name; } } return '摩羯座'; } /** * 计算生肖 */ private function calcZodiac($date) { $ts = strtotime($date); if (! $ts) { return ''; } $zodiacs = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']; $year = (int) date('Y', $ts); return $zodiacs[($year - 4) % 12]; } /** * 简易 geohash 编码(用于同城粗匹配前缀) */ private function geoHash($lat, $lng, $precision = 8) { $base32 = '0123456789bcdefghjkmnpqrstuvwxyz'; $latRange = [-90.0, 90.0]; $lngRange = [-180.0, 180.0]; $hash = ''; $bits = 0; $bit = 0; $even = true; $i = 0; while (strlen($hash) < $precision) { if ($even) { $mid = ($lngRange[0] + $lngRange[1]) / 2; if ($lng > $mid) { $bit = 1; $lngRange[0] = $mid; } else { $bit = 0; $lngRange[1] = $mid; } } else { $mid = ($latRange[0] + $latRange[1]) / 2; if ($lat > $mid) { $bit = 1; $latRange[0] = $mid; } else { $bit = 0; $latRange[1] = $mid; } } $even = ! $even; $bits = ($bits << 1) + $bit; if (++$i == 5) { $hash .= $base32[$bits]; $bits = 0; $i = 0; } } return $hash; } /** * 默认隐私 / 设置项 */ private function defaultPrivacy(): array { return [ 'hide_age' => 0, 'hide_online' => 0, 'hide_distance' => 0, 'hide_album' => 0, 'danmaku_global' => 1, // 弹幕总开关 ]; } /** * 读取我的隐私 / 设置 */ public function privacy() { $uid = $this->auth->uid; $profile = ProfileModel::where('uid', $uid)->field('privacy_config')->find(); $pc = $profile && $profile->privacy_config ? json_decode($profile->privacy_config, true) : []; $pc = is_array($pc) ? $pc : []; $this->success(array_merge($this->defaultPrivacy(), $pc)); } /** * 更新隐私 / 设置(隐藏年龄/在线/距离/相册 + 弹幕总开关) */ public function updatePrivacy() { $uid = $this->auth->uid; $allow = ['hide_age', 'hide_online', 'hide_distance', 'hide_album', 'danmaku_global']; $data = Request::post(); $update = array_intersect_key($data, array_flip($allow)); if (empty($update)) { $this->error('无有效字段'); } $profile = ProfileModel::where('uid', $uid)->field('privacy_config')->find(); $pc = $profile && $profile->privacy_config ? json_decode($profile->privacy_config, true) : []; $pc = is_array($pc) ? $pc : []; foreach ($update as $k => $v) { $pc[$k] = $v ? 1 : 0; } ProfileModel::where('uid', $uid)->update(['privacy_config' => json_encode($pc, JSON_UNESCAPED_UNICODE)]); $this->success($pc, '已更新'); } /** * 账号注销(逻辑注销:匿名化资料 + 标记注销时间) * 注:JWT 为无状态令牌,注销后需客户端清除本地令牌;服务端标记后拒绝以该账号发起的新业务 */ public function cancelAccount() { $uid = $this->auth->uid; // 匿名化资料 ProfileModel::where('uid', $uid)->update([ 'bio' => '', 'address' => '', 'latitude' => null, 'longitude' => null, 'geo_hash' => '', 'canceled_at' => time(), 'online_status'=> 0, ]); // 匿名化账号(昵称/头像) try { UserModel::where('uid', $uid)->update([ 'nickname' => '已注销用户', 'avatar' => '', 'status' => 0, ]); } catch (\Throwable $e) { // 个别环境 user 表无 status 字段时忽略 } $this->success([], '账号已注销'); } /** * 我的聚合统计(缘友/粉丝/关注/获赞/动态数) * 供「我的」中心页展示使用 */ public function summary() { $uid = $this->auth->model->uid; $stat = function (callable $cb, $default = 0) { try { return $cb(); } catch (\Throwable $e) { return $default; } }; // 关注:我关注别人(uid 为关注者) $following = $stat(function () use ($uid) { return (int) Db::name('wxchat_follow') ->where('uid', $uid) ->where('status', 1) ->count(); }); // 粉丝:别人关注我(fid 为被关注者) $followers = $stat(function () use ($uid) { return (int) Db::name('wxchat_follow') ->where('fid', $uid) ->where('status', 1) ->count(); }); // 缘友:互为好友数 $friends = $stat(function () use ($uid) { return (int) Db::name('wxchat_friend') ->where(function ($q) use ($uid) { $q->where('uid', $uid)->whereOr('fid', $uid); }) ->where('status', 1) ->count(); }); // 获赞:我的动态被点赞总数 $likes = $stat(function () use ($uid) { return (int) Db::name('wxchat_moments') ->where('uid', $uid) ->where('status', 1) ->sum('like_count'); }); // 我的动态数 $moments = $stat(function () use ($uid) { return (int) Db::name('wxchat_moments') ->where('uid', $uid) ->where('status', 1) ->count(); }); $this->success([ 'following' => $following, 'followers' => $followers, 'friends' => $friends, 'likes' => $likes, 'moments' => $moments, ]); } /** * 排行榜(财富榜/魅力榜) * type = rich 按金币,type = charm 按粉丝数 */ public function ranking() { $type = Request::get('type', 'charm'); $limit = 50; if ($type == 'rich') { $list = Db::name('member_wallets')->alias('w') ->join('user u', 'u.uid = w.uid') ->order('w.coins', 'desc') ->limit($limit) ->field('u.uid,u.nickname,u.avatar,w.coins as value') ->select(); $metric = 'coins'; } else { $list = Db::name('wxchat_follow')->alias('f') ->join('user u', 'u.uid = f.fid') ->where('f.status', 1) ->group('f.fid') ->order('count(f.fid) desc') ->limit($limit) ->field('f.fid as uid,u.nickname,u.avatar,count(f.fid) as value') ->select(); $metric = 'fans'; } $rank = 1; $result = []; foreach ($list as $it) { $result[] = [ 'rank' => $rank++, 'uid' => $it['uid'], 'nickname' => $it['nickname'], 'avatar' => $it['avatar'], 'value' => (int) $it['value'], 'coins' => isset($it['coins']) ? (int) $it['coins'] : 0, ]; } $this->success(['type' => $type, 'metric' => $metric, 'list' => $result]); } /** * 我的钱包(余额 / 金币 / 汇总 / 明细) */ public function wallet() { $uid = $this->uid; $row = Db::name('member_wallets')->where('uid', $uid)->find(); if (empty($row)) { $row = [ 'balance' => '0.00', 'coins' => 0, 'total_recharge' => '0.00', 'total_consume' => '0.00', ]; } // 金币/余额变动明细(表不存在时返回空) try { $logs = Db::name('wxchat_coin_logs') ->where('uid', $uid) ->order('create_at', 'desc') ->limit(50) ->field('id,type,change_num,balance_after,remark,create_at') ->select(); } catch (\Exception $e) { $logs = []; } $this->success([ 'balance' => (float) ($row['balance'] ?? 0), 'coins' => (int) ($row['coins'] ?? 0), 'total_recharge' => (float) ($row['total_recharge'] ?? 0), 'total_consume' => (float) ($row['total_consume'] ?? 0), 'logs' => $logs, ]); } }