优化打手提现审核流程
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
-- 打手提现账户:每位打手仅允许首次登记,后续变更由客服处理。
|
||||
CREATE TABLE IF NOT EXISTS worker_withdrawal_accounts (
|
||||
worker_id BIGINT PRIMARY KEY REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
account_channel TEXT NOT NULL CHECK (account_channel IN ('alipay', 'wechat')),
|
||||
account_name TEXT NOT NULL,
|
||||
account_no TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE worker_withdrawal_accounts IS '打手不可自行修改的提现账户';
|
||||
COMMENT ON COLUMN worker_withdrawal_accounts.account_channel IS '提现渠道:alipay / wechat';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 提现账户改为每个渠道各登记一次,保留既有账户数据。
|
||||
ALTER TABLE worker_withdrawal_accounts
|
||||
DROP CONSTRAINT IF EXISTS worker_withdrawal_accounts_pkey;
|
||||
|
||||
ALTER TABLE worker_withdrawal_accounts
|
||||
ADD PRIMARY KEY (worker_id, account_channel);
|
||||
|
||||
COMMENT ON TABLE worker_withdrawal_accounts IS '打手每个渠道仅可自行登记一次的提现账户';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 微信提现不使用账号,改为保存不可修改的收款二维码。
|
||||
ALTER TABLE worker_withdrawal_accounts
|
||||
ADD COLUMN IF NOT EXISTS wechat_qr_code_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN worker_withdrawal_accounts.wechat_qr_code_json IS '微信收款二维码文件信息';
|
||||
@@ -94,6 +94,15 @@ export type WorkerFinanceRequestRow = {
|
||||
worker_phone?: string
|
||||
}
|
||||
|
||||
export type WorkerWithdrawalAccountRow = {
|
||||
worker_id: number
|
||||
account_channel: string
|
||||
account_name: string
|
||||
account_no: string
|
||||
wechat_qr_code_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type WorkCategoryRow = {
|
||||
id: number
|
||||
category_key: string
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
WorkerFinanceRequestRow,
|
||||
WorkerLevelRow,
|
||||
WorkerUserRow,
|
||||
WorkerWithdrawalAccountRow,
|
||||
WorkerWalletLedgerRow,
|
||||
WorkerWalletRow,
|
||||
} from './types.js'
|
||||
@@ -437,6 +438,65 @@ export async function createWorkerFinanceRequest(input: {
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerWithdrawalAccount(
|
||||
workerId: number | string,
|
||||
accountChannel: string,
|
||||
): Promise<WorkerWithdrawalAccountRow | null> {
|
||||
const result = await query<WorkerWithdrawalAccountRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM worker_withdrawal_accounts
|
||||
WHERE worker_id = $1 AND account_channel = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[Number(workerId), accountChannel],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listWorkerWithdrawalAccounts(
|
||||
workerId: number | string,
|
||||
): Promise<WorkerWithdrawalAccountRow[]> {
|
||||
const result = await query<WorkerWithdrawalAccountRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM worker_withdrawal_accounts
|
||||
WHERE worker_id = $1
|
||||
ORDER BY account_channel ASC
|
||||
`,
|
||||
[Number(workerId)],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function createWorkerWithdrawalAccount(input: {
|
||||
workerId: number
|
||||
accountChannel: string
|
||||
accountName: string
|
||||
accountNo: string
|
||||
wechatQrCodeJson: string
|
||||
now: string
|
||||
}): Promise<WorkerWithdrawalAccountRow | null> {
|
||||
const result = await query<WorkerWithdrawalAccountRow>(
|
||||
`
|
||||
INSERT INTO worker_withdrawal_accounts (
|
||||
worker_id, account_channel, account_name, account_no, wechat_qr_code_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb, $6)
|
||||
ON CONFLICT (worker_id, account_channel) DO NOTHING
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.workerId,
|
||||
input.accountChannel,
|
||||
input.accountName,
|
||||
input.accountNo,
|
||||
input.wechatQrCodeJson,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerFinanceRequestById(
|
||||
requestId: number | string,
|
||||
): Promise<WorkerFinanceRequestRow | null> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { uploadFileAsset } from '../services/file-storage/file-storage-service.j
|
||||
import {
|
||||
changeWorkerPassword,
|
||||
createWorkerRechargeRequest,
|
||||
createWorkerWithdrawalAccount,
|
||||
createWorkerWithdrawRequest,
|
||||
getWorkerProfile,
|
||||
getWorkerSessionSummary,
|
||||
@@ -147,6 +148,19 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/profile/withdrawal-account',
|
||||
requireActiveWorker,
|
||||
createRouteHandler(
|
||||
(req) => createWorkerWithdrawalAccount(req.body || {}, getRequiredWorkerSession(req)),
|
||||
{
|
||||
successMessage: '提现信息已添加',
|
||||
errorMessage: '添加提现信息失败',
|
||||
scope: '[worker/profile/withdrawal-account]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/profile/withdraw-requests',
|
||||
requireActiveWorker,
|
||||
|
||||
@@ -260,6 +260,11 @@ export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined
|
||||
|
||||
export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) {
|
||||
if (!request) return null
|
||||
const payload = safeParseJson(request.payload_json)
|
||||
const wechatQrCodeImage = refreshUploadedFileUrls(payload.wechatQrCodeImage)
|
||||
if (String(wechatQrCodeImage.url || '').trim()) {
|
||||
payload.wechatQrCodeImage = wechatQrCodeImage
|
||||
}
|
||||
return {
|
||||
requestId: Number(request.id),
|
||||
requestType: request.request_type || '',
|
||||
@@ -270,7 +275,7 @@ export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | unde
|
||||
accountNo: request.account_no || '',
|
||||
note: request.note || '',
|
||||
reviewedNote: request.reviewed_note || '',
|
||||
payload: safeParseJson(request.payload_json),
|
||||
payload,
|
||||
createdAt: request.created_at,
|
||||
updatedAt: request.updated_at,
|
||||
reviewedAt: request.reviewed_at,
|
||||
@@ -901,7 +906,7 @@ export function normalizeAdminFinanceReviewStatus(value: unknown) {
|
||||
|
||||
export function normalizeWithdrawChannel(value: unknown) {
|
||||
const channel = String(value || '').trim()
|
||||
if (['alipay', 'wechat', 'bank'].includes(channel)) {
|
||||
if (['alipay', 'wechat'].includes(channel)) {
|
||||
return channel
|
||||
}
|
||||
throw createHttpError('提现方式不正确', {
|
||||
@@ -910,6 +915,16 @@ export function normalizeWithdrawChannel(value: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 提现金额超过 100 元时,仅允许使用支付宝。 */
|
||||
export function assertWithdrawChannelAllowedForAmount(amount: number, channel: string) {
|
||||
if (amount > 10_000 && channel !== 'alipay') {
|
||||
throw createHttpError('提现金额超过 100 元时,仅支持支付宝提现', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_withdraw_channel_amount_limited',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMatchType(value: unknown) {
|
||||
const matchType = String(value || '').trim()
|
||||
if (matchType === 'exact') return 'exact'
|
||||
|
||||
@@ -3,7 +3,9 @@ import test from 'node:test'
|
||||
|
||||
import type { WorkOrderRow, WorkerUserRow } from '../../repositories/worker-platform/index.js'
|
||||
import {
|
||||
assertWithdrawChannelAllowedForAmount,
|
||||
assertWorkerLoginAllowed,
|
||||
normalizeWithdrawChannel,
|
||||
normalizeRequirementFields,
|
||||
resolveCollectSubmitTargetWorkOrder,
|
||||
resolveSkuNameQuantity,
|
||||
@@ -72,6 +74,28 @@ test('assertWorkerLoginAllowed blocks disabled workers with worker_disabled', ()
|
||||
)
|
||||
})
|
||||
|
||||
test('微信提现金额不超过 100 元时允许提交', () => {
|
||||
assert.doesNotThrow(() => assertWithdrawChannelAllowedForAmount(10_000, 'wechat'))
|
||||
})
|
||||
|
||||
test('微信提现金额超过 100 元时被拒绝', () => {
|
||||
assert.throws(
|
||||
() => assertWithdrawChannelAllowedForAmount(10_001, 'wechat'),
|
||||
(error: unknown) =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(error as { errorCode?: string }).errorCode === 'worker_withdraw_channel_amount_limited',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('提现渠道仅支持支付宝和微信', () => {
|
||||
assert.equal(normalizeWithdrawChannel('alipay'), 'alipay')
|
||||
assert.equal(normalizeWithdrawChannel('wechat'), 'wechat')
|
||||
assert.throws(() => normalizeWithdrawChannel('bank'))
|
||||
})
|
||||
|
||||
test('normalizeRequirementFields default template keeps gameId/gameNickname as text inputs', () => {
|
||||
const fields = normalizeRequirementFields([])
|
||||
const gameId = fields.find((field) => field.key === 'gameId')
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
createWorkOrderEvent,
|
||||
createWorkerFinanceRequest,
|
||||
createWorkerUser,
|
||||
createWorkerWithdrawalAccount as createWorkerWithdrawalAccountRecord,
|
||||
getWorkerFinanceRequestSummary,
|
||||
getWorkCategoryByKey,
|
||||
getWorkerLevelByKey,
|
||||
getLatestWorkerSmsCode,
|
||||
getWorkerUserByInviteCode,
|
||||
getWorkerUserById,
|
||||
getWorkerWithdrawalAccount,
|
||||
getWorkerUserByPhone,
|
||||
getWorkerUserByUsername,
|
||||
getWorkerUserByDisplayName,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
listWorkerSharesByWorker,
|
||||
listWorkerWalletLedgers,
|
||||
listWorkerWorkOrderNotes,
|
||||
listWorkerWithdrawalAccounts,
|
||||
listWorkCategories,
|
||||
listWorkOrders,
|
||||
listWorkOrderEventsByOrderId,
|
||||
@@ -63,6 +66,7 @@ import { nowIso } from '../../utils/time.js'
|
||||
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { getSmsProvider } from '../sms/index.js'
|
||||
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||
import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js'
|
||||
|
||||
import {
|
||||
DEFAULT_CATEGORY_KEY,
|
||||
@@ -84,6 +88,7 @@ import {
|
||||
mapWorkOrderShare,
|
||||
mapWorkerUser,
|
||||
normalizeAmountFen,
|
||||
assertWithdrawChannelAllowedForAmount,
|
||||
normalizeFinanceRequestStatus,
|
||||
normalizeFinanceRequestType,
|
||||
normalizeInteger,
|
||||
@@ -109,6 +114,8 @@ import {
|
||||
verifyWorkerPassword,
|
||||
} from './mappers.js'
|
||||
|
||||
const WORKER_DAILY_WITHDRAW_LIMIT = 3
|
||||
|
||||
export type WorkerSession = {
|
||||
sessionId: string
|
||||
workerId: number
|
||||
@@ -505,14 +512,21 @@ export function requireActiveWorkerSession(session: WorkerSession | null | undef
|
||||
|
||||
export async function getWorkerProfile(session: WorkerSession) {
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const [financeSummary, acceptedOrderCount, activeOrderCount, timeoutOrderCount, financeConfig] =
|
||||
await Promise.all([
|
||||
getWorkerFinanceRequestSummary(session.workerId),
|
||||
countWorkerAcceptedOrders(session.workerId),
|
||||
countWorkerActiveOrders(session.workerId),
|
||||
countWorkerTimeoutEvents(session.workerId),
|
||||
Promise.resolve(getWorkerFinanceConfig()),
|
||||
])
|
||||
const [
|
||||
financeSummary,
|
||||
acceptedOrderCount,
|
||||
activeOrderCount,
|
||||
timeoutOrderCount,
|
||||
financeConfig,
|
||||
withdrawalAccounts,
|
||||
] = await Promise.all([
|
||||
getWorkerFinanceRequestSummary(session.workerId),
|
||||
countWorkerAcceptedOrders(session.workerId),
|
||||
countWorkerActiveOrders(session.workerId),
|
||||
countWorkerTimeoutEvents(session.workerId),
|
||||
Promise.resolve(getWorkerFinanceConfig()),
|
||||
listWorkerWithdrawalAccounts(session.workerId),
|
||||
])
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const levelProgress = await resolveLevelProgress(worker, acceptedOrderCount)
|
||||
return {
|
||||
@@ -528,6 +542,7 @@ export async function getWorkerProfile(session: WorkerSession) {
|
||||
},
|
||||
levelProgress,
|
||||
financeConfig,
|
||||
withdrawalAccounts: withdrawalAccounts.map(mapWithdrawalAccount),
|
||||
orderTimelineVisible: runtimeConfig.worker.orderTimelineVisible === true,
|
||||
}
|
||||
}
|
||||
@@ -696,8 +711,8 @@ export async function createWorkerWithdrawRequest(
|
||||
resolveChinaDayRange().start,
|
||||
resolveChinaDayRange().end,
|
||||
)
|
||||
if (withdrawCountToday > 0) {
|
||||
throw createHttpError('每天限提现 1 次,今天已提交过提现申请', {
|
||||
if (withdrawCountToday >= WORKER_DAILY_WITHDRAW_LIMIT) {
|
||||
throw createHttpError('每天最多提现 3 次(支付宝、微信共享次数),今天已达上限', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_withdraw_daily_limit_reached',
|
||||
})
|
||||
@@ -712,18 +727,19 @@ export async function createWorkerWithdrawRequest(
|
||||
}
|
||||
|
||||
const accountChannel = normalizeWithdrawChannel(payload.accountChannel || payload.channel)
|
||||
const accountName = String(payload.accountName || payload.realName || '').trim()
|
||||
const accountNo = String(payload.accountNo || payload.account || '').trim()
|
||||
if (!accountName) {
|
||||
throw createHttpError('请填写收款人姓名', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_withdraw_account_name_required',
|
||||
const withdrawalAccount = await getWorkerWithdrawalAccount(worker.id, accountChannel)
|
||||
if (!withdrawalAccount) {
|
||||
throw createHttpError('请先添加对应的提现信息;修改提现信息请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_withdrawal_account_required',
|
||||
})
|
||||
}
|
||||
if (!accountNo) {
|
||||
throw createHttpError('请填写收款账号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_withdraw_account_no_required',
|
||||
assertWithdrawChannelAllowedForAmount(amount, accountChannel)
|
||||
const wechatQrCodeImage = resolveWechatWithdrawalQrCode(withdrawalAccount)
|
||||
if (accountChannel === 'wechat' && !wechatQrCodeImage) {
|
||||
throw createHttpError('微信提现信息缺少收款二维码,请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_withdrawal_wechat_qr_required',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -743,13 +759,14 @@ export async function createWorkerWithdrawRequest(
|
||||
requestType: 'withdraw',
|
||||
amount,
|
||||
accountChannel,
|
||||
accountName,
|
||||
accountNo,
|
||||
accountName: withdrawalAccount.account_name,
|
||||
accountNo: withdrawalAccount.account_no,
|
||||
note: note || '个人中心提现申请',
|
||||
payloadJson: JSON.stringify({
|
||||
source: 'worker_profile_withdraw',
|
||||
username: worker.username,
|
||||
phone: worker.phone || '',
|
||||
wechatQrCodeImage,
|
||||
}),
|
||||
now: nowIso(),
|
||||
})
|
||||
@@ -765,6 +782,97 @@ export async function createWorkerWithdrawRequest(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkerWithdrawalAccount(
|
||||
payload: JsonObject = {},
|
||||
session: WorkerSession,
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const accountChannel = normalizeWithdrawChannel(payload.accountChannel || payload.channel)
|
||||
const existing = await getWorkerWithdrawalAccount(worker.id, accountChannel)
|
||||
if (existing) {
|
||||
throw createHttpError('该提现方式的信息已添加,修改请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_withdrawal_account_already_set',
|
||||
})
|
||||
}
|
||||
|
||||
const accountName = String(payload.accountName || payload.realName || '').trim()
|
||||
const accountNo =
|
||||
accountChannel === 'alipay' ? String(payload.accountNo || payload.account || '').trim() : ''
|
||||
const wechatQrCodeImage =
|
||||
accountChannel === 'wechat'
|
||||
? normalizeProofFiles(payload.wechatQrCodeImages || payload.wechatQrCodes)[0] || null
|
||||
: null
|
||||
if (!accountName) {
|
||||
throw createHttpError('请填写收款人姓名', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_withdraw_account_name_required',
|
||||
})
|
||||
}
|
||||
if (accountChannel === 'alipay' && !accountNo) {
|
||||
throw createHttpError('请填写收款账号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_withdraw_account_no_required',
|
||||
})
|
||||
}
|
||||
if (accountChannel === 'wechat' && !wechatQrCodeImage) {
|
||||
throw createHttpError('请上传微信收款二维码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_withdraw_wechat_qr_required',
|
||||
})
|
||||
}
|
||||
|
||||
const created = await createWorkerWithdrawalAccountRecord({
|
||||
workerId: worker.id,
|
||||
accountChannel,
|
||||
accountName,
|
||||
accountNo,
|
||||
wechatQrCodeJson: JSON.stringify(wechatQrCodeImage || {}),
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!created) {
|
||||
throw createHttpError('该提现方式的信息已添加,修改请联系客服处理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_withdrawal_account_already_set',
|
||||
})
|
||||
}
|
||||
return {
|
||||
withdrawalAccount: mapWithdrawalAccount(created),
|
||||
}
|
||||
}
|
||||
|
||||
function mapWithdrawalAccount(account: {
|
||||
account_channel: string
|
||||
account_name: string
|
||||
account_no: string
|
||||
wechat_qr_code_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
}) {
|
||||
const wechatQrCodeImage = resolveWechatWithdrawalQrCode(account)
|
||||
return {
|
||||
accountChannel: account.account_channel,
|
||||
accountName: account.account_name,
|
||||
accountNoMasked:
|
||||
account.account_channel === 'alipay' ? maskWithdrawalAccountNo(account.account_no) : '',
|
||||
wechatQrCodeImage,
|
||||
createdAt: account.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWechatWithdrawalQrCode(account: {
|
||||
wechat_qr_code_json: string | Record<string, unknown>
|
||||
}) {
|
||||
const file = refreshUploadedFileUrls(safeParseJson(account.wechat_qr_code_json))
|
||||
return String(file.url || '').trim() ? file : null
|
||||
}
|
||||
|
||||
function maskWithdrawalAccountNo(accountNo: string) {
|
||||
const normalized = String(accountNo || '').trim()
|
||||
if (normalized.length <= 4) return normalized
|
||||
return `${'*'.repeat(Math.max(4, normalized.length - 4))}${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
export async function changeWorkerPassword(payload: JsonObject = {}, session: WorkerSession) {
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const currentPassword = normalizePassword(payload.currentPassword || payload.oldPassword)
|
||||
|
||||
Reference in New Issue
Block a user