CRMEB/backend/app/services/user/UserMoneyServices.php
吴昊天 2cb4c258cd fix(余额通知): 变动类型改为中文标题,避免模板与金额重复展示
通过 UserMoneyServices 映射 type 标识为中文,未命中时回退「余额变动」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:24:25 +08:00

554 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
namespace app\services\user;
use app\dao\user\UserMoneyDao;
use app\dao\user\UserDao;
use crmeb\basic\BaseService;
use app\services\user\MemberOrderServices;
use app\dao\order\OrderCenterDao;
use crmeb\exceptions\AdminException;
use crmeb\exceptions\ApiException;
class UserMoneyServices extends BaseService
{
/**
* @var UserMoneyDao
*/
protected $dao;
/**
* 用户记录模板
* @var array[]
*/
protected $incomeData = [
'pay_product' => [
'title' => '余额支付购买商品',
'type' => 'pay_product',
'mark' => '余额支付{%num%}元购买商品',
'status' => 1,
'pm' => 0
],
'pay_member' => [
'title' => '余额支付购买会员',
'type' => 'pay_member',
'mark' => '余额支付{%num%}元购买会员',
'status' => 1,
'pm' => 0
],
'offline_scan' => [
'title' => '余额支付线下收银',
'type' => 'offline_scan',
'mark' => '余额支付{%num%}元线下收银',
'status' => 1,
'pm' => 0
],
'pay_product_refund' => [
'title' => '商品退款',
'type' => 'pay_product_refund',
'mark' => '订单退款到余额{%num%}元',
'status' => 1,
'pm' => 1
],
'system_add' => [
'title' => '系统增加余额',
'type' => 'system_add',
'mark' => '系统增加{%num%}余额',
'status' => 1,
'pm' => 1
],
'system_sub' => [
'title' => '系统减少余额',
'type' => 'system_sub',
'mark' => '系统扣除{%num%}余额',
'status' => 1,
'pm' => 0
],
'user_recharge' => [
'title' => '用户充值余额',
'type' => 'recharge',
'mark' => '成功充值余额{%price%}元,赠送{%give_price%}元',
'status' => 1,
'pm' => 1
],
'user_recharge_refund' => [
'title' => '用户充值退款',
'type' => 'recharge_refund',
'mark' => '退款扣除余额{%num%}元',
'status' => 1,
'pm' => 0
],
'user_recharge_refund_release' => [
'title' => '充值退款失败退回余额', 'type' => 'recharge_refund_release',
'mark' => '充值退款失败退回{%num%}元', 'status' => 1, 'pm' => 1
],
'brokerage_to_nowMoney' => [
'title' => '佣金提现到余额',
'type' => 'extract',
'mark' => '佣金提现到余额{%num%}元',
'status' => 1,
'pm' => 1
],
'lottery_use_money' => [
'title' => '参与抽奖使用余额',
'type' => 'lottery_use',
'mark' => '参与抽奖使用{%num%}余额',
'status' => 1,
'pm' => 0
],
'lottery_give_money' => [
'title' => '抽奖中奖赠送余额',
'type' => 'lottery_add',
'mark' => '抽奖中奖赠送{%num%}余额',
'status' => 1,
'pm' => 1
],
'register_system_add' => [
'title' => '新用户注册赠送余额',
'type' => 'register_system_add',
'mark' => '新用户注册赠送{%num%}余额',
'status' => 1,
'pm' => 1
],
];
/**
* UserMoneyServices constructor.
* @param UserMoneyDao $dao
*/
public function __construct(UserMoneyDao $dao)
{
$this->dao = $dao;
}
/**
* 余额流水类型 → 中文标题(按 incomeData 的 type 字段匹配,兼容按配置键查找)
* @param string $type
* @return string
*/
public function getTypeTitle(string $type): string
{
$type = trim($type);
if ($type === '') {
return '';
}
foreach ($this->incomeData as $key => $item) {
if (($item['type'] ?? '') === $type || (string)$key === $type) {
return (string)($item['title'] ?? $type);
}
}
return $type;
}
/**
* 全部余额类型中文映射(type => title)
* @return array<string, string>
*/
public function getTypeTitleMap(): array
{
$map = [];
foreach ($this->incomeData as $key => $item) {
$title = (string)($item['title'] ?? '');
if ($title === '') {
continue;
}
$map[(string)$key] = $title;
if (!empty($item['type'])) {
$map[(string)$item['type']] = $title;
}
}
return $map;
}
/**
* 写入用户记录
* @param string $type 写入类型
* @param int $uid
* @param int|string|array $number
* @param int|string $balance
* @param $linkId
* @param string $mark
* @return bool|mixed
*/
public function moneyIncome(string $type, int $uid, $number, $linkId, string $mark = '')
{
$data = $this->incomeData[$type] ?? null;
if (!$data) {
return true;
}
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$userInfo = $userServices->get($uid, ['now_money']);
if (!$userInfo) {
throw new ApiException('用户不存在');
}
// 当前余额
$currentMoney = $userInfo['now_money'];
if (is_array($number)) {
$key = array_keys($number);
$key = array_map(function ($item) {
return '{%' . $item . '%}';
}, $key);
$value = array_values($number);
$data['number'] = $number['number'] ?? 0;
$data['mark'] = $mark === '' ? str_replace($key, $value, $data['mark']) : $mark;
} else {
$data['number'] = $number;
$data['mark'] = $mark === '' ? str_replace(['{%num%}'], $number, $data['mark']) : $mark;
}
$data['uid'] = $uid;
$data['link_id'] = $linkId;
$data['add_time'] = time();
try {
// 所有余额变动都采用事务处理,保证数据一致性
return $this->transaction(function () use ($uid, $data, $number, $currentMoney, $userServices) {
$lockedUser = app()->make(UserDao::class)->getDb()->where('uid', $uid)->lock(true)->field('uid,now_money')->find();
if (!$lockedUser) throw new ApiException('用户不存在');
$currentMoney = $lockedUser['now_money'];
// 更新用户余额(使用乐观锁防止并发超扣)
if ($data['pm'] == 0) {
if (!$userServices->decrMoney($uid, $data['number'])) {
throw new ApiException('余额不足');
} else {
// 计算变动后的余额
$balance = bcsub((string)$currentMoney, (string)$data['number'], 2);
}
} else {
if (!$userServices->bcInc($uid, 'now_money', (string)$data['number'])) throw new \RuntimeException('余额更新失败');
// 计算变动后的余额
$balance = bcadd((string)$currentMoney, (string)$data['number'], 2);
}
$data['balance'] = $balance;
// 增加余额记录
if (!$this->dao->save($data)) throw new \RuntimeException('余额流水写入失败');
// 增加余额变动事件
event('UserBalanceChange', [$uid, $data['type'], $number, $balance, $data['mark']]);
return true;
});
} catch (\Exception $e) {
throw new ApiException($e->getMessage().$e->getLine());
}
}
/**
* 写入用户记录
* @param string $type 写入类型
* @param int $uid
* @param int|string|array $number
* @param int|string $balance
* @param $linkId
* @param string $mark
* @return bool|mixed
*/
public function income(string $type, int $uid, $number, $balance, $linkId, string $mark = '')
{
$data = $this->incomeData[$type] ?? null;
if (!$data) {
return true;
}
$data['uid'] = $uid;
$data['balance'] = $balance ?? 0;
$data['link_id'] = $linkId;
if (is_array($number)) {
$key = array_keys($number);
$key = array_map(function ($item) {
return '{%' . $item . '%}';
}, $key);
$value = array_values($number);
$data['number'] = $number['number'] ?? 0;
$data['mark'] = $mark === '' ? str_replace($key, $value, $data['mark']) : $mark;
} else {
$data['number'] = $number;
$data['mark'] = $mark === '' ? str_replace(['{%num%}'], $number, $data['mark']) : $mark;
}
$data['add_time'] = time();
try {
// 所有余额变动都采用事务处理,保证数据一致性
return $this->transaction(function () use ($uid, $data, $number, $balance) {
// 增加余额记录
$this->dao->save($data);
// 更新用户余额(使用乐观锁防止并发超扣)
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
// 扣款时使用条件更新(乐观锁),确保余额充足
if ($data['pm'] == 0) {
if (!$userServices->decrMoney($uid, $number)) {
throw new ApiException('余额不足');
}
} else {
$userServices->update($uid, ['now_money' => number_format((float)$balance, 2, '.', '')]);
}
// 增加余额变动事件
event('UserBalanceChange', [$uid,$data['type'], $number, $balance, $data['mark']]);
return true;
});
} catch (\Exception $e) {
throw new ApiException($e->getMessage().$e->getLine());
}
}
/**
* 余额记录
* @param $where
* @return array
*/
public function balanceList($where)
{
$status = [];
foreach ($this->incomeData as $value) {
$status[$value['type']] = $value['title'];
}
[$page, $limit] = $this->getPageValue();
$list = $this->dao->getList($where, $page, $limit);
//关联用户
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$uids = array_column($list, 'uid');
$nicknameArr = $userServices->getColumn([['uid', 'in', $uids]], 'nickname', 'uid');
//关联订单
/** @var OrderCenterDao $orderServices */
$orderServices = app()->make(OrderCenterDao::class);
/** @var MemberOrderServices $memberOrderServices */
$memberOrderServices = app()->make(MemberOrderServices::class);
foreach ($list as &$item) {
$item['nickname'] = $nicknameArr[$item['uid']];
if ($item['type'] == 'pay_product' || $item['type'] == 'pay_product_refund') {
$item['relation'] = $orderServices->value(['id' => $item['link_id']], 'order_no');
} elseif ($item['type'] == 'recharge' || $item['type'] == 'recharge_refund') {
/** @var UserRechargeServices $userRechargeServices */
$userRechargeServices = app()->make(UserRechargeServices::class);
$item['relation'] = $userRechargeServices->value(['id' => (int)$item['link_id']], 'order_id');
} elseif ($item['type'] == 'pay_member') {
$item['relation'] = $memberOrderServices->value(['id' => $item['link_id']], 'order_id');
} else {
$item['relation'] = $status[$item['type']];
}
$item['add_time'] = date('Y-m-d H:i:s', $item['add_time']);
$item['type_name'] = $status[$item['type']];
}
$count = $this->dao->count($where);
return compact('list', 'count', 'status');
}
/**
* 余额记录备注
* @param $data
* @return bool
*/
public function recordRemark($id, $mark)
{
if ($this->dao->update($id, ['mark' => $mark])) {
return true;
} else {
throw new AdminException('备注失败');
}
}
/**
* 余额统计基础
* @return array
* @throws \ReflectionException
*/
public function getBasic()
{
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$data['now_balance'] = $userServices->sum(['status' => 1], 'now_money', true);
$data['add_balance'] = $this->dao->sum([
['pm', '=', 1],
['type', 'in', ['system_add', 'recharge', 'extract', 'lottery_add', 'register_system_add']]
], 'number', false);
$data['sub_balance'] = bcsub($data['add_balance'], $data['now_balance'], 2);
return $data;
}
/**
* 余额趋势
* @param $where
* @return array
*/
public function getTrend($where)
{
$time = explode('-', $where['time']);
if (count($time) != 2) throw new AdminException('请选择时间');
$dayCount = (strtotime($time[1]) - strtotime($time[0])) / 86400 + 1;
$data = [];
if ($dayCount == 1) {
$data = $this->trend($time, 0);
} elseif ($dayCount > 1 && $dayCount <= 31) {
$data = $this->trend($time, 1);
} elseif ($dayCount > 31 && $dayCount <= 92) {
$data = $this->trend($time, 3);
} elseif ($dayCount > 92) {
$data = $this->trend($time, 30);
}
return $data;
}
/**
* 余额趋势
* @param $time
* @param $num
* @param false $excel
* @return array
*/
public function trend($time, $num, $excel = false)
{
if ($num == 0) {
$xAxis = ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'];
$timeType = '%H';
} elseif ($num != 0) {
$dt_start = strtotime($time[0]);
$dt_end = strtotime($time[1]);
while ($dt_start <= $dt_end) {
if ($num == 30) {
$xAxis[] = date('Y-m', $dt_start);
$dt_start = strtotime("+1 month", $dt_start);
$timeType = '%Y-%m';
} else {
$xAxis[] = date('m-d', $dt_start);
$dt_start = strtotime("+$num day", $dt_start);
$timeType = '%m-%d';
}
}
}
$time[1] = date("Y-m-d", strtotime("+1 day", strtotime($time[1])));
$point_add = array_column($this->dao->getBalanceTrend($time, $timeType, 'add_time', 'sum(number)', 'add'), 'num', 'days');
$point_sub = array_column($this->dao->getBalanceTrend($time, $timeType, 'add_time', 'sum(number)', 'sub'), 'num', 'days');
$data = $series = [];
foreach ($xAxis as $item) {
$data['余额积累'][] = isset($point_add[$item]) ? floatval($point_add[$item]) : 0;
$data['余额消耗'][] = isset($point_sub[$item]) ? floatval($point_sub[$item]) : 0;
}
foreach ($data as $key => $item) {
$series[] = [
'name' => $key,
'data' => $item,
'type' => 'line',
];
}
return compact('xAxis', 'series');
}
/**
* 余额来源
* @param $where
* @return array
*/
public function getChannel($where)
{
$bing_xdata = ['系统增加', '用户充值', '佣金提现', '抽奖赠送', '商品退款'];
$color = ['#64a1f4', '#3edeb5', '#70869f', '#ffc653', '#fc7d6a'];
$data = ['system_add', 'recharge', 'extract', 'lottery_add', 'pay_product_refund'];
$bing_data = [];
foreach ($data as $key => $item) {
$bing_data[] = [
'name' => $bing_xdata[$key],
'value' => $this->dao->sum(['pm' => 1, 'type' => $item, 'time' => $where['time']], 'number', true),
'itemStyle' => ['color' => $color[$key]]
];
}
$list = [];
$count = array_sum(array_column($bing_data, 'value'));
foreach ($bing_data as $key => $item) {
$list[] = [
'name' => $item['name'],
'value' => $item['value'],
'percent' => $count != 0 ? bcmul((string)bcdiv((string)$item['value'], (string)$count, 4), '100', 2) : 0,
];
}
array_multisort(array_column($list, 'value'), SORT_DESC, $list);
return compact('bing_xdata', 'bing_data', 'list');
}
/**
* 余额类型
* @param $where
* @return array
*/
public function getType($where)
{
$bing_xdata = ['系统减少', '充值退款', '购买商品', '购买会员'];
$color = ['#64a1f4', '#3edeb5', '#70869f', '#ffc653'];
$data = ['system_sub', 'recharge_refund', 'pay_product', 'pay_member'];
$bing_data = [];
foreach ($data as $key => $item) {
$bing_data[] = [
'name' => $bing_xdata[$key],
'value' => $this->dao->sum(['pm' => 0, 'type' => $item, 'time' => $where['time']], 'number', true),
'itemStyle' => ['color' => $color[$key]]
];
}
$list = [];
$count = array_sum(array_column($bing_data, 'value'));
foreach ($bing_data as $key => $item) {
$list[] = [
'name' => $item['name'],
'value' => $item['value'],
'percent' => $count != 0 ? bcmul((string)bcdiv((string)$item['value'], (string)$count, 4), '100', 2) : 0,
];
}
array_multisort(array_column($list, 'value'), SORT_DESC, $list);
return compact('bing_xdata', 'bing_data', 'list');
}
public function getMoneyList($uid, $type)
{
$where = [];
$where['uid'] = $uid;
[$page, $limit] = $this->getPageValue();
if ($type == 1) {
$where['pm'] = 0;
} elseif ($type == 2) {
$where['pm'] = 1;
$where['not_type'] = ['pay_product_refund'];
}
$list = $this->dao->getList($where, $page, $limit);
$count = $this->dao->count($where);
$times = [];
if ($list) {
foreach ($list as &$item) {
$item['time'] = $item['time_key'] = $item['add_time'] ? date('Y-m', (int)$item['add_time']) : '';
$item['add_time'] = $item['add_time'] ? date('Y-m-d H:i', (int)$item['add_time']) : '';
}
$times = array_merge(array_unique(array_column($list, 'time_key')));
}
return ['list' => $list, 'time' => $times, 'count' => $count];
}
/**
* 根据查询用户充值金额
* @param array $where
* @param string $rechargeSumField
* @param string $selectType
* @param string $group
* @return float|mixed
*/
public function getRechargeMoneyByWhere(array $where, string $rechargeSumField, string $selectType, string $group = "")
{
switch ($selectType) {
case "sum" :
return $this->dao->getWhereSumField($where, $rechargeSumField);
case "group" :
return $this->dao->getGroupField($where, $rechargeSumField, $group);
}
}
}