475 lines
18 KiB
PHP
475 lines
18 KiB
PHP
<?php
|
||
|
||
namespace app\admin\controller\order;
|
||
|
||
use app\common\controller\Backend;
|
||
use support\think\Db;
|
||
use support\Response;
|
||
use Throwable;
|
||
use Webman\Http\Request as WebmanRequest;
|
||
|
||
/**
|
||
* 提现订单
|
||
*
|
||
* 当前审核流转:
|
||
* - 用户端提交提现时,立即冻结余额(user.coin - apply_amount)并生成 withdraw_order(status=0)与 withdraw 流水(direction=2)。
|
||
* - 管理员在后台审核:通过(approve)→ status=1;拒绝(reject)→ status=2 并回冲用户余额与流水。
|
||
* - 通过流程不再额外扣钱包,因为申请时已冻结;仅在管理员调整 amount/fee 时写一条差额流水。
|
||
*/
|
||
class WithdrawOrder extends Backend
|
||
{
|
||
protected ?object $model = null;
|
||
|
||
protected bool $modelValidate = true;
|
||
|
||
protected bool $modelSceneValidate = true;
|
||
|
||
protected string|array $quickSearchField = ['id', 'order_no', 'idempotency_key', 'receive_type', 'receive_account', 'remark'];
|
||
|
||
protected string|array $defaultSortField = ['id' => 'desc'];
|
||
|
||
protected string|array $orderGuarantee = ['id' => 'desc'];
|
||
|
||
protected array $withJoinTable = ['user', 'channel', 'reviewAdmin'];
|
||
|
||
protected function initController(WebmanRequest $request): ?Response
|
||
{
|
||
$this->model = new \app\common\model\WithdrawOrder();
|
||
return null;
|
||
}
|
||
|
||
protected function _index(): Response
|
||
{
|
||
if ($this->request && $this->request->get('select')) {
|
||
return $this->select($this->request);
|
||
}
|
||
|
||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||
$table = strtolower($this->model->getTable());
|
||
$mainShort = $alias[$table] ?? '';
|
||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||
$channelIds = $this->getScopedChannelIdsForFilter();
|
||
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
|
||
}
|
||
|
||
$res = $this->model
|
||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||
->with($this->withJoinTable)
|
||
->visible([
|
||
'user' => ['username', 'phone'],
|
||
'channel' => ['name'],
|
||
'reviewAdmin' => ['username'],
|
||
])
|
||
->alias($alias)
|
||
->where($where)
|
||
->order($order)
|
||
->paginate($limit);
|
||
|
||
return $this->success('', [
|
||
'list' => $res->items(),
|
||
'total' => $res->total(),
|
||
'remark' => get_route_remark(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* GET 时返回关联信息,便于编辑弹窗直接渲染 user.username/channel.name
|
||
*/
|
||
protected function _edit(): Response
|
||
{
|
||
$pk = $this->model->getPk();
|
||
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
|
||
if ($id === null || $id === '') {
|
||
return $this->error(__('Parameter error'));
|
||
}
|
||
|
||
if ($this->request && $this->request->method() === 'POST') {
|
||
// 历史 CRUD 的 POST 编辑已被 approve/reject 替代,这里阻止直接改金额绕过审核流程
|
||
return $this->error('请使用通过/拒绝按钮完成审核');
|
||
}
|
||
|
||
$row = $this->loadWithRelations(intval(strval($id)));
|
||
if (!$row) {
|
||
return $this->error(__('Record not found'));
|
||
}
|
||
if (!$this->checkChannelScoped($row)) {
|
||
return $this->error(__('You have no permission'));
|
||
}
|
||
return $this->success('', ['row' => $row]);
|
||
}
|
||
|
||
/**
|
||
* 审核通过:允许调整 amount/fee;actual_amount 自动为 amount - fee。
|
||
* 对金额差额自动在用户钱包与流水中做增减,保持账务平衡。
|
||
*/
|
||
public function approve(WebmanRequest $request): Response
|
||
{
|
||
$response = $this->initializeBackend($request);
|
||
if ($response !== null) {
|
||
return $response;
|
||
}
|
||
if ($request->method() !== 'POST') {
|
||
return $this->error(__('Parameter error'));
|
||
}
|
||
|
||
$id = $this->intParam($request->post('id'));
|
||
if ($id <= 0) {
|
||
return $this->error(__('Parameter error'));
|
||
}
|
||
|
||
$newAmount = $this->decimalParam($request->post('amount'), '0');
|
||
$newFee = $this->decimalParam($request->post('fee'), '0');
|
||
if (bccomp($newAmount, '0', 2) <= 0) {
|
||
return $this->error('申请金额必须大于 0');
|
||
}
|
||
if (bccomp($newFee, '0', 2) < 0) {
|
||
return $this->error('手续费不能为负');
|
||
}
|
||
if (bccomp($newFee, $newAmount, 2) > 0) {
|
||
return $this->error('手续费不能大于申请金额');
|
||
}
|
||
$newActual = bcsub($newAmount, $newFee, 2);
|
||
|
||
$remarkRaw = $request->post('remark');
|
||
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
|
||
|
||
$order = Db::name('withdraw_order')->where('id', $id)->find();
|
||
if (!$order) {
|
||
return $this->error(__('Record not found'));
|
||
}
|
||
if (!$this->checkChannelScoped($order)) {
|
||
return $this->error(__('You have no permission'));
|
||
}
|
||
$currentStatus = $this->intParam($order['status'] ?? 0);
|
||
if ($currentStatus !== 0) {
|
||
return $this->error('该订单已审核,无需重复操作');
|
||
}
|
||
|
||
$userId = $this->intParam($order['user_id'] ?? 0);
|
||
if ($userId <= 0) {
|
||
return $this->error('订单缺少用户信息');
|
||
}
|
||
$oldAmount = bcadd(strval($order['amount'] ?? '0'), '0', 2);
|
||
$diff = bcsub($newAmount, $oldAmount, 2);
|
||
|
||
$now = time();
|
||
$adminId = $this->intParam($this->auth->id ?? 0);
|
||
$adminName = $this->adminDisplayName();
|
||
$channelIdRaw = $order['channel_id'] ?? null;
|
||
$channelId = ($channelIdRaw === null || $channelIdRaw === '')
|
||
? null
|
||
: $this->intParam($channelIdRaw);
|
||
if ($remark === '') {
|
||
$remark = '管理员(' . $adminName . ')审核通过:金额 '
|
||
. $this->shortAmount($newAmount) . ',手续费 ' . $this->shortAmount($newFee)
|
||
. ',实际到账 ' . $this->shortAmount($newActual);
|
||
}
|
||
|
||
Db::startTrans();
|
||
try {
|
||
// 金额调整差额处理
|
||
$cmp = bccomp($diff, '0', 2);
|
||
if ($cmp > 0) {
|
||
// 新金额更大:再冻结用户 diff
|
||
$userRow = Db::name('user')->where('id', $userId)->find();
|
||
if (!$userRow) {
|
||
Db::rollback();
|
||
return $this->error('关联用户不存在');
|
||
}
|
||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||
if (bccomp($beforeCoin, $diff, 2) < 0) {
|
||
Db::rollback();
|
||
return $this->error('用户余额不足以补扣调整差额');
|
||
}
|
||
$afterCoin = bcsub($beforeCoin, $diff, 2);
|
||
Db::name('user')->where('id', $userId)->update([
|
||
'coin' => $afterCoin,
|
||
'total_withdraw_coin' => Db::raw('total_withdraw_coin + ' . $diff),
|
||
'update_time' => $now,
|
||
]);
|
||
Db::name('user_wallet_record')->insert([
|
||
'user_id' => $userId,
|
||
'channel_id' => $channelId,
|
||
'biz_type' => 'withdraw',
|
||
'direction' => 2,
|
||
'amount' => $diff,
|
||
'balance_before' => $beforeCoin,
|
||
'balance_after' => $afterCoin,
|
||
'ref_type' => 'withdraw_order',
|
||
'ref_id' => $id,
|
||
'idempotency_key' => 'wd_adjust_add_' . strval($order['order_no'] ?? $id) . '_' . $now,
|
||
'operator_admin_id' => $adminId > 0 ? $adminId : null,
|
||
'remark' => '管理员(' . $adminName . ')审核调增申请金额差额 '
|
||
. $this->shortAmount($diff),
|
||
'create_time' => $now,
|
||
]);
|
||
} elseif ($cmp < 0) {
|
||
// 新金额更小:退回差额
|
||
$abs = bcsub('0', $diff, 2);
|
||
$userRow = Db::name('user')->where('id', $userId)->find();
|
||
if (!$userRow) {
|
||
Db::rollback();
|
||
return $this->error('关联用户不存在');
|
||
}
|
||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||
$afterCoin = bcadd($beforeCoin, $abs, 2);
|
||
Db::name('user')->where('id', $userId)->update([
|
||
'coin' => $afterCoin,
|
||
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $abs),
|
||
'update_time' => $now,
|
||
]);
|
||
Db::name('user_wallet_record')->insert([
|
||
'user_id' => $userId,
|
||
'channel_id' => $channelId,
|
||
'biz_type' => 'withdraw_refund',
|
||
'direction' => 1,
|
||
'amount' => $abs,
|
||
'balance_before' => $beforeCoin,
|
||
'balance_after' => $afterCoin,
|
||
'ref_type' => 'withdraw_order',
|
||
'ref_id' => $id,
|
||
'idempotency_key' => 'wd_adjust_sub_' . strval($order['order_no'] ?? $id) . '_' . $now,
|
||
'operator_admin_id' => $adminId > 0 ? $adminId : null,
|
||
'remark' => '管理员(' . $adminName . ')审核调减申请金额差额 '
|
||
. $this->shortAmount($abs),
|
||
'create_time' => $now,
|
||
]);
|
||
}
|
||
|
||
Db::name('withdraw_order')->where('id', $id)->update([
|
||
'amount' => $newAmount,
|
||
'fee' => $newFee,
|
||
'actual_amount' => $newActual,
|
||
'status' => 1,
|
||
'review_admin_id' => $adminId > 0 ? $adminId : null,
|
||
'review_time' => $now,
|
||
'remark' => substr($remark, 0, 255),
|
||
'update_time' => $now,
|
||
]);
|
||
Db::commit();
|
||
} catch (Throwable $e) {
|
||
Db::rollback();
|
||
return $this->error($e->getMessage());
|
||
}
|
||
|
||
return $this->success('审核通过', [
|
||
'id' => $id,
|
||
'amount' => $newAmount,
|
||
'fee' => $newFee,
|
||
'actual_amount' => $newActual,
|
||
'status' => 1,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 审核拒绝:必须填写驳回原因(remark)。
|
||
* 回冲申请时的冻结:user.coin += amount;total_withdraw_coin -= amount;写一条 withdraw_refund 流水。
|
||
*/
|
||
public function reject(WebmanRequest $request): Response
|
||
{
|
||
$response = $this->initializeBackend($request);
|
||
if ($response !== null) {
|
||
return $response;
|
||
}
|
||
if ($request->method() !== 'POST') {
|
||
return $this->error(__('Parameter error'));
|
||
}
|
||
|
||
$id = $this->intParam($request->post('id'));
|
||
if ($id <= 0) {
|
||
return $this->error(__('Parameter error'));
|
||
}
|
||
$remarkRaw = $request->post('remark');
|
||
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
|
||
if ($remark === '') {
|
||
return $this->error('请填写拒绝原因');
|
||
}
|
||
|
||
$order = Db::name('withdraw_order')->where('id', $id)->find();
|
||
if (!$order) {
|
||
return $this->error(__('Record not found'));
|
||
}
|
||
if (!$this->checkChannelScoped($order)) {
|
||
return $this->error(__('You have no permission'));
|
||
}
|
||
$currentStatus = $this->intParam($order['status'] ?? 0);
|
||
if ($currentStatus !== 0) {
|
||
return $this->error('该订单已审核,无需重复操作');
|
||
}
|
||
|
||
$userId = $this->intParam($order['user_id'] ?? 0);
|
||
if ($userId <= 0) {
|
||
return $this->error('订单缺少用户信息');
|
||
}
|
||
$amount = bcadd(strval($order['amount'] ?? '0'), '0', 2);
|
||
$channelIdRaw = $order['channel_id'] ?? null;
|
||
$channelId = ($channelIdRaw === null || $channelIdRaw === '')
|
||
? null
|
||
: $this->intParam($channelIdRaw);
|
||
|
||
$now = time();
|
||
$adminId = $this->intParam($this->auth->id ?? 0);
|
||
$adminName = $this->adminDisplayName();
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$userRow = Db::name('user')->where('id', $userId)->find();
|
||
if (!$userRow) {
|
||
Db::rollback();
|
||
return $this->error('关联用户不存在');
|
||
}
|
||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||
$afterCoin = bcadd($beforeCoin, $amount, 2);
|
||
Db::name('user')->where('id', $userId)->update([
|
||
'coin' => $afterCoin,
|
||
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amount),
|
||
'update_time' => $now,
|
||
]);
|
||
|
||
Db::name('user_wallet_record')->insert([
|
||
'user_id' => $userId,
|
||
'channel_id' => $channelId,
|
||
'biz_type' => 'withdraw_refund',
|
||
'direction' => 1,
|
||
'amount' => $amount,
|
||
'balance_before' => $beforeCoin,
|
||
'balance_after' => $afterCoin,
|
||
'ref_type' => 'withdraw_order',
|
||
'ref_id' => $id,
|
||
'idempotency_key' => 'wd_reject_' . strval($order['order_no'] ?? $id) . '_' . $now,
|
||
'operator_admin_id' => $adminId > 0 ? $adminId : null,
|
||
'remark' => '管理员(' . $adminName . ')驳回提现,退回冻结金额 '
|
||
. $this->shortAmount($amount) . ':' . $remark,
|
||
'create_time' => $now,
|
||
]);
|
||
|
||
Db::name('withdraw_order')->where('id', $id)->update([
|
||
'status' => 2,
|
||
'review_admin_id' => $adminId > 0 ? $adminId : null,
|
||
'review_time' => $now,
|
||
'remark' => substr($remark, 0, 255),
|
||
'update_time' => $now,
|
||
]);
|
||
Db::commit();
|
||
} catch (Throwable $e) {
|
||
Db::rollback();
|
||
return $this->error($e->getMessage());
|
||
}
|
||
|
||
return $this->success('审核已拒绝', [
|
||
'id' => $id,
|
||
'status' => 2,
|
||
'remark' => $remark,
|
||
]);
|
||
}
|
||
|
||
private function loadWithRelations(int $id): ?array
|
||
{
|
||
$row = $this->model
|
||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||
->with($this->withJoinTable)
|
||
->visible([
|
||
'user' => ['username', 'phone'],
|
||
'channel' => ['name'],
|
||
'reviewAdmin' => ['username'],
|
||
])
|
||
->where($this->model->getTable() . '.id', $id)
|
||
->find();
|
||
if (!$row) {
|
||
return null;
|
||
}
|
||
return $row->toArray();
|
||
}
|
||
|
||
private function checkChannelScoped(array|object $row): bool
|
||
{
|
||
if (!$this->auth || $this->auth->isSuperAdmin()) {
|
||
return true;
|
||
}
|
||
$channelIds = $this->getScopedChannelIdsForFilter();
|
||
if ($channelIds === []) {
|
||
return false;
|
||
}
|
||
$raw = is_array($row) ? ($row['channel_id'] ?? null) : ($row->channel_id ?? null);
|
||
if ($raw === null || $raw === '') {
|
||
// 无归属渠道的数据只有超管可见
|
||
return false;
|
||
}
|
||
$cid = $this->intParam($raw);
|
||
return in_array($cid, $channelIds, true);
|
||
}
|
||
|
||
private function intParam($raw): int
|
||
{
|
||
if ($raw === null || $raw === '') {
|
||
return 0;
|
||
}
|
||
if (!is_numeric(strval($raw))) {
|
||
return 0;
|
||
}
|
||
return intval(strval($raw));
|
||
}
|
||
|
||
private function decimalParam($raw, string $default): string
|
||
{
|
||
if ($raw === null || $raw === '' || !is_numeric(strval($raw))) {
|
||
return bcadd($default, '0', 2);
|
||
}
|
||
return bcadd(strval($raw), '0', 2);
|
||
}
|
||
|
||
private function adminDisplayName(): string
|
||
{
|
||
if (!$this->auth) {
|
||
return 'admin';
|
||
}
|
||
$name = $this->auth->username ?? null;
|
||
if (is_string($name) && $name !== '') {
|
||
return $name;
|
||
}
|
||
$id = $this->intParam($this->auth->id ?? 0);
|
||
return '#' . strval($id);
|
||
}
|
||
|
||
/**
|
||
* 把 2 位小数金额压缩成最多 2 位小数用于展示(不影响落库精度)
|
||
*/
|
||
private function shortAmount(string $amount): string
|
||
{
|
||
if (!is_numeric($amount)) {
|
||
return $amount;
|
||
}
|
||
$normalized = bcadd($amount, '0', 2);
|
||
$negative = false;
|
||
if (str_starts_with($normalized, '-')) {
|
||
$negative = true;
|
||
$normalized = substr($normalized, 1);
|
||
}
|
||
$parts = explode('.', $normalized, 2);
|
||
$intPart = $parts[0] ?? '0';
|
||
$fracPart = $parts[1] ?? '0000';
|
||
$displayFrac = substr($fracPart, 0, 2);
|
||
$v = $intPart . '.' . str_pad($displayFrac, 2, '0');
|
||
return $negative ? ('-' . $v) : $v;
|
||
}
|
||
|
||
/**
|
||
* @return int[]
|
||
*/
|
||
private function getScopedChannelIdsForFilter(): array
|
||
{
|
||
if (!$this->auth) {
|
||
return [0];
|
||
}
|
||
if ($this->auth->isSuperAdmin()) {
|
||
return [];
|
||
}
|
||
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $this->auth->id)->find();
|
||
$ids = [];
|
||
if ($admin && !empty($admin['channel_id'])) {
|
||
$ids[] = $admin['channel_id'];
|
||
}
|
||
return array_values(array_unique($ids));
|
||
}
|
||
}
|