feat: 新增地址选择插件及订阅消息等多项功能优化

- 新增地址选择插件,支持地图位置选择并返回地址和经纬度
- 新增微信订阅消息发送功能,支持审核结果、提现通知等场景
- 新增订单支付状态查询、提现收款人姓名保存等接口
- 新增服务区域管理,支持按省市区划分并同步到信息发布
- 新增资金流水和营收报表导出功能
- 新增可用优惠券查询、订单退款拆分处理等功能
- 优化后台角标显示、首页数据统计、UI配置下发等
- 修复订单支付并发问题、地区管理逻辑等
这个提交包含在:
2026-10-22 17:14:56 +08:00
父节点 088a08d43a
当前提交 cb12bf5fc8
共有 180 个文件被更改,包括 9695 次插入 和 1680 次删除

查看文件

@@ -4,6 +4,7 @@ namespace app\api\controller;
use think\Db;
use app\common\library\DistributionService;
use app\common\library\CouponService;
/**
* 用户端 · 信息发布与详情(Module-POST)
@@ -113,11 +114,13 @@ class Post extends V1
/**
* P1 发布信息
* POST /api/v1/post/create
* 入参 {categoryId,title,content,media[],contact,phone,address,lat?,lng?,plan,payType}
* 入参 {categoryId,title,content,media[],contact,phone,address,areaId?,districtId?,area?,lat?,lng?,plan,payType}
* → {postId,auditStatus,order,plan,amount,discountRate,quotaLeft,freeQuotaLeft}
* 规则 BR-03/BR-04/BR-02/BR-13(计费顺序:套餐额度 → 免费发布条数 → 单条付费)
* 说明:
* - `media` 接收**上传后的 URL 数组**(先调 POST /post/upload 拿 url,再随发布提交);
* - 服务区域为**区县**:优先取 `areaId`(=fc_area.id),兼容 `districtId` / `area` 名称;
* 都没传但带坐标时,按最近服务区域自动归属;均无则允许为空(兼容旧客户端);
* - `lat`/`lng` 可选(gcj02),由前端 chooseLocation 采集,用于首页「附近」排序;
* - 单条付费金额 = round(单条价格 × 会员折扣率, 2),折扣率取自 member.benefit.postDiscount
* (会员权益,非会员/解析失败视作 1);金额一律服务端计算,忽略客户端传值。
@@ -186,10 +189,6 @@ class Post extends V1
if ($amount > 0 && !in_array($payType, ['wxpay', 'balance'], true)) {
$this->fail('支付方式不支持');
}
// 余额校验须在落库之前:否则余额不足时会残留一条 is_paid=1 的无效发布
if ($amount > 0 && $payType === 'balance' && (float)$user['money'] < $amount) {
$this->fail('余额不足');
}
} elseif ($quotaLeft > 0) {
$useQuota = true; // ① 套餐额度(开通月/年卡赠送,随会员有效期)
} elseif ($free['unlimited'] || $free['left'] > 0) {
@@ -204,87 +203,160 @@ class Post extends V1
}
$paid = $amount > 0;
$now = time();
$postId = Db::name('fc_post')->insertGetId([
'user_id' => $user['id'],
'category_id' => $categoryId,
'title' => $title,
'content' => $content,
'cover' => '',
'media' => json_encode($this->mediaArray(), JSON_UNESCAPED_UNICODE),
'price' => round((float)$this->input('price', 0), 2),
'price_unit' => (string)$this->input('priceUnit', ''),
'area' => (string)$this->input('area', ''),
'address' => (string)$this->input('address', ''),
'contact' => (string)$this->input('contact', ''),
'phone' => $phone,
'wechat' => '',
'tags' => $this->tagsString(),
// LBS 坐标(gcj02;未定位/为 0 时存 NULL,首页「附近」不参与距离排序)
'lat' => $this->coord('lat', 90),
'lng' => $this->coord('lng', 180),
'is_paid' => $paid ? 1 : 0,
'amount' => $paid ? $amount : 0,
'pay_type' => $paid ? $payType : '',
'audit_status' => 0,
'views' => 0,
'likes' => 0,
'comment_count' => 0,
'status' => 1,
'createtime' => $now,
'updatetime' => $now,
]);
$order = null;
if ($paid) {
// 余额支付为「即时支付」:下单即扣款、订单直接置为已支付;
// 微信支付需要前端调起收银台,保持 pending 由支付回调置为 paid。
$paidNow = $payType === 'balance';
$orderNo = $this->orderNo('PO');
Db::name('fc_order')->insert([
'order_no' => $orderNo,
'user_id' => $user['id'],
'type' => 'post',
'title' => $title,
'amount' => $amount,
'pay_type' => $payType,
'status' => $paidNow ? 'paid' : 'pending',
'paytime' => $paidNow ? $now : null,
'related_id' => $postId,
'createtime' => $now,
'updatetime' => $now,
]);
if ($paidNow) {
$balance = round((float)$user['money'] - $amount, 2);
Db::name('user')->where('id', $user['id'])->update([
'money' => $balance,
'updatetime' => $now,
]);
$this->flow('income', '发布付费', $amount, $balance, $postId);
// 余额支付发布付费:触发分销结算
$orderRow = Db::name('fc_order')->where('order_no', $orderNo)->find();
if ($orderRow) {
DistributionService::settleOrder($orderRow);
// 优惠券(仅单条付费发布可用)
$originAmount = $amount;
$couponAmount = 0;
$userCouponId = 0;
if ($paid && $plan === 'single') {
$userCouponId = (int)$this->input('userCouponId', 0);
if ($userCouponId > 0) {
try {
$couponInfo = CouponService::preview($user['id'], $userCouponId, $originAmount);
} catch (\Throwable $e) {
$this->fail($e->getMessage());
}
$couponAmount = $couponInfo['discount'];
$amount = round($originAmount - $couponAmount, 2);
}
$order = [
'orderNo' => $orderNo,
'amount' => $amount,
'payType' => $payType,
'status' => $paidNow ? 'paid' : 'pending',
];
}
// 套餐额度内免费发布:落库成功后扣减 1 条额度(先发布后扣,发布失败不白扣)
if ($useQuota) {
$quotaLeft = max(0, $quotaLeft - 1);
Db::name('fc_user_ext')->where('user_id', $user['id'])->update([
'post_quota_left' => $quotaLeft,
'updatetime' => $now,
]);
// 余额校验须在落库之前(按抵扣后的实付金额判断)
if ($paid && $payType === 'balance' && (float)$user['money'] < $amount) {
$this->fail('余额不足');
}
// 区域:优先 districtId,其次 area 名称,最后按坐标匹配最近区县
$region = $this->resolveRegion();
// 封面:显式传 cover 优先;否则取媒体里的第一张图片
$cover = trim((string)$this->input('cover', ''));
if ($cover === '') {
foreach ($this->mediaArray() as $m) {
if (!preg_match('/\.(mp4|mov|m4v|avi|wmv|flv|m3u8)(\?|$)/i', $m)) {
$cover = $m;
break;
}
}
}
if ($cover !== '' && !preg_match('#^(/uploads/|https?://)#i', $cover)) {
$this->fail('封面地址不合法');
}
$now = time();
$postId = 0;
$order = null;
$balanceAfter = 0;
try {
// 发布落库 + 扣款 + 订单 + 额度扣减 同一事务:任一步失败整体回滚
Db::transaction(function () use (
&$postId, &$order, &$balanceAfter, &$quotaLeft,
$paid, $user, $amount, $originAmount, $couponAmount, $userCouponId, $payType,
$now, $categoryId, $title, $content, $phone, $useQuota, $region, $cover
) {
$paidNow = $paid && $payType === 'balance';
// 订单号提前生成:优惠券锁定需要绑定订单号
$orderNo = $paid ? $this->orderNo('PO') : '';
if ($userCouponId > 0) {
CouponService::lock($user['id'], $userCouponId, $originAmount, $orderNo);
}
// 余额扣减:条件更新,并发下不会扣成负数
if ($paidNow) {
$affected = Db::name('user')
->where('id', (int)$user['id'])
->where('money', '>=', $amount)
->setDec('money', $amount);
if (!$affected) {
throw new \RuntimeException('余额不足');
}
$balanceAfter = (float)Db::name('user')->where('id', (int)$user['id'])->value('money');
}
$postId = Db::name('fc_post')->insertGetId([
'user_id' => $user['id'],
'category_id' => $categoryId,
'title' => $title,
'content' => $content,
'cover' => $cover,
'media' => json_encode($this->mediaArray(), JSON_UNESCAPED_UNICODE),
'price' => round((float)$this->input('price', 0), 2),
'price_unit' => (string)$this->input('priceUnit', ''),
'area' => $region['area'],
'district_id' => $region['district_id'],
'address' => (string)$this->input('address', ''),
'contact' => (string)$this->input('contact', ''),
'phone' => $phone,
'wechat' => '',
'tags' => $this->tagsString(),
// LBS 坐标(gcj02;未定位/为 0 时存 NULL,首页「附近」不参与距离排序)
'lat' => $this->coord('lat', 90),
'lng' => $this->coord('lng', 180),
'is_paid' => $paid ? 1 : 0,
'amount' => $paid ? $amount : 0,
'pay_type' => $paid ? $payType : '',
'audit_status' => 0,
'views' => 0,
'likes' => 0,
'comment_count' => 0,
'status' => 1,
'createtime' => $now,
'updatetime' => $now,
]);
if ($paid) {
// 余额支付为「即时支付」:扣款 + 订单直接 paid;
// 微信支付保持 pending,由支付回调置为 paid 并发放权益。
Db::name('fc_order')->insert([
'order_no' => $orderNo,
'user_id' => $user['id'],
'type' => 'post',
'title' => $title,
'amount' => $amount,
'origin_amount' => $originAmount,
'coupon_amount' => $couponAmount,
'coupon_id' => $userCouponId > 0 ? $userCouponId : 0,
'pay_type' => $payType,
'status' => $paidNow ? 'paid' : 'pending',
'paytime' => $paidNow ? $now : null,
'related_id' => $postId,
'createtime' => $now,
'updatetime' => $now,
]);
if ($paidNow) {
$this->flow('income', '发布付费', $amount, $balanceAfter, $postId);
// 余额支付:核销优惠券 + 触发分销结算
CouponService::markUsed($orderNo);
$orderRow = Db::name('fc_order')->where('order_no', $orderNo)->find();
if ($orderRow) {
DistributionService::settleOrder($orderRow);
}
}
$order = [
'orderNo' => $orderNo,
'amount' => $amount,
'originAmount' => $originAmount,
'couponAmount' => $couponAmount,
'payType' => $payType,
'status' => $paidNow ? 'paid' : 'pending',
];
}
// 套餐额度内免费发布:落库成功后扣减 1 条额度(先发布后扣,发布失败不白扣)
if ($useQuota) {
$quotaLeft = max(0, $quotaLeft - 1);
Db::name('fc_user_ext')->where('user_id', $user['id'])->update([
'post_quota_left' => $quotaLeft,
'updatetime' => $now,
]);
}
});
} catch (\Throwable $e) {
$this->fail($e->getMessage() ?: '发布失败,请重试');
}
$this->ok([
@@ -293,6 +365,8 @@ class Post extends V1
'order' => $order,
'plan' => $useQuota ? 'quota' : ($useFreeQuota ? 'free' : ($paid ? 'single' : 'free')),
'amount' => $amount,
'originAmount' => $originAmount,
'couponAmount' => $couponAmount,
// 本次生效的会员发布折扣率(非会员/未配置为 1)
'discountRate' => $discountRate,
'quotaLeft' => $quotaLeft,
@@ -349,6 +423,7 @@ class Post extends V1
'price' => (float)$row['price'],
'priceUnit' => (string)$row['price_unit'],
'area' => (string)$row['area'],
'areaId' => (int)$row['district_id'],
'address' => (string)$row['address'],
'auditStatus' => (int)$row['audit_status'],
'auditReason' => (string)$row['audit_reason'],
@@ -562,6 +637,8 @@ class Post extends V1
'contact' => (string)$row['contact'],
'phone' => (string)$row['phone'],
'address' => (string)$row['address'],
'area' => (string)$row['area'],
'areaId' => (int)$row['district_id'],
'savedAt' => $this->iso($row['savedat']),
];
}
@@ -611,6 +688,8 @@ class Post extends V1
'contact' => (string)$this->input('contact', ''),
'phone' => (string)$this->input('phone', ''),
'address' => (string)$this->input('address', ''),
'area' => (string)$this->input('area', ''),
'district_id' => (int)$this->input('areaId', (int)$this->input('districtId', 0)),
'savedat' => $now,
'updatetime' => $now,
];
@@ -683,10 +762,112 @@ class Post extends V1
$this->fail('信息不存在', 404);
}
Db::name('fc_post')->where('id', $id)->setInc('views');
$post['views'] = (int)$post['views'] + 1;
// accessOnly=1:仅回读解锁态(如从会员中心返回时刷新),不计浏览量
if (!$this->input('accessOnly', false)) {
Db::name('fc_post')->where('id', $id)->setInc('views');
$post['views'] = (int)$post['views'] + 1;
}
$this->ok($this->buildPost($post));
$this->ok($this->contactAccess($this->buildPost($post), $post));
}
/**
* 详情页联系方式可见性(避免「已开通会员仍显示已受保护」)
*
* 可见规则(与 payView 计费口径一致):
* ① 会员有效期内 → 免费看
* ② 已付费解锁 fc_pay_view → 看(余额 / 微信回调写入)
* ③ 本人发布的信息 → 看
* 其它(游客 / 非会员未付费)→ 仍返回脱敏串
*
* 出参新增:unlocked(bool)、wechat(明文,未解锁为空串)。
* 注意:只加在详情接口,列表(首页/收藏等)仍保持脱敏,避免手机号被批量抓取。
*
* @param array $data buildPost() 结果
* @param array $post fc_post 行
* @return array
*/
protected function contactAccess($data, $post)
{
$data['unlocked'] = false;
$data['wechat'] = '';
$me = $this->user();
if (!$me) {
return $data;
}
$now = time();
$ext = $this->ext($me['id']);
$isMember = (int)$ext['is_member'] === 1
&& (empty($ext['member_expiretime']) || (int)$ext['member_expiretime'] > $now);
$isOwner = (int)$post['user_id'] === (int)$me['id'];
$paid = (bool)Db::name('fc_pay_view')
->where('user_id', $me['id'])
->where('post_id', $post['id'])
->find();
// 会员每日免费查看剩余次数(只做展示;解锁统一走 payView,按额度/付费计费)
$quota = $this->memberFreeViewQuota();
$today = date('Y-m-d');
$used = ((string)$ext['free_view_date'] === $today) ? (int)$ext['free_view_used'] : 0;
$data['freeViewLeft'] = $isMember ? max(0, $quota - $used) : 0;
if ($isOwner || $paid) {
$data['unlocked'] = true;
$data['contact'] = (string)$post['phone'];
$data['wechat'] = $post['wechat'] !== '' ? (string)$post['wechat'] : ('tongcheng_' . $post['id']);
}
return $data;
}
/**
* 会员每日免费查看条数(member.benefit.freeView,默认 20)
*
* @return int
*/
protected function memberFreeViewQuota()
{
$cfg = $this->cfg('member.benefit', []);
if (is_array($cfg) && isset($cfg['freeView'])) {
return max(0, (int)$cfg['freeView']);
}
return 20;
}
/**
* 消耗一次会员免费查看额度(跨天自动重置;需在事务内调用)
*
* @param int $userId
* @param int $quota
* @return bool 是否成功占用
*/
protected function consumeFreeView($userId, $quota)
{
$today = date('Y-m-d');
$ext = Db::name('fc_user_ext')->where('user_id', (int)$userId)->lock(true)->find();
if (!$ext) {
return false;
}
if ((string)$ext['free_view_date'] !== $today) {
Db::name('fc_user_ext')->where('user_id', (int)$userId)->update([
'free_view_date' => $today,
'free_view_used' => 1,
'updatetime' => time(),
]);
return true;
}
if ((int)$ext['free_view_used'] >= $quota) {
return false;
}
Db::name('fc_user_ext')->where('user_id', (int)$userId)->setInc('free_view_used', 1);
return true;
}
/**
@@ -733,59 +914,72 @@ class Post extends V1
$ext = $this->ext($user['id']);
$isMember = (int)$ext['is_member'] === 1
&& (!$ext['member_expiretime'] || $ext['member_expiretime'] > $now);
$quota = $this->memberFreeViewQuota();
$orderId = 0;
if ($isMember) {
// 会员免费解锁
$amount = 0;
$payType = 'free';
} elseif ($payType === 'balance') {
$balance = (float)$user['money'];
if ($balance < $amount) {
$this->fail('余额不足');
}
$balance = round($balance - $amount, 2);
Db::name('user')->where('id', $user['id'])->update([
'money' => $balance,
'updatetime' => $now,
]);
$this->flow('expense', '付费查看', $amount, $balance, $id);
} else {
// 微信支付:只落 pending 订单(不预写解锁记录),
// 支付成功后由 /pay/notify 按 type=payview + related_id 解锁(回调可独立完成)
$orderNo = $this->orderNo('VO');
Db::name('fc_order')->insert([
'order_no' => $orderNo,
'user_id' => $user['id'],
'type' => 'payview',
'title' => '付费查看 #' . $id,
'amount' => $amount,
'pay_type' => 'wxpay',
'status' => 'pending',
'paytime' => null,
'related_id' => $id,
'createtime' => $now,
'updatetime' => $now,
]);
$pending = null;
$this->ok([
'order' => [
'orderNo' => $orderNo,
'amount' => $amount,
'payType' => 'wxpay',
'status' => 'pending',
],
]);
try {
Db::transaction(function () use (&$orderId, &$pending, $isMember, $quota, $payType, $amount, $user, $id, $now) {
// 会员每日免费查看额度(当天用完后按正常付费)
$usedFree = ($isMember && $quota > 0) ? $this->consumeFreeView((int)$user['id'], $quota) : false;
if ($usedFree) {
$amount = 0;
$payType = 'free';
} elseif ($payType === 'balance') {
// 条件扣款:并发下不会扣成负数
$affected = Db::name('user')
->where('id', (int)$user['id'])
->where('money', '>=', $amount)
->setDec('money', $amount);
if (!$affected) {
throw new \RuntimeException('余额不足');
}
$balance = (float)Db::name('user')->where('id', (int)$user['id'])->value('money');
$this->flow('expense', '付费查看', $amount, $balance, $id);
} else {
// 微信支付:只落 pending 订单(不预写解锁记录),
// 支付成功后由 /pay/notify 按 type=payview + related_id 解锁(回调可独立完成)
$orderNo = $this->orderNo('VO');
Db::name('fc_order')->insert([
'order_no' => $orderNo,
'user_id' => $user['id'],
'type' => 'payview',
'title' => '付费查看 #' . $id,
'amount' => $amount,
'pay_type' => 'wxpay',
'status' => 'pending',
'paytime' => null,
'related_id' => $id,
'createtime' => $now,
'updatetime' => $now,
]);
$pending = [
'orderNo' => $orderNo,
'amount' => $amount,
'payType' => 'wxpay',
'status' => 'pending',
];
return;
}
Db::name('fc_pay_view')->insert([
'user_id' => $user['id'],
'post_id' => $id,
'pay_type' => $payType,
'amount' => $amount,
'order_id' => $orderId,
'createtime' => $now,
]);
});
} catch (\Throwable $e) {
$this->fail($e->getMessage() ?: '解锁失败,请重试');
}
Db::name('fc_pay_view')->insert([
'user_id' => $user['id'],
'post_id' => $id,
'pay_type' => $payType,
'amount' => $amount,
'order_id' => $orderId,
'createtime' => $now,
]);
if ($pending !== null) {
$this->ok(['order' => $pending]);
}
$this->ok(['unlocked' => true, 'contact' => $contact]);
}
@@ -936,6 +1130,66 @@ class Post extends V1
return $val;
}
/**
* 解析发布信息的服务区域(区县)
* 优先级:areaId(兼容 districtId)> area 名称 > 经纬度最近区域(300km 内)。
* 未提供任何区域时返回空区域,保持与旧客户端兼容。
* @return array{area:string,district_id:int}
*/
protected function resolveRegion()
{
$areaId = (int)$this->input('areaId', 0);
if (!$areaId) {
$areaId = (int)$this->input('districtId', 0);
}
$areaName = trim((string)$this->input('area', ''));
$area = null;
if ($areaId > 0) {
$area = Db::name('fc_area')
->where('id', $areaId)
->where('enabled', 1)
->find();
if (!$area) {
$this->fail('所选服务区域不可用,请重新选择');
}
} elseif ($areaName !== '') {
$area = Db::name('fc_area')
->where('district', $areaName)
->where('enabled', 1)
->find();
} else {
$lat = $this->coord('lat', 90);
$lng = $this->coord('lng', 180);
if ($lat !== null && $lng !== null) {
$rows = Db::name('fc_area')
->where('enabled', 1)
->whereNotNull('lat')
->whereNotNull('lng')
->select();
$best = null;
foreach ($rows as $row) {
$distance = $this->haversine($lat, $lng, (float)$row['lat'], (float)$row['lng']);
if ($best === null || $distance < $best['distance']) {
$best = ['row' => $row, 'distance' => $distance];
}
}
if ($best && $best['distance'] <= 300) {
$area = $best['row'];
}
}
}
if (!$area) {
return ['area' => $areaName, 'district_id' => 0];
}
return [
'area' => (string)$area['district'],
'district_id' => (int)$area['id'],
];
}
/**
* 会员发布折扣率
* 取自 member.benefit.postDiscount(如 '8 折'→0.8、'9.5折'→0.95、'八折' 视为解析失败→1);
@@ -1123,6 +1377,7 @@ class Post extends V1
'price' => (float)$row['price'],
'priceUnit' => (string)$row['price_unit'],
'area' => (string)$row['area'],
'areaId' => (int)$row['district_id'],
'address' => (string)$row['address'],
'contact' => $this->maskPhone($row['phone']),
'views' => (int)$row['views'],