359 lines
14 KiB
PHP
359 lines
14 KiB
PHP
<?php
|
|
|
|
namespace app\common\service;
|
|
|
|
use app\common\model\Accounts;
|
|
use app\common\model\Orders;
|
|
use app\common\model\User;
|
|
use app\common\traits\ServiceTrait;
|
|
use think\Cache;
|
|
use think\Db;
|
|
use think\Log;
|
|
|
|
/**
|
|
* 推广分佣
|
|
* - 推广关系:用户首次通过分享链接/小程序码进入时永久绑定 fa_user.promoter_id
|
|
* - 下单时把买家的推广人快照到 fa_orders.promoter_id
|
|
* - 订单交易完成时按后台配置结算佣金到推广人余额
|
|
*/
|
|
class PromotionService
|
|
{
|
|
use ServiceTrait;
|
|
|
|
/** @var Orders */
|
|
public static $Model = Orders::class;
|
|
|
|
//佣金状态
|
|
const COMMISSION_STATUS_PENDING = 0; //待结算
|
|
const COMMISSION_STATUS_SETTLED = 1; //已结算
|
|
const COMMISSION_STATUS_SKIPPED = 2; //不分佣(结算时未开启分佣/比例为0/未支付)
|
|
|
|
const COMMISSION_STATUS_MAP = [
|
|
self::COMMISSION_STATUS_PENDING => '待结算',
|
|
self::COMMISSION_STATUS_SETTLED => '已结算',
|
|
self::COMMISSION_STATUS_SKIPPED => '不分佣',
|
|
];
|
|
|
|
//小程序码 scene 中推广人参数名(scene 最长 32 字符)
|
|
const QRCODE_SCENE_KEY = 'pid';
|
|
//小程序码落地页
|
|
const QRCODE_PAGE = 'pages/home/home';
|
|
const QRCODE_CACHE_PREFIX = 'promotion_qrcode:';
|
|
const QRCODE_CACHE_TTL = 604800; //7天
|
|
|
|
const ENV_VERSIONS = ['release', 'trial', 'develop'];
|
|
|
|
//推广订单列表展示的订单状态(已支付及之后)
|
|
const VISIBLE_ORDER_STATUS = [
|
|
OrderService::ORDER_STATUS_WAIT_SHIP,
|
|
OrderService::ORDER_STATUS_SHIPPED,
|
|
OrderService::ORDER_STATUS_FINISHED,
|
|
];
|
|
|
|
/**
|
|
* 是否开启推广分佣
|
|
* @return bool
|
|
*/
|
|
public static function isEnabled(): bool
|
|
{
|
|
//后台配置为 switch 类型,开启存 '1',关闭存 '0'
|
|
return (string)config('site.enable_promotion_commission') === '1';
|
|
}
|
|
|
|
/**
|
|
* 按订单类型获取佣金比例(0~1),为 0 表示该类订单不分佣
|
|
* - 软件订单:site.soft_promotion_commission_rate
|
|
* - 需求订单(首付款/尾款):site.promotion_commission_ratio
|
|
* @param int $orderType 订单类型 OrderService::ORDER_TYPE_*
|
|
* @return string
|
|
*/
|
|
public static function getRatio(int $orderType): string
|
|
{
|
|
$key = $orderType === OrderService::ORDER_TYPE_SOFTWARE ? 'site.soft_promotion_commission_rate' : 'site.promotion_commission_ratio';
|
|
|
|
return self::normalizeRatio(config($key));
|
|
}
|
|
|
|
/**
|
|
* 规范化比例配置,后台填写大于 1 时按百分比处理,如 10 表示 10%
|
|
* @param mixed $ratio
|
|
* @return string
|
|
*/
|
|
protected static function normalizeRatio($ratio): string
|
|
{
|
|
if (!is_numeric($ratio) || $ratio <= 0) {
|
|
return '0';
|
|
}
|
|
$ratio = (string)$ratio;
|
|
if (bccomp($ratio, '1', 4) > 0) {
|
|
$ratio = bcdiv($ratio, '100', 4);
|
|
}
|
|
|
|
return bccomp($ratio, '1', 4) > 0 ? '1' : bcadd($ratio, '0', 4);
|
|
}
|
|
|
|
/**
|
|
* 绑定推广关系(永久绑定,已绑定的用户不再变更),失败只记日志不抛异常,避免影响登录
|
|
* @param int $userId 当前用户ID
|
|
* @param int $promoterId 推广人用户ID
|
|
* @return bool 是否本次绑定成功
|
|
*/
|
|
public static function bindPromoter(int $userId, int $promoterId): bool
|
|
{
|
|
if ($userId <= 0 || $promoterId <= 0 || $userId === $promoterId) {
|
|
return false;
|
|
}
|
|
try {
|
|
$user = User::where('id', $userId)->field('id,promoter_id')->find();
|
|
if (!$user || $user->promoter_id > 0) {
|
|
return false;
|
|
}
|
|
$promoter = User::where('id', $promoterId)->field('id,promoter_id,status')->find();
|
|
if (!$promoter || ($promoter->status && $promoter->status !== 'normal')) {
|
|
return false;
|
|
}
|
|
//禁止互相绑定
|
|
if (intval($promoter->promoter_id) === $userId) {
|
|
return false;
|
|
}
|
|
//条件更新保证并发下只绑定一次
|
|
$res = User::where(['id' => $userId, 'promoter_id' => 0])->update([
|
|
'promoter_id' => $promoterId,
|
|
'promoter_bind_time' => time(),
|
|
]);
|
|
if ($res) {
|
|
Log::info(__CLASS__ . ' 绑定推广关系:' . json_encode(['user_id' => $userId, 'promoter_id' => $promoterId], JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
return (bool)$res;
|
|
} catch (\Exception $e) {
|
|
Log::error(__CLASS__ . ' 绑定推广关系失败:' . json_encode(['user_id' => $userId, 'promoter_id' => $promoterId, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取用户的推广人ID(用于下单快照)
|
|
* @param int $userId
|
|
* @return int
|
|
*/
|
|
public static function getPromoterId(int $userId): int
|
|
{
|
|
if ($userId <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return intval(User::where('id', $userId)->value('promoter_id'));
|
|
}
|
|
|
|
/**
|
|
* 结算满足条件的已完成订单佣金(可在外层事务中调用,内部使用保存点,单笔失败不影响外层业务)
|
|
* @param array $where 订单范围条件,如 ['parent_order_no' => 'xxx']
|
|
* @return void
|
|
*/
|
|
public static function settleOrders(array $where)
|
|
{
|
|
$orderIds = self::where($where)
|
|
->where('order_status', OrderService::ORDER_STATUS_FINISHED)
|
|
->where('promoter_id', '>', 0)
|
|
->where('commission_status', self::COMMISSION_STATUS_PENDING)
|
|
->column('id');
|
|
foreach ($orderIds as $orderId) {
|
|
self::settleOrder(intval($orderId));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 兜底:结算所有待结算的已完成推广订单(定时任务调用)
|
|
* @return void
|
|
*/
|
|
public static function settlePendingOrders()
|
|
{
|
|
self::where('order_status', OrderService::ORDER_STATUS_FINISHED)
|
|
->where('promoter_id', '>', 0)
|
|
->where('commission_status', self::COMMISSION_STATUS_PENDING)
|
|
->field('id')
|
|
->chunk(100, function ($orders) {
|
|
foreach ($orders as $order) {
|
|
self::settleOrder(intval($order->id));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 结算单笔订单佣金
|
|
* @param int $orderId
|
|
* @return bool 是否发放了佣金
|
|
*/
|
|
public static function settleOrder(int $orderId): bool
|
|
{
|
|
Db::startTrans();
|
|
try {
|
|
$order = self::where('id', $orderId)->lock(true)->find();
|
|
if (!$order
|
|
|| intval($order->commission_status) !== self::COMMISSION_STATUS_PENDING
|
|
|| intval($order->order_status) !== OrderService::ORDER_STATUS_FINISHED
|
|
|| intval($order->promoter_id) <= 0) {
|
|
Db::commit();
|
|
return false;
|
|
}
|
|
$promoterId = intval($order->promoter_id);
|
|
$ratio = self::isEnabled() ? self::getRatio(intval($order->order_type)) : '0';
|
|
$amount = bcmul((string)$order->payment_amount, $ratio, 2);
|
|
$isPaid = OrderPaymentService::where(['order_no' => $order->order_no, 'pay_status' => 'SUCCESS'])->count() > 0;
|
|
$skip = bccomp($amount, '0', 2) <= 0 || !$isPaid || $promoterId === intval($order->user_id);
|
|
|
|
$saveData = [
|
|
'commission_ratio' => $ratio,
|
|
'commission_amount' => $skip ? 0 : $amount,
|
|
'commission_status' => $skip ? self::COMMISSION_STATUS_SKIPPED : self::COMMISSION_STATUS_SETTLED,
|
|
'commission_time' => time(),
|
|
];
|
|
if (!$skip) {
|
|
self::ensureAccount($promoterId);
|
|
$title = OrderService::ORDER_TYPE_MAP[$order->order_type] ?? '订单';
|
|
$log = AccountLogService::addLog(
|
|
AccountLogService::ACCOUNT_TYPE_AMOUNT,
|
|
$promoterId,
|
|
$amount,
|
|
'推广佣金-' . $title . $order->order_no,
|
|
AccountLogService::TYPE_COMMISSION,
|
|
0,
|
|
AccountLogService::INC_EXP_INCREASE,
|
|
$order->id,
|
|
$order->payment_amount,
|
|
$ratio
|
|
);
|
|
AccountService::updateData($promoterId, $amount, 0, 0, 0, $log->id);
|
|
try {
|
|
MessageService::createData(
|
|
$promoterId,
|
|
'推广佣金到账',
|
|
'您推广的订单' . $order->order_no . '已完成,佣金' . $amount . '元已存入余额',
|
|
['page' => 'pages/promoter/index/index']
|
|
);
|
|
} catch (\Exception $e) {
|
|
Log::error(__CLASS__ . ' 佣金到账站内消息失败:' . json_encode(['order_id' => $orderId, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
|
|
}
|
|
}
|
|
$upRes = self::where(['id' => $orderId, 'commission_status' => self::COMMISSION_STATUS_PENDING])->update($saveData);
|
|
if (!$upRes) {
|
|
exception('更新订单佣金状态失败');
|
|
}
|
|
Db::commit();
|
|
Log::info(__CLASS__ . ' 推广佣金结算:' . json_encode(['order_id' => $orderId, 'promoter_id' => $promoterId] + $saveData, JSON_UNESCAPED_UNICODE));
|
|
|
|
return !$skip;
|
|
} catch (\Exception $e) {
|
|
Db::rollback();
|
|
Log::error(__CLASS__ . ' 推广佣金结算失败:' . json_encode(['order_id' => $orderId, 'error' => $e->getMessage(), 'file' => $e->getFile() . ':' . $e->getLine()], JSON_UNESCAPED_UNICODE));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 推广人账户不存在时补建(手机号注册的用户可能没有账户)
|
|
* @param int $userId
|
|
* @return void
|
|
*/
|
|
protected static function ensureAccount(int $userId)
|
|
{
|
|
if (!Accounts::where('user_id', $userId)->count()) {
|
|
Accounts::create(['user_id' => $userId]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 推广中心概览
|
|
* @param int $userId
|
|
* @return array
|
|
*/
|
|
public static function getOverview(int $userId): array
|
|
{
|
|
//TP5.0 克隆查询对象会共用参数绑定导致 HY093,每次都新建查询
|
|
$base = [
|
|
'promoter_id' => $userId,
|
|
'order_status' => ['in', self::VISIBLE_ORDER_STATUS],
|
|
];
|
|
$settledAmount = self::where($base)->where('commission_status', self::COMMISSION_STATUS_SETTLED)->sum('commission_amount');
|
|
$pendingCount = self::where($base)->where('commission_status', self::COMMISSION_STATUS_PENDING)->count();
|
|
$orderCount = self::where($base)->count();
|
|
|
|
return [
|
|
'enabled' => self::isEnabled(),
|
|
'soft_ratio_percent' => floatval(bcmul(self::getRatio(OrderService::ORDER_TYPE_SOFTWARE), '100', 2)),
|
|
'demand_ratio_percent' => floatval(bcmul(self::getRatio(OrderService::ORDER_TYPE_FIRST_PAYMENT), '100', 2)),
|
|
'invite_count' => User::where('promoter_id', $userId)->count(),
|
|
'order_count' => $orderCount,
|
|
'settled_amount' => bcadd((string)$settledAmount, '0', 2),
|
|
'pending_count' => $pendingCount,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 推广订单列表
|
|
* @param int $userId
|
|
* @param int $pageSize
|
|
* @return array
|
|
*/
|
|
public static function getOrderList(int $userId, int $pageSize = 10): array
|
|
{
|
|
$list = self::where('promoter_id', $userId)
|
|
->where('order_status', 'in', self::VISIBLE_ORDER_STATUS)
|
|
->field('id,user_id,order_no,order_type,order_status,payment_amount,commission_amount,commission_status,commission_time,createtime')
|
|
->order('id desc')
|
|
->paginate($pageSize)
|
|
->toArray();
|
|
$userIds = array_unique(array_column($list['data'], 'user_id'));
|
|
$users = $userIds ? User::where('id', 'in', $userIds)->column('nickname,avatar', 'id') : [];
|
|
foreach ($list['data'] as &$item) {
|
|
$buyer = $users[$item['user_id']] ?? [];
|
|
$item['buyer_nickname'] = $buyer['nickname'] ?? '';
|
|
$item['buyer_avatar'] = $buyer['avatar'] ?? '';
|
|
$item['order_type_text'] = OrderService::ORDER_TYPE_MAP[$item['order_type']] ?? '';
|
|
$item['commission_status'] = intval($item['commission_status']);
|
|
$item['commission_status_text'] = self::COMMISSION_STATUS_MAP[$item['commission_status']] ?? '';
|
|
$item['createtime_text'] = date('Y-m-d H:i', $item['createtime']);
|
|
unset($item['user_id']);
|
|
}
|
|
unset($item);
|
|
|
|
return $list;
|
|
}
|
|
|
|
/**
|
|
* 获取推广小程序码(scene=pid=用户ID),返回 data URL,结果缓存 7 天
|
|
* @param int $userId
|
|
* @param string $envVersion release|trial|develop
|
|
* @return string
|
|
* @throws \Exception
|
|
*/
|
|
public static function getQrcode(int $userId, string $envVersion = 'release'): string
|
|
{
|
|
if (!in_array($envVersion, self::ENV_VERSIONS, true)) {
|
|
$envVersion = 'release';
|
|
}
|
|
$cacheKey = self::QRCODE_CACHE_PREFIX . $userId . ':' . $envVersion;
|
|
if ($cached = Cache::get($cacheKey)) {
|
|
return $cached;
|
|
}
|
|
$response = (new EasyWeChatService())->app->getClient()->postJson('/wxa/getwxacodeunlimit', [
|
|
'scene' => self::QRCODE_SCENE_KEY . '=' . $userId,
|
|
'page' => self::QRCODE_PAGE,
|
|
'check_path' => $envVersion === 'release',
|
|
'env_version' => $envVersion,
|
|
'width' => 430,
|
|
]);
|
|
//成功返回图片二进制,失败返回 JSON
|
|
if (strpos($response->getHeaderLine('content-type', false), 'image') === false) {
|
|
$error = $response->toArray(false);
|
|
Log::error(__CLASS__ . ' 生成推广小程序码失败:' . json_encode(['user_id' => $userId, 'res' => $error], JSON_UNESCAPED_UNICODE));
|
|
throw new \Exception('生成推广码失败:' . ($error['errmsg'] ?? '未知错误'));
|
|
}
|
|
$dataUrl = $response->toDataUrl();
|
|
Cache::set($cacheKey, $dataUrl, self::QRCODE_CACHE_TTL);
|
|
|
|
return $dataUrl;
|
|
}
|
|
}
|