文件
fenleixinxi/application/api/controller/Merchant.php
MiAn bb6418f51b feat: 新增电话拨打功能及相关配置,完善农产品分类与地区数据
- 新增商家/信息详情页电话拨打接口,支持每日额度限制与拨打日志记录
- 新增平台免责声明内容及协议接口,完善用户服务协议相关配置
- 新增农产品分类图标与上市角标展示,优化分类管理界面样式
- 新增全国行政区划工具类与城市选择组件,重构地区数据结构
- 新增SSH自检与部署脚本,完善项目部署流程
2026-10-27 17:05:00 +08:00

376 行
14 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?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);
}
}