完善接口和后台页面

This commit is contained in:
2026-04-18 15:19:36 +08:00
parent a4878a9bbd
commit e3f26ba1f7
45 changed files with 3071 additions and 232 deletions

View File

@@ -9,6 +9,13 @@ use Webman\Http\Request as WebmanRequest;
/**
* 充值订单
*
* 订单的"由 0 转 1成功入账"统一走 app\common\library\finance\DepositSettlement。
* 当前充值接口为 mock 支付网关,点击即成功;后台不再保留人工审核按钮,
* 如需人工补单,请通过后续专门的"补单/冲正"工具完成,而不是在这个 CRUD 里直接改 status。
*
* 编辑入口现在只用于"查看详情"GET 返回订单 + 关联的 user/channel 信息,
* 阻止 POST 任何改字段的动作(保证金额、状态只能由结算服务变更)。
*/
class DepositOrder extends Backend
{
@@ -18,7 +25,7 @@ class DepositOrder extends Backend
protected bool $modelSceneValidate = true;
protected string|array $quickSearchField = ['id', 'order_no', 'pay_channel', 'remark'];
protected string|array $quickSearchField = ['id', 'order_no', 'pay_channel', 'remark', 'deposit_tier_id', 'idempotency_key'];
protected string|array $defaultSortField = ['id' => 'desc'];
@@ -65,6 +72,69 @@ class DepositOrder extends Backend
]);
}
/**
* GET 时返回关联信息,便于前端详情弹窗直接渲染 user.username / channel.name
* POST 一律拒绝,保证充值订单的金额/状态只能由结算服务变更。
*/
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') {
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]);
}
private function loadWithRelations(int $id): ?array
{
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
])
->where($this->model->getTable() . '.id', $id)
->find();
if (!$row) {
return null;
}
return $row->toArray();
}
private function checkChannelScoped(array $row): bool
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
return true;
}
$channelIds = $this->getScopedChannelIdsForFilter();
if ($channelIds === []) {
return false;
}
$raw = $row['channel_id'] ?? null;
if ($raw === null || $raw === '') {
return false;
}
if (!is_numeric(strval($raw))) {
return false;
}
return in_array(intval(strval($raw)), $channelIds, true);
}
/**
* @return int[]
*/

View File

@@ -5,10 +5,16 @@ 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_orderstatus=0与 withdraw 流水direction=2
* - 管理员在后台审核通过approve→ status=1拒绝reject→ status=2 并回冲用户余额与流水。
* - 通过流程不再额外扣钱包,因为申请时已冻结;仅在管理员调整 amount/fee 时写一条差额流水。
*/
class WithdrawOrder extends Backend
{
@@ -66,6 +72,387 @@ class WithdrawOrder extends Backend
]);
}
/**
* 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/feeactual_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', 4) <= 0) {
return $this->error('申请金额必须大于 0');
}
if (bccomp($newFee, '0', 4) < 0) {
return $this->error('手续费不能为负');
}
if (bccomp($newFee, $newAmount, 4) > 0) {
return $this->error('手续费不能大于申请金额');
}
$newActual = bcsub($newAmount, $newFee, 4);
$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', 4);
$diff = bcsub($newAmount, $oldAmount, 4);
$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', 4);
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', 4);
if (bccomp($beforeCoin, $diff, 4) < 0) {
Db::rollback();
return $this->error('用户余额不足以补扣调整差额');
}
$afterCoin = bcsub($beforeCoin, $diff, 4);
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, 4);
$userRow = Db::name('user')->where('id', $userId)->find();
if (!$userRow) {
Db::rollback();
return $this->error('关联用户不存在');
}
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 4);
$afterCoin = bcadd($beforeCoin, $abs, 4);
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 += amounttotal_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', 4);
$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', 4);
$afterCoin = bcadd($beforeCoin, $amount, 4);
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', 4);
}
return bcadd(strval($raw), '0', 4);
}
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);
}
/**
* 把 4 位小数金额压缩成最多 2 位小数用于展示(不影响落库精度)
*/
private function shortAmount(string $amount): string
{
if (!is_numeric($amount)) {
return $amount;
}
$normalized = bcadd($amount, '0', 4);
$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[]
*/