612 lines
21 KiB
PHP
612 lines
21 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
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 <admin@ywxapp.cn>
|
||
*/
|
||
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,
|
||
]);
|
||
}
|
||
}
|