押金解冻天数可配置与待解冻押金扣减,超时订单保留记录

This commit is contained in:
yml2213
2026-08-02 10:14:05 +08:00
parent 043dd3c128
commit 93df3d1c9b
20 changed files with 854 additions and 38 deletions
@@ -11,6 +11,7 @@ import {
const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json')
const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health'
const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
export function getScheduledJobsFilePath() {
return SCHEDULED_JOBS_FILE_PATH
@@ -46,6 +47,12 @@ export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConf
|| createDefaultWorkOrderTimeoutJob()
}
export function getDepositUnfreezeJob(config: JsonObject = getScheduledJobsConfig()) {
return (Array.isArray(config.jobs) ? config.jobs : [])
.find((item) => String(item.id || '').trim() === DEPOSIT_UNFREEZE_JOB_ID)
|| createDefaultDepositUnfreezeJob()
}
export function getScheduledJobById(jobId: unknown, config: JsonObject = getScheduledJobsConfig()) {
return (Array.isArray(config.jobs) ? config.jobs : [])
.find((item) => String(item.id || '').trim() === String(jobId || '').trim()) || null
@@ -59,6 +66,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
.filter((item): item is NonNullable<ReturnType<typeof normalizeScheduledJob>> => Boolean(item))
const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID)
const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID)
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
if (!hasCloudtentaclesHealth) {
jobs.push(createDefaultCloudtentaclesHealthJob())
@@ -66,6 +74,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
if (!hasWorkOrderTimeout) {
jobs.push(createDefaultWorkOrderTimeoutJob())
}
if (!hasDepositUnfreeze) {
jobs.push(createDefaultDepositUnfreezeJob())
}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
@@ -85,10 +96,27 @@ function normalizeScheduledJob(rawValue: unknown) {
if (type === 'work_order_timeout') {
return normalizeWorkOrderTimeoutJob(rawValue)
}
if (type === 'deposit_unfreeze') {
return normalizeDepositUnfreezeJob(rawValue)
}
return null
}
function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: DEPOSIT_UNFREEZE_JOB_ID,
type: 'deposit_unfreeze',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
scanLimit: normalizeRangeInteger(config.scanLimit, 100, 1, 2000),
},
}
}
function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
@@ -171,10 +199,23 @@ function createDefaultScheduledJobsConfig() {
jobs: [
createDefaultCloudtentaclesHealthJob(),
createDefaultWorkOrderTimeoutJob(),
createDefaultDepositUnfreezeJob(),
],
}
}
function createDefaultDepositUnfreezeJob() {
return {
id: DEPOSIT_UNFREEZE_JOB_ID,
type: 'deposit_unfreeze',
enabled: true,
intervalSeconds: 60,
config: {
scanLimit: 100,
},
}
}
function createDefaultWorkOrderTimeoutJob() {
return {
id: WORK_ORDER_TIMEOUT_JOB_ID,
@@ -0,0 +1,24 @@
import type { JsonObject } from '../../types/json.js'
import { logInfo } from '../../utils/logger.js'
import { settleDueDepositUnfreezes } from '../worker-platform/worker-service.js'
export async function runDepositUnfreezeJob(job: JsonObject) {
const config =
job.config && typeof job.config === 'object' && !Array.isArray(job.config)
? (job.config as JsonObject)
: {}
const scanLimit = Math.max(1, Number(config.scanLimit || 100))
const result = await settleDueDepositUnfreezes({ limit: scanLimit })
logInfo('[deposit-unfreeze]', '押金解冻扫描完成', result)
return {
ok: true,
status: 'ok',
message: `扫描 ${result.checkedCount} 笔,解冻 ${result.processedCount}`,
checkedCount: result.checkedCount,
failedCount: 0,
asset: result.processedCount,
}
}
@@ -6,6 +6,7 @@ import {
getScheduledJobsConfig,
} from './config-service.js'
import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js'
import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
const timers = new Map<string, NodeJS.Timeout>()
@@ -182,6 +183,9 @@ function dispatchJob(job: JsonObject) {
if (job.type === 'work_order_timeout') {
return runWorkOrderTimeoutJob(job)
}
if (job.type === 'deposit_unfreeze') {
return runDepositUnfreezeJob(job)
}
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
statusCode: 400,
@@ -19,6 +19,7 @@ import {
createWorkOrderEvent,
createWorkerFinanceRequest,
createWorkerUser,
deductPendingDepositUnfreeze,
deleteWorkCategory,
deleteWorkOrder,
deleteWorkerLevel,
@@ -49,6 +50,7 @@ import {
listWorkerUsers,
listWorkOrderShares,
listWorkOrderSharesByOrderIds,
sumPendingUnfreezeByOrderIds,
resolveProblemWorkOrder,
reviewWorkerFinanceRequest,
submitWorkOrderShareAcceptance,
@@ -456,6 +458,9 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
bucket.push(share)
sharesByOrderId.set(orderId, bucket)
}
const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds(
items.map((item) => Number(item.id)),
)
return {
items: items.map((item) => {
const orderShares = sharesByOrderId.get(Number(item.id)) || []
@@ -467,6 +472,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
).length
return {
...mapWorkOrderAdmin(item),
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0,
sharingProgress: {
joinedQuantity,
pendingSubmissionCount,
@@ -477,6 +483,62 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
}
}
export async function deductAdminWorkOrderPendingDeposit(
workOrderId: number | string,
payload: JsonObject = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (workOrder.status !== WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('只有已验收的工单可以扣减待解冻押金', {
statusCode: 409,
errorCode: 'work_order_deduct_pending_status_invalid',
})
}
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
const note = String(payload.note || payload.reason || '').trim()
if (amount <= 0) {
throw createHttpError('扣减金额必须大于 0', {
statusCode: 400,
errorCode: 'work_order_deduct_pending_amount_invalid',
})
}
if (!note) {
throw createHttpError('请填写扣减原因', {
statusCode: 400,
errorCode: 'work_order_deduct_pending_note_required',
})
}
const result = await deductPendingDepositUnfreeze({
workOrderId: workOrder.id,
amount,
note,
now: nowIso(),
})
if (result.failureReason === 'no_pending_deposit') {
throw createHttpError('该工单没有待解冻的押金', {
statusCode: 409,
errorCode: 'work_order_deduct_pending_none',
})
}
if (result.failureReason === 'amount_exceeds_pending') {
throw createHttpError('扣减金额超过该工单待解冻押金总额', {
statusCode: 409,
errorCode: 'work_order_deduct_pending_amount_exceeds',
})
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: '',
eventType: 'pending_deposit_deducted',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({ amount, note }),
now: nowIso(),
})
return { deductedAmount: result.deductedAmount }
}
export async function getAdminWorkOrderSharing(workOrderId: number | string) {
const workOrder = await getRequiredWorkOrder(workOrderId)
const shares = await listWorkOrderShares(workOrder.id)
@@ -937,6 +999,7 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) {
const now = nowIso()
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
now,
})
if (failureReason === 'sharing_pending_submissions') {
@@ -285,6 +285,7 @@ export function mapWorkerUser(worker: WorkerUserRow) {
wallet: {
availableAmount: Number(worker.available_amount || 0),
frozenDepositAmount: Number(worker.frozen_deposit_amount || 0),
pendingUnfreezeAmount: Number(worker.pending_unfreeze_amount || 0),
totalCreditedAmount: Number(worker.total_credited_amount || 0),
totalSettledAmount: Number(worker.total_settled_amount || 0),
},
@@ -452,6 +453,7 @@ export function mapWallet(wallet: Awaited<ReturnType<typeof addWorkerWalletCredi
workerId: Number(wallet?.worker_id || 0),
availableAmount: Number(wallet?.available_amount || 0),
frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0),
pendingUnfreezeAmount: Number(wallet?.pending_unfreeze_amount || 0),
totalCreditedAmount: Number(wallet?.total_credited_amount || 0),
totalSettledAmount: Number(wallet?.total_settled_amount || 0),
}
@@ -759,6 +761,8 @@ export function normalizeWalletLedgerType(value: unknown) {
'withdraw_paid',
'deposit_freeze',
'deposit_release',
'deposit_pending_unfreeze',
'deposit_unfreeze',
'deposit_deduction',
'reward_settlement',
].includes(ledgerType)
@@ -8,6 +8,7 @@ import {
} from '../file-storage/file-storage-service.js'
export type WorkerFinanceConfig = {
depositUnfreezeDays: number
recharge: {
enabled: boolean
channelName: string
@@ -46,6 +47,7 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo
const withdraw = isPlainObject(source.withdraw) ? source.withdraw : {}
return {
depositUnfreezeDays: normalizeRangeInteger(source.depositUnfreezeDays, 3, 0, 30),
recharge: {
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
@@ -65,6 +67,7 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo
export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
return {
depositUnfreezeDays: 3,
recharge: {
enabled: true,
channelName: '',
@@ -82,6 +85,7 @@ export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceConfig {
return {
depositUnfreezeDays: config.depositUnfreezeDays,
recharge: {
...config.recharge,
qrCodeImage: config.recharge.qrCodeImage
@@ -94,6 +98,14 @@ function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceC
}
}
function normalizeRangeInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) {
return fallback
}
return Math.min(max, Math.max(min, parsed))
}
function normalizeUploadedFileForStorage(value: unknown): UploadedFileDto | null {
if (!isPlainObject(value)) {
return null
@@ -10,6 +10,7 @@ import {
countWorkerAcceptedOrders,
countWorkerActiveOrders,
countWorkerTimeoutEvents,
countWorkerWithdrawRequestsOnDay,
countWorkCategoryUsages,
countWorkerLevelUsages,
countWorkOrderPendingSharingSubmissions,
@@ -48,7 +49,9 @@ import {
listWorkerUsers,
listWorkOrderShares,
listWorkOrderSharesByOrderIds,
listDueDepositUnfreezes,
listOverdueWorkOrders,
releaseDepositUnfreeze,
resolveProblemWorkOrder,
reviewWorkerFinanceRequest,
settleOverdueWorkOrder,
@@ -521,6 +524,20 @@ export async function createWorkerWithdrawRequest(
errorCode: 'worker_withdraw_disabled',
})
}
await settleDueDepositUnfreezes({ workerId: worker.id, limit: 50 })
const withdrawCountToday = await countWorkerWithdrawRequestsOnDay(
worker.id,
resolveChinaDayRange().start,
resolveChinaDayRange().end,
)
if (withdrawCountToday > 0) {
throw createHttpError('每天限提现 1 次,今天已提交过提现申请', {
statusCode: 409,
errorCode: 'worker_withdraw_daily_limit_reached',
})
}
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
if (amount <= 0) {
throw createHttpError('提现金额必须大于 0', {
@@ -743,6 +760,38 @@ export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeou
return isWorkOrderTimeoutPolicy(value) ? value : 'reopen'
}
export async function settleDueDepositUnfreezes(
options: { limit?: number; workerId?: number } = {},
) {
const due = await listDueDepositUnfreezes({
limit: Math.max(1, Number(options.limit || 100)),
workerId: Number(options.workerId || 0),
})
let processedCount = 0
for (const unfreeze of due) {
const released = await releaseDepositUnfreeze({
unfreezeId: unfreeze.id,
now: nowIso(),
})
if (released) processedCount += 1
}
return {
checkedCount: due.length,
processedCount,
}
}
function resolveChinaDayRange(): { start: string; end: string } {
const utcNow = new Date()
const utcDayStartMs =
Date.UTC(utcNow.getUTCFullYear(), utcNow.getUTCMonth(), utcNow.getUTCDate()) -
8 * 3_600_000
return {
start: new Date(utcDayStartMs).toISOString(),
end: new Date(utcDayStartMs + 86_400_000).toISOString(),
}
}
export async function settleOverdueWorkOrders(
options: { limit?: number; workerId?: number } = {},
) {