修复提现申请冻结余额

This commit is contained in:
yml2213
2026-08-23 16:51:32 +08:00
parent b49fa12484
commit 844f071aab
10 changed files with 409 additions and 42 deletions
@@ -0,0 +1,11 @@
-- 提现申请改为"申请即冻结"资金模型:
-- 1) worker_wallets 新增 frozen_withdraw_amount,申请提现立即从 available_amount 划转到该字段;
-- 2) worker_finance_requests 新增 frozen_amount 标记该笔申请是否已冻结资金(0=旧数据未冻结),
-- 审核通过/拒绝按此标记选择解冻或旧口径扣款,保证存量数据平滑过渡;
-- 3) 仅对迁移后创建的申请生效,存量申请仍按旧口径在审核通过时扣款。
ALTER TABLE worker_wallets
ADD COLUMN IF NOT EXISTS frozen_withdraw_amount INTEGER NOT NULL DEFAULT 0;
ALTER TABLE worker_finance_requests
ADD COLUMN IF NOT EXISTS frozen_amount INTEGER NOT NULL DEFAULT 0;
@@ -64,6 +64,7 @@ export type WorkerWalletRow = {
worker_id: number
available_amount: number
frozen_deposit_amount: number
frozen_withdraw_amount: number
pending_unfreeze_amount: number
total_credited_amount: number
total_settled_amount: number
@@ -204,6 +205,8 @@ export type WorkerFinanceRequestRow = {
request_type: string
status: string
amount: number
/** 申请时已冻结的资金额度;0 表示旧数据未冻结(迁移前遗留)。 */
frozen_amount: number
account_channel: string
account_name: string
account_no: string
@@ -67,6 +67,91 @@ export async function createWorkerFinanceRequest(input: {
return result.rows[0] || null
}
/**
* 创建提现申请并在同一事务内立即冻结资金:
* available_amount 划入 frozen_withdraw_amount,杜绝"待提现 + 接单押金"双重占用余额。
*/
export async function createWorkerWithdrawRequestWithFreeze(input: {
workerId: number
amount: number
accountChannel: string
accountName: string
accountNo: string
note: string
payloadJson: string
now: string
}): Promise<
| {
request: WorkerFinanceRequestRow | null
failureReason: 'withdraw_insufficient'
}
| { request: WorkerFinanceRequestRow; failureReason: null }
> {
return withTransaction(async (client) => {
await ensureWorkerWalletWithClient(client, Number(input.workerId), input.now)
const wallet = await getWorkerWalletWithClient(client, Number(input.workerId))
const availableAmount = Number(wallet?.available_amount || 0)
const frozenWithdrawAmount = Number(wallet?.frozen_withdraw_amount || 0)
if (availableAmount < input.amount) {
return { request: null, failureReason: 'withdraw_insufficient' }
}
const nextAvailable = availableAmount - input.amount
const nextFrozenWithdraw = frozenWithdrawAmount + input.amount
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, frozen_withdraw_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, nextFrozenWithdraw, input.now, Number(input.workerId)],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
note, payload_json, created_at
) VALUES ($1, 'withdraw_freeze', $2, $3, $4, $5, '{}'::jsonb, $6)
`,
[
Number(input.workerId),
-input.amount,
nextAvailable,
nextFrozenWithdraw,
'提现申请冻结余额',
input.now,
],
)
const createdResult = await client.query<WorkerFinanceRequestRow>(
`
INSERT INTO worker_finance_requests (
worker_id, request_type, status, amount, frozen_amount,
account_channel, account_name, account_no,
note, reviewed_note, payload_json, created_at, updated_at
) VALUES ($1, 'withdraw', 'pending', $2, $2, $3, $4, $5, $6, '', $7::jsonb, $8, $8)
RETURNING *
`,
[
Number(input.workerId),
input.amount,
input.accountChannel,
input.accountName,
input.accountNo,
input.note,
input.payloadJson,
input.now,
],
)
const request = createdResult.rows[0] || null
if (!request) {
throw new Error('提现申请创建失败')
}
return { request, failureReason: null }
})
}
export async function getWorkerWithdrawalAccount(
workerId: number | string,
accountChannel: string,
@@ -314,20 +399,30 @@ export async function reviewWorkerFinanceRequest(input: {
(current.request_type === 'withdraw' ? '提现申请审核通过' : '充值申请审核通过')
if (current.request_type === 'withdraw') {
if (availableAmount < amount) {
const plan = resolveWithdrawApprovalPlan({
requestFrozenAmount: Number(current.frozen_amount || 0),
frozenWithdrawAmount: Number(wallet?.frozen_withdraw_amount || 0),
availableAmount,
amount,
})
if (!plan.ok) {
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: 'withdraw_insufficient',
failureReason: plan.reason,
}
}
const nextAvailable = availableAmount - amount
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, updated_at = $2
WHERE worker_id = $3
SET available_amount = $1, frozen_withdraw_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, input.now, Number(current.worker_id)],
[
plan.nextAvailableAmount,
plan.nextFrozenWithdrawAmount,
input.now,
Number(current.worker_id),
],
)
await client.query(
`
@@ -338,10 +433,10 @@ export async function reviewWorkerFinanceRequest(input: {
`,
[
Number(current.worker_id),
-amount,
nextAvailable,
frozenAmount,
note,
plan.ledgerAmount,
plan.nextAvailableAmount,
plan.nextFrozenWithdrawAmount,
plan.mode === 'frozen' ? `${note}(申请时已冻结,本次确认打款)` : note,
JSON.stringify({
requestId: Number(current.id),
requestType: current.request_type,
@@ -385,6 +480,59 @@ export async function reviewWorkerFinanceRequest(input: {
],
)
}
} else if (
input.status === 'rejected' &&
current.request_type === 'withdraw' &&
Number(current.frozen_amount || 0) > 0
) {
// 新口径:拒绝提现申请时把申请冻结的资金退回可用余额。
await ensureWorkerWalletWithClient(client, Number(current.worker_id), input.now)
const wallet = await getWorkerWalletWithClient(client, Number(current.worker_id))
const release = resolveWithdrawRejectionPlan({
requestFrozenAmount: Number(current.frozen_amount || 0),
frozenWithdrawAmount: Number(wallet?.frozen_withdraw_amount || 0),
availableAmount: Number(wallet?.available_amount || 0),
})
if (!release.ok) {
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: release.reason,
}
}
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, frozen_withdraw_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[
release.nextAvailableAmount,
release.nextFrozenWithdrawAmount,
input.now,
Number(current.worker_id),
],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
audit_status, note, payload_json, created_at
) VALUES ($1, 'withdraw_release', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
`,
[
Number(current.worker_id),
release.releaseAmount,
release.nextAvailableAmount,
release.nextFrozenWithdrawAmount,
`${input.reviewedNote || '提现申请审核未通过'},冻结余额已退回`,
JSON.stringify({
requestId: Number(current.id),
requestType: current.request_type,
source: 'admin_finance_review',
}),
input.now,
],
)
}
await client.query(
@@ -525,3 +673,72 @@ function buildWorkerFinanceRequestWhere({
params,
}
}
export type WithdrawApprovalPlan =
| {
ok: true
mode: 'frozen' | 'legacy'
nextAvailableAmount: number
nextFrozenWithdrawAmount: number
ledgerAmount: number
}
| { ok: false; reason: 'withdraw_insufficient' }
/**
* 提现审核通过的资金划转计划。
* - frozen 模式(新数据):申请时已冻结,打款确认仅解冻,不动可用余额;
* - legacy 模式(迁移前旧数据 frozen_amount=0):沿用"确认时从可用余额扣款"口径。
*/
export function resolveWithdrawApprovalPlan(input: {
requestFrozenAmount: number
frozenWithdrawAmount: number
availableAmount: number
amount: number
}): WithdrawApprovalPlan {
if (input.requestFrozenAmount > 0) {
if (input.frozenWithdrawAmount < input.requestFrozenAmount) {
return { ok: false, reason: 'withdraw_insufficient' }
}
return {
ok: true,
mode: 'frozen',
nextAvailableAmount: input.availableAmount,
nextFrozenWithdrawAmount: input.frozenWithdrawAmount - input.requestFrozenAmount,
ledgerAmount: 0,
}
}
if (input.availableAmount < input.amount) {
return { ok: false, reason: 'withdraw_insufficient' }
}
return {
ok: true,
mode: 'legacy',
nextAvailableAmount: input.availableAmount - input.amount,
nextFrozenWithdrawAmount: input.frozenWithdrawAmount,
ledgerAmount: -input.amount,
}
}
/** 提现审核拒绝时的资金退回计划。 */
export function resolveWithdrawRejectionPlan(input: {
requestFrozenAmount: number
frozenWithdrawAmount: number
availableAmount: number
}):
| {
ok: true
releaseAmount: number
nextAvailableAmount: number
nextFrozenWithdrawAmount: number
}
| { ok: false; reason: 'withdraw_insufficient' } {
if (input.frozenWithdrawAmount < input.requestFrozenAmount) {
return { ok: false, reason: 'withdraw_insufficient' }
}
return {
ok: true,
releaseAmount: input.requestFrozenAmount,
nextAvailableAmount: input.availableAmount + input.requestFrozenAmount,
nextFrozenWithdrawAmount: input.frozenWithdrawAmount - input.requestFrozenAmount,
}
}
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveWithdrawApprovalPlan, resolveWithdrawRejectionPlan } from './worker-finance-repo.js'
test('提现通过(新口径):申请时已冻结,确认打款只解冻不动可用余额', () => {
const plan = resolveWithdrawApprovalPlan({
requestFrozenAmount: 8_000,
frozenWithdrawAmount: 8_000,
availableAmount: 2_000,
amount: 8_000,
})
assert.deepEqual(plan, {
ok: true,
mode: 'frozen',
nextAvailableAmount: 2_000,
nextFrozenWithdrawAmount: 0,
ledgerAmount: 0,
})
})
test('提现通过(新口径):多笔待审时只解冻当前申请的冻结额度', () => {
const plan = resolveWithdrawApprovalPlan({
requestFrozenAmount: 3_000,
frozenWithdrawAmount: 11_000,
availableAmount: 5_000,
amount: 3_000,
})
assert.deepEqual(plan, {
ok: true,
mode: 'frozen',
nextAvailableAmount: 5_000,
nextFrozenWithdrawAmount: 8_000,
ledgerAmount: 0,
})
})
test('提现通过(新口径):冻结额度异常不足时拒绝并保持现状', () => {
const plan = resolveWithdrawApprovalPlan({
requestFrozenAmount: 8_000,
frozenWithdrawAmount: 1_000,
availableAmount: 9_000,
amount: 8_000,
})
assert.deepEqual(plan, { ok: false, reason: 'withdraw_insufficient' })
})
test('提现通过(旧数据兜底):未冻结申请沿用确认时扣款口径', () => {
const plan = resolveWithdrawApprovalPlan({
requestFrozenAmount: 0,
frozenWithdrawAmount: 0,
availableAmount: 10_000,
amount: 8_000,
})
assert.deepEqual(plan, {
ok: true,
mode: 'legacy',
nextAvailableAmount: 2_000,
nextFrozenWithdrawAmount: 0,
ledgerAmount: -8_000,
})
})
test('提现通过(旧数据兜底):可用余额不足时拒绝', () => {
const plan = resolveWithdrawApprovalPlan({
requestFrozenAmount: 0,
frozenWithdrawAmount: 0,
availableAmount: 5_000,
amount: 8_000,
})
assert.deepEqual(plan, { ok: false, reason: 'withdraw_insufficient' })
})
test('提现拒绝(新口径):冻结资金全额退回可用余额', () => {
const plan = resolveWithdrawRejectionPlan({
requestFrozenAmount: 8_000,
frozenWithdrawAmount: 8_000,
availableAmount: 2_000,
})
assert.deepEqual(plan, {
ok: true,
releaseAmount: 8_000,
nextAvailableAmount: 10_000,
nextFrozenWithdrawAmount: 0,
})
})
test('提现拒绝(新口径):多笔待审时只退回当前申请额度', () => {
const plan = resolveWithdrawRejectionPlan({
requestFrozenAmount: 3_000,
frozenWithdrawAmount: 11_000,
availableAmount: 4_000,
})
assert.deepEqual(plan, {
ok: true,
releaseAmount: 3_000,
nextAvailableAmount: 7_000,
nextFrozenWithdrawAmount: 8_000,
})
})
test('提现拒绝(旧数据):未冻结申请不退回资金', () => {
const plan = resolveWithdrawRejectionPlan({
requestFrozenAmount: 0,
frozenWithdrawAmount: 0,
availableAmount: 2_000,
})
assert.deepEqual(plan, {
ok: true,
releaseAmount: 0,
nextAvailableAmount: 2_000,
nextFrozenWithdrawAmount: 0,
})
})
test('提现拒绝: 冻结额度异常不足时保持申请待处理', () => {
const plan = resolveWithdrawRejectionPlan({
requestFrozenAmount: 8_000,
frozenWithdrawAmount: 1_000,
availableAmount: 9_000,
})
assert.deepEqual(plan, { ok: false, reason: 'withdraw_insufficient' })
})
@@ -251,7 +251,7 @@ export async function reviewAdminWorkerFinanceRequest(
publishWorkerFinanceRealtimeChange({
requestId: Number(reviewed.request.id),
workerId: Number(reviewed.request.worker_id),
walletChanged: reviewed.request.request_type === 'withdraw' && status === 'approved',
walletChanged: reviewed.request.request_type === 'withdraw',
})
return {
@@ -1,7 +1,7 @@
import {
countWorkerWithdrawRequestsOnDay,
createWorkerFinanceRequest,
getWorkerFinanceRequestSummary,
createWorkerWithdrawRequestWithFreeze,
getWorkerWithdrawalAccount,
listDueDepositUnfreezes,
releaseDepositUnfreeze,
@@ -164,20 +164,9 @@ export async function createWorkerWithdrawRequest(
})
}
const financeSummary = await getWorkerFinanceRequestSummary(worker.id)
const availableAmount = Number(worker.available_amount || 0)
const availableForWithdraw = Math.max(0, availableAmount - financeSummary.pendingWithdrawAmount)
if (amount > availableForWithdraw) {
throw createHttpError('可提现余额不足,请先减少提现金额或等待已提交申请处理', {
statusCode: 409,
errorCode: 'worker_withdraw_amount_insufficient',
})
}
const note = String(payload.note || payload.remark || '').trim()
const created = await createWorkerFinanceRequest({
const created = await createWorkerWithdrawRequestWithFreeze({
workerId: worker.id,
requestType: 'withdraw',
amount,
accountChannel,
accountName: withdrawalAccount.account_name,
@@ -192,25 +181,30 @@ export async function createWorkerWithdrawRequest(
}),
now: nowIso(),
})
if (!created) {
throw createHttpError('提现申请创建失败', {
statusCode: 500,
errorCode: 'worker_withdraw_request_create_failed',
if (created.failureReason === 'withdraw_insufficient') {
throw createHttpError('提现余额不足,请先减少提现金额或等待已提交申请处理', {
statusCode: 409,
errorCode: 'worker_withdraw_amount_insufficient',
})
}
const request = created.request
await createWorkerWithdrawAdminNotification({
requestId: Number(created.id),
requestId: Number(request.id),
workerName: worker.display_name || worker.username,
amountFen: Number(created.amount || amount),
channel: created.account_channel,
amountFen: Number(request.amount || amount),
channel: request.account_channel,
})
publishWorkerFinanceRealtimeChange({
requestId: Number(created.id),
requestId: Number(request.id),
workerId: Number(worker.id),
})
publishWorkerWalletRealtimeChange(Number(worker.id))
return {
request: mapFinanceRequest(created),
availableForWithdraw,
request: mapFinanceRequest(request),
availableForWithdraw: Math.max(
0,
Number(worker.available_amount || 0) - Number(request.amount || amount),
),
}
}
@@ -68,7 +68,9 @@ export function normalizeWalletLedgerType(value: unknown) {
if (
[
'manual_credit',
'withdraw_freeze',
'withdraw_paid',
'withdraw_release',
'deposit_freeze',
'deposit_release',
'deposit_pending_unfreeze',