- 新增商家/信息详情页电话拨打接口,支持每日额度限制与拨打日志记录 - 新增平台免责声明内容及协议接口,完善用户服务协议相关配置 - 新增农产品分类图标与上市角标展示,优化分类管理界面样式 - 新增全国行政区划工具类与城市选择组件,重构地区数据结构 - 新增SSH自检与部署脚本,完善项目部署流程
376 行
14 KiB
PHP
376 行
14 KiB
PHP
<?php
|
||
|
||
namespace app\api\controller;
|
||
|
||
use think\Db;
|
||
|
||
/**
|
||
* 用户端 · 商家门店与主页(Module-MERCHANT)
|
||
* 路由:
|
||
* GET /api/v1/merchant/:id M1 商家公开主页/门店详情(手机号隐藏脱敏)
|
||
* POST /api/v1/merchant/:id/dial M1-B 商家门店申请拨打电话(校验每日拨打次数上限)
|
||
* GET /api/v1/merchant/:id/posts M2 商家名下发布的信息流(分页)
|
||
*/
|
||
class Merchant extends V1
|
||
{
|
||
/**
|
||
* M1 获取商家门店详情及公开档案
|
||
* GET /api/v1/merchant/:id
|
||
* 规则:允许游客;支持通过 merchant.id 或 user_id 查询;界面隐藏明文手机号,仅保留拨打按钮与免责弹窗
|
||
*
|
||
* @return void
|
||
*/
|
||
public function detail()
|
||
{
|
||
$id = (int)$this->routeId();
|
||
if ($id <= 0) {
|
||
$id = (int)$this->input('id', 0) ?: (int)$this->input('userId', 0);
|
||
}
|
||
if ($id <= 0) {
|
||
$this->fail('缺少商家参数', 400);
|
||
}
|
||
|
||
// 优先按 merchant.id 查,若未命中再按 user_id 查
|
||
$m = Db::name('fc_merchant')->where('id', $id)->find();
|
||
if (!$m) {
|
||
$m = Db::name('fc_merchant')->where('user_id', $id)->find();
|
||
}
|
||
if (!$m) {
|
||
$this->fail('商家门店不存在', 404);
|
||
}
|
||
|
||
// 关联发布者基础信息
|
||
$user = Db::name('user')->where('id', $m['user_id'])->find();
|
||
$userNickname = $user ? (string)$user['nickname'] : '';
|
||
$userAvatar = $user ? (string)$user['avatar'] : '';
|
||
|
||
// 统计真实有效发布数量
|
||
$postCount = (int)Db::name('fc_post')
|
||
->where('user_id', $m['user_id'])
|
||
->where('status', 1)
|
||
->where('audit_status', 1)
|
||
->whereNull('deletetime')
|
||
->count();
|
||
|
||
// 计算用户与门店之间的物理距离(km)
|
||
$clientLat = $this->coord('lat', 90);
|
||
$clientLng = $this->coord('lng', 180);
|
||
$distance = null;
|
||
if ($clientLat !== null && $clientLng !== null && !empty($m['lat']) && !empty($m['lng'])) {
|
||
$distance = $this->calcDistance($clientLat, $clientLng, (float)$m['lat'], (float)$m['lng']);
|
||
}
|
||
|
||
// 首屏拉取前 6 条该商家的有效发帖
|
||
$posts = $this->fetchMerchantPosts($m['user_id'], 1, 6);
|
||
|
||
// 商家详情页统一隐藏明文手机号(仅返回脱敏号码),通过 POST /merchant/:id/dial 校验每日拨打次数上限
|
||
$rawContact = trim((string)(isset($m['contact']) ? $m['contact'] : ''));
|
||
$isMember = false;
|
||
$isOwner = false;
|
||
$me = $this->user();
|
||
if ($me) {
|
||
$now = time();
|
||
$ext = $this->ext($me['id']);
|
||
$isMember = (int)$ext['is_member'] === 1
|
||
&& !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now;
|
||
$isOwner = (int)$m['user_id'] === (int)$me['id'];
|
||
}
|
||
|
||
$callState = $this->callQuotaState($me ? (int)$me['id'] : 0, $isMember);
|
||
$maskedContact = $this->maskPhone($rawContact);
|
||
$dialDisplay = $this->buildDialDisplay($userNickname ?: (string)$m['name'], $rawContact);
|
||
|
||
$data = [
|
||
'id' => (int)$m['id'],
|
||
'userId' => (int)$m['user_id'],
|
||
'name' => (string)$m['name'],
|
||
'logo' => (string)(isset($m['logo']) ? $m['logo'] : ''),
|
||
'banner' => (string)(isset($m['banner']) ? $m['banner'] : ''),
|
||
'address' => (string)(isset($m['address']) ? $m['address'] : ''),
|
||
'lat' => (isset($m['lat']) && $m['lat'] !== null && $m['lat'] !== '') ? (float)$m['lat'] : null,
|
||
'lng' => (isset($m['lng']) && $m['lng'] !== null && $m['lng'] !== '') ? (float)$m['lng'] : null,
|
||
'contact' => $maskedContact,
|
||
'hasPhone' => $rawContact !== '',
|
||
'dialDisplay' => $dialDisplay,
|
||
'callQuotaTotal' => $callState['total'],
|
||
'callQuotaUsed' => $callState['used'],
|
||
'callQuotaLeft' => $callState['left'],
|
||
'canDial' => $isOwner || $callState['unlimited'] || $callState['left'] > 0,
|
||
'unlocked' => false,
|
||
'category' => (string)(isset($m['category']) ? $m['category'] : ''),
|
||
'notice' => (string)(isset($m['notice']) ? $m['notice'] : ''),
|
||
'auditStatus' => (int)$m['audit_status'],
|
||
'verified' => (int)$m['audit_status'] === 1,
|
||
'licenseVerified' => !empty($m['license']) && (int)$m['audit_status'] === 1,
|
||
'postCount' => $postCount,
|
||
'distance' => $distance,
|
||
'userNickname' => $userNickname,
|
||
'userAvatar' => $userAvatar,
|
||
'createdAt' => isset($m['createtime']) ? $this->iso($m['createtime']) : '',
|
||
'posts' => $posts['list'],
|
||
'hasMorePosts' => $posts['hasMore'],
|
||
];
|
||
|
||
$this->ok($data);
|
||
}
|
||
|
||
/**
|
||
* M1-B 商家详情页申请拨打电话(校验每日拨打次数上限并记录拨打日志)
|
||
* POST /api/v1/merchant/:id/dial
|
||
* → {phoneNumber, dialDisplay, callQuotaTotal, callQuotaUsed, callQuotaLeft}
|
||
*
|
||
* @return void
|
||
*/
|
||
public function dial()
|
||
{
|
||
$user = $this->auth();
|
||
$id = (int)$this->routeId();
|
||
if ($id <= 0) {
|
||
$id = (int)$this->input('id', 0) ?: (int)$this->input('userId', 0);
|
||
}
|
||
if ($id <= 0) {
|
||
$this->fail('缺少商家参数', 400);
|
||
}
|
||
|
||
$m = Db::name('fc_merchant')->where('id', $id)->find();
|
||
if (!$m) {
|
||
$m = Db::name('fc_merchant')->where('user_id', $id)->find();
|
||
}
|
||
if (!$m) {
|
||
$this->fail('商家门店不存在', 404);
|
||
}
|
||
|
||
$phone = trim((string)(isset($m['contact']) ? $m['contact'] : ''));
|
||
if ($phone === '') {
|
||
$this->fail('该商家暂未预留联系电话');
|
||
}
|
||
|
||
$now = time();
|
||
$ext = $this->ext($user['id']);
|
||
$isMember = (int)$ext['is_member'] === 1
|
||
&& !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now;
|
||
$isOwner = (int)$m['user_id'] === (int)$user['id'];
|
||
|
||
$state = $this->callQuotaState((int)$user['id'], $isMember);
|
||
|
||
if (!$isOwner && !$state['unlimited'] && $state['left'] <= 0) {
|
||
$msg = $isMember
|
||
? ('您今日的 VIP 拨打次数已达上限(' . $state['total'] . '次/日)')
|
||
: ('您今日的免费拨打次数已达上限(' . $state['total'] . '次/日),开通 VIP 会员可享每日更多拨打次数');
|
||
$this->fail($msg, 403);
|
||
}
|
||
|
||
$today = date('Y-m-d');
|
||
Db::name('fc_call_log')->insert([
|
||
'user_id' => (int)$user['id'],
|
||
'target_type' => 'merchant',
|
||
'target_id' => (int)$m['id'],
|
||
'phone' => $phone,
|
||
'call_date' => $today,
|
||
'createtime' => $now,
|
||
]);
|
||
|
||
$newUsed = $state['used'] + 1;
|
||
$newLeft = $state['unlimited'] ? 999 : max(0, $state['total'] - $newUsed);
|
||
$pubUser = Db::name('user')->where('id', $m['user_id'])->find();
|
||
$contactLabel = $pubUser && !empty($pubUser['nickname']) ? (string)$pubUser['nickname'] : (string)$m['name'];
|
||
|
||
$this->ok([
|
||
'phoneNumber' => $phone,
|
||
'dialDisplay' => $this->buildDialDisplay($contactLabel, $phone),
|
||
'callQuotaTotal' => $state['total'],
|
||
'callQuotaUsed' => $newUsed,
|
||
'callQuotaLeft' => $newLeft,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 构造免责声明拨打弹窗展示文案(如:王138****8000)
|
||
*
|
||
* @param string $name 联系人或门店名称
|
||
* @param string $phone 手机号
|
||
* @return string
|
||
*/
|
||
protected function buildDialDisplay($name, $phone)
|
||
{
|
||
$masked = $this->maskPhone($phone);
|
||
$clean = trim((string)$name);
|
||
if ($clean === '') {
|
||
return $masked;
|
||
}
|
||
$surname = mb_substr($clean, 0, 1, 'UTF-8');
|
||
return $surname . $masked;
|
||
}
|
||
|
||
/**
|
||
* 计算指定用户今日的电话拨打额度状态
|
||
*
|
||
* @param int $userId 用户ID(0表示游客)
|
||
* @param bool $isMember 是否为有效期内VIP会员
|
||
* @return array{total:int,used:int,left:int,unlimited:bool}
|
||
*/
|
||
protected function callQuotaState($userId, $isMember)
|
||
{
|
||
$normalQuota = max(0, (int)$this->cfg('call.normalDailyQuota', 5));
|
||
$vipQuota = max(0, (int)$this->cfg('call.vipDailyQuota', 30));
|
||
$total = $isMember ? $vipQuota : $normalQuota;
|
||
$unlimited = ($total === 0);
|
||
|
||
$used = 0;
|
||
if ($userId > 0) {
|
||
$today = date('Y-m-d');
|
||
$used = (int)Db::name('fc_call_log')
|
||
->where('user_id', $userId)
|
||
->where('call_date', $today)
|
||
->count();
|
||
}
|
||
|
||
return [
|
||
'total' => $total,
|
||
'used' => $used,
|
||
'left' => $unlimited ? 999 : max(0, $total - $used),
|
||
'unlimited' => $unlimited,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* M2 获取商家名下的信息列表(支持分页)
|
||
* GET /api/v1/merchant/:id/posts
|
||
*
|
||
* @return void
|
||
*/
|
||
public function posts()
|
||
{
|
||
$id = (int)$this->routeId();
|
||
if ($id <= 0) {
|
||
$id = (int)$this->input('id', 0) ?: (int)$this->input('userId', 0);
|
||
}
|
||
if ($id <= 0) {
|
||
$this->fail('缺少商家参数', 400);
|
||
}
|
||
|
||
$userId = $id;
|
||
$m = Db::name('fc_merchant')->where('id', $id)->find();
|
||
if ($m) {
|
||
$userId = (int)$m['user_id'];
|
||
}
|
||
|
||
list($page, $size) = $this->pager(10, 30);
|
||
$res = $this->fetchMerchantPosts($userId, $page, $size);
|
||
$this->ok($res);
|
||
}
|
||
|
||
/**
|
||
* 查询指定商家的发帖列表统一组装
|
||
*
|
||
* @param int $userId 发布者用户ID
|
||
* @param int $page 页码
|
||
* @param int $size 每页数量
|
||
* @return array
|
||
*/
|
||
protected function fetchMerchantPosts($userId, $page, $size)
|
||
{
|
||
$q = Db::name('fc_post')
|
||
->where('user_id', $userId)
|
||
->where('status', 1)
|
||
->where('audit_status', 1)
|
||
->whereNull('deletetime');
|
||
|
||
$total = (int)$q->count();
|
||
$rows = Db::name('fc_post')
|
||
->where('user_id', $userId)
|
||
->where('status', 1)
|
||
->where('audit_status', 1)
|
||
->whereNull('deletetime')
|
||
->order('is_top desc, id desc')
|
||
->page($page, $size)
|
||
->select();
|
||
|
||
$list = [];
|
||
foreach ($rows as $r) {
|
||
$catName = '';
|
||
if (!empty($r['category_id'])) {
|
||
$cat = Db::name('fc_category')->where('id', $r['category_id'])->value('name');
|
||
$catName = $cat ? (string)$cat : '';
|
||
}
|
||
$subCatId = isset($r['sub_category_id']) ? (int)$r['sub_category_id'] : 0;
|
||
$subCatName = '';
|
||
if ($subCatId > 0) {
|
||
$subCat = Db::name('fc_category')->where('id', $subCatId)->value('name');
|
||
$subCatName = $subCat ? (string)$subCat : '';
|
||
}
|
||
|
||
$cover = (string)$r['cover'];
|
||
$mediaArr = $this->jsonArr($r['media']);
|
||
if ($cover === '' && !empty($mediaArr)) {
|
||
$cover = $mediaArr[0];
|
||
}
|
||
|
||
$tags = [];
|
||
if (!empty($r['tags'])) {
|
||
foreach (explode(',', (string)$r['tags']) as $t) {
|
||
$t = trim($t);
|
||
if ($t !== '') {
|
||
$tags[] = $t;
|
||
}
|
||
}
|
||
}
|
||
|
||
$list[] = [
|
||
'id' => (int)$r['id'],
|
||
'title' => (string)$r['title'],
|
||
'content' => (string)$r['content'],
|
||
'price' => (float)$r['price'],
|
||
'priceUnit' => (string)$r['price_unit'],
|
||
'cover' => $cover,
|
||
'media' => $mediaArr,
|
||
'province' => isset($r['province']) ? (string)$r['province'] : '',
|
||
'city' => isset($r['city']) ? (string)$r['city'] : '',
|
||
'area' => (string)$r['area'],
|
||
'address' => (string)$r['address'],
|
||
'plantArea' => isset($r['plant_area']) ? (string)$r['plant_area'] : '',
|
||
'harvestTime' => isset($r['harvest_time']) ? (string)$r['harvest_time'] : '',
|
||
'outputRange' => isset($r['output_range']) ? (string)$r['output_range'] : '',
|
||
'categoryId' => (int)$r['category_id'],
|
||
'categoryName' => $catName,
|
||
'subCategoryId' => $subCatId,
|
||
'subCategoryName' => $subCatName,
|
||
'isTop' => (int)$r['is_top'] === 1,
|
||
'views' => (int)$r['views'],
|
||
'commentCount' => (int)$r['comment_count'],
|
||
'createdAt' => $this->iso($r['createtime']),
|
||
'tags' => $tags,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'list' => $list,
|
||
'total' => $total,
|
||
'page' => $page,
|
||
'size' => $size,
|
||
'hasMore' => ($page * $size) < $total,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 计算两点大地距离(单位 km,保留 1 位小数)
|
||
*
|
||
* @param float $lat1 纬度1
|
||
* @param float $lng1 经度1
|
||
* @param float $lat2 纬度2
|
||
* @param float $lng2 经度2
|
||
* @return float
|
||
*/
|
||
protected function calcDistance($lat1, $lng1, $lat2, $lng2)
|
||
{
|
||
$earthRadius = 6378.137;
|
||
$radLat1 = deg2rad($lat1);
|
||
$radLat2 = deg2rad($lat2);
|
||
$a = $radLat1 - $radLat2;
|
||
$b = deg2rad($lng1) - deg2rad($lng2);
|
||
$s = 2 * asin(sqrt(pow(sin($a / 2), 2) + cos($radLat1) * cos($radLat2) * pow(sin($b / 2), 2)));
|
||
$s = $s * $earthRadius;
|
||
return round($s, 1);
|
||
}
|
||
}
|