完善账号冻结与VIP免审核
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
-- 046_worker_freeze_reason.sql —— 记录账号冻结原因,避免被普通审核备注覆盖。
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS frozen_reason TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS frozen_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN worker_users.frozen_reason IS '最近一次冻结账号的原因或备注';
|
||||
COMMENT ON COLUMN worker_users.frozen_at IS '最近一次冻结账号的时间';
|
||||
@@ -19,6 +19,8 @@ export type WorkerUserRow = {
|
||||
status: string
|
||||
worker_type: string
|
||||
review_note: string
|
||||
frozen_reason: string
|
||||
frozen_at: string | null
|
||||
session_version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -376,6 +376,8 @@ export async function updateWorkerUser(
|
||||
| 'status'
|
||||
| 'worker_type'
|
||||
| 'review_note'
|
||||
| 'frozen_reason'
|
||||
| 'frozen_at'
|
||||
| 'level_id'
|
||||
| 'session_version'
|
||||
| 'updated_at'
|
||||
@@ -394,16 +396,20 @@ export async function updateWorkerUser(
|
||||
status = $1,
|
||||
worker_type = $2,
|
||||
review_note = $3,
|
||||
level_id = $4,
|
||||
session_version = $5,
|
||||
reviewed_at = $6,
|
||||
updated_at = $7
|
||||
WHERE id = $8
|
||||
frozen_reason = $4,
|
||||
frozen_at = $5,
|
||||
level_id = $6,
|
||||
session_version = $7,
|
||||
reviewed_at = $8,
|
||||
updated_at = $9
|
||||
WHERE id = $10
|
||||
`,
|
||||
[
|
||||
next.status,
|
||||
next.worker_type || 'external',
|
||||
next.review_note || '',
|
||||
next.frozen_reason || '',
|
||||
next.frozen_at || null,
|
||||
next.level_id || null,
|
||||
Number(next.session_version || 1),
|
||||
next.reviewed_at || null,
|
||||
|
||||
@@ -145,6 +145,7 @@ import {
|
||||
normalizeSubmittedFields,
|
||||
normalizeUploadedFiles,
|
||||
normalizeWorkerType,
|
||||
normalizeWorkerFreezeReason,
|
||||
normalizeWithdrawChannel,
|
||||
resolveFreezeDepositAmount,
|
||||
mapWorkOrderEvents,
|
||||
@@ -1025,6 +1026,10 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
|
||||
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
|
||||
const worker = await getRequiredWorker(workerId)
|
||||
const status = normalizeReviewStatus(payload.status)
|
||||
const freezeReason =
|
||||
status === 'rejected'
|
||||
? normalizeWorkerFreezeReason(payload.freezeReason ?? payload.freeze_reason)
|
||||
: worker.frozen_reason || ''
|
||||
const workerType = normalizeWorkerType(payload.workerType ?? payload.worker_type)
|
||||
const requestedLevelId = normalizeOptionalId(payload.levelId)
|
||||
let levelId = worker.level_id || null
|
||||
@@ -1046,7 +1051,12 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
|
||||
status,
|
||||
worker_type: workerType,
|
||||
level_id: levelId,
|
||||
review_note: String(payload.reviewNote || payload.note || '').trim(),
|
||||
review_note:
|
||||
status === 'rejected'
|
||||
? freezeReason
|
||||
: String((payload.reviewNote ?? payload.note ?? worker.review_note) || '').trim(),
|
||||
frozen_reason: freezeReason,
|
||||
frozen_at: status === 'rejected' ? now : worker.frozen_at,
|
||||
session_version: normalizeSessionVersion(worker.session_version) + 1,
|
||||
reviewed_at: now,
|
||||
updated_at: now,
|
||||
|
||||
@@ -110,6 +110,17 @@ export function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 整单接单人与拼单参与者使用不同验收路径。
|
||||
* 母工单保留拼单配置时,后台仍可在无人参与前整单指派,不能因此失去 VIP 免审核资格。
|
||||
*/
|
||||
export function canWorkerAutoAcceptWholeOrder(input: {
|
||||
worker: WorkerUserRow
|
||||
hasPersonalSharingShare: boolean
|
||||
}): boolean {
|
||||
return !input.hasPersonalSharingShare && resolveWorkerPermissions(input.worker).autoAcceptWithoutEvidence
|
||||
}
|
||||
|
||||
export function resolveVisibleDelaySeconds(levelKey: string, configured?: unknown): number {
|
||||
const configuredValue = normalizeInteger(configured, -1)
|
||||
if (configuredValue >= 0) return configuredValue
|
||||
@@ -228,6 +239,8 @@ export function mapWorkerUser(worker: WorkerUserRow) {
|
||||
status: worker.status,
|
||||
workerType: normalizeWorkerType(worker.worker_type),
|
||||
reviewNote: worker.review_note || '',
|
||||
freezeReason: worker.frozen_reason || '',
|
||||
frozenAt: worker.frozen_at,
|
||||
inviteCode: worker.invite_code || '',
|
||||
inviter: worker.inviter_id
|
||||
? {
|
||||
@@ -1394,6 +1407,24 @@ export function normalizeReviewStatus(value: unknown) {
|
||||
return 'pending_review'
|
||||
}
|
||||
|
||||
/** 冻结原因必须可追溯,限制长度避免异常大载荷写入账号资料。 */
|
||||
export function normalizeWorkerFreezeReason(value: unknown): string {
|
||||
const reason = String(value || '').trim()
|
||||
if (!reason) {
|
||||
throw createHttpError('请填写冻结原因或备注', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_freeze_reason_required',
|
||||
})
|
||||
}
|
||||
if (reason.length > 500) {
|
||||
throw createHttpError('冻结原因不能超过 500 个字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_freeze_reason_too_long',
|
||||
})
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
|
||||
const action = String(value || '').trim()
|
||||
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
|
||||
|
||||
@@ -17,10 +17,12 @@ import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import {
|
||||
assertWithdrawChannelAllowedForAmount,
|
||||
assertWorkerLoginAllowed,
|
||||
canWorkerAutoAcceptWholeOrder,
|
||||
isWorkOrderSharingFilled,
|
||||
isWorkerPasswordStrong,
|
||||
normalizeWithdrawChannel,
|
||||
normalizeRequirementFields,
|
||||
normalizeWorkerFreezeReason,
|
||||
mapWorkOrderForWorker,
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderSharingWorkers,
|
||||
@@ -451,6 +453,16 @@ test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_reje
|
||||
)
|
||||
})
|
||||
|
||||
test('冻结账号必须填写原因,并限制原因长度', () => {
|
||||
assert.throws(() => normalizeWorkerFreezeReason(''), {
|
||||
message: '请填写冻结原因或备注',
|
||||
})
|
||||
assert.equal(normalizeWorkerFreezeReason('多次超时未完成订单'), '多次超时未完成订单')
|
||||
assert.throws(() => normalizeWorkerFreezeReason('a'.repeat(501)), {
|
||||
message: '冻结原因不能超过 500 个字符',
|
||||
})
|
||||
})
|
||||
|
||||
test('assertWorkerLoginAllowed blocks pending review workers with worker_pending_review', () => {
|
||||
assert.throws(
|
||||
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'pending_review' })),
|
||||
@@ -511,6 +523,22 @@ test('等级免审核后补图权限默认关闭,仅显式开启时生效', ()
|
||||
)
|
||||
})
|
||||
|
||||
test('后台整单指派给 VIP5 时不因母工单保留拼单配置而失去免审核资格', () => {
|
||||
const vip5Worker = buildWorkerUserRow({
|
||||
level_key: 'vip5',
|
||||
level_permission_json: { autoAcceptWithoutEvidence: true },
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
canWorkerAutoAcceptWholeOrder({ worker: vip5Worker, hasPersonalSharingShare: false }),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
canWorkerAutoAcceptWholeOrder({ worker: vip5Worker, hasPersonalSharingShare: true }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizeRequirementFields default template keeps gameId/gameNickname as text inputs', () => {
|
||||
const fields = normalizeRequirementFields([])
|
||||
const gameId = fields.find((field) => field.key === 'gameId')
|
||||
|
||||
@@ -143,6 +143,7 @@ import {
|
||||
resolveFreezeDepositAmount,
|
||||
resolveRequirementFields,
|
||||
resolveWorkerPermissions,
|
||||
canWorkerAutoAcceptWholeOrder,
|
||||
safeCompare,
|
||||
signWorkerPayload,
|
||||
throwGrabWorkOrderFailure,
|
||||
@@ -2017,8 +2018,10 @@ export async function submitWorkerOrderAcceptance(
|
||||
if (sharingShare) {
|
||||
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
|
||||
}
|
||||
const canAutoAcceptWithoutEvidence =
|
||||
!workOrder.sharing_enabled && resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
|
||||
const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({
|
||||
worker,
|
||||
hasPersonalSharingShare: false,
|
||||
})
|
||||
const now = nowIso()
|
||||
if (
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||
|
||||
@@ -42,6 +42,10 @@ type WithdrawalAccountFormValues = {
|
||||
wechatQrCodeImages?: UploadedFile[]
|
||||
}
|
||||
|
||||
type FreezeWorkerFormValues = {
|
||||
freezeReason: string
|
||||
}
|
||||
|
||||
export default function WorkersPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -55,6 +59,7 @@ export default function WorkersPanel() {
|
||||
const [levelWorker, setLevelWorker] = useState<WorkerUser | null>(null)
|
||||
const [withdrawalAccountWorker, setWithdrawalAccountWorker] = useState<WorkerUser | null>(null)
|
||||
const [resetPasswordWorker, setResetPasswordWorker] = useState<WorkerUser | null>(null)
|
||||
const [freezeWorker, setFreezeWorker] = useState<WorkerUser | null>(null)
|
||||
const [withdrawalAccounts, setWithdrawalAccounts] = useState<AdminWorkerWithdrawalAccount[]>([])
|
||||
const [loadingWithdrawalAccounts, setLoadingWithdrawalAccounts] = useState(false)
|
||||
const [savingWithdrawalAccount, setSavingWithdrawalAccount] = useState(false)
|
||||
@@ -62,6 +67,7 @@ export default function WorkersPanel() {
|
||||
const [levelForm] = Form.useForm<{ levelId?: number }>()
|
||||
const [withdrawalAccountForm] = Form.useForm<WithdrawalAccountFormValues>()
|
||||
const [resetPasswordForm] = Form.useForm<{ password: string }>()
|
||||
const [freezeForm] = Form.useForm<FreezeWorkerFormValues>()
|
||||
const withdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm)
|
||||
|
||||
const workersQuery = useQuery({
|
||||
@@ -108,6 +114,22 @@ export default function WorkersPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFreezeWorker(values: FreezeWorkerFormValues) {
|
||||
if (!freezeWorker) return
|
||||
try {
|
||||
await reviewAdminWorkerUser(freezeWorker.workerId, {
|
||||
status: 'rejected',
|
||||
freezeReason: values.freezeReason.trim(),
|
||||
})
|
||||
message.success('打手账号已冻结,原登录设备已退出')
|
||||
setFreezeWorker(null)
|
||||
freezeForm.resetFields()
|
||||
await refreshWorkers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '冻结账号失败')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWorkerType(worker: WorkerUser, workerType: 'internal' | 'external') {
|
||||
const isInternal = workerType === 'internal'
|
||||
modal.confirm({
|
||||
@@ -297,6 +319,18 @@ export default function WorkersPanel() {
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '冻结原因',
|
||||
width: 220,
|
||||
render: (_, row) =>
|
||||
row.status === 'rejected' && row.freezeReason ? (
|
||||
<Typography.Text ellipsis={{ tooltip: row.freezeReason }}>
|
||||
{row.freezeReason}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 340,
|
||||
@@ -306,7 +340,9 @@ export default function WorkersPanel() {
|
||||
<Button onClick={() => review(row, 'active')}>启用</Button>
|
||||
) : null}
|
||||
{row.status !== 'rejected' ? (
|
||||
<Button onClick={() => review(row, 'rejected')}>拒绝</Button>
|
||||
<Button danger onClick={() => setFreezeWorker(row)}>
|
||||
冻结
|
||||
</Button>
|
||||
) : null}
|
||||
{row.workerType !== 'internal' ? (
|
||||
<Button onClick={() => toggleWorkerType(row, 'internal')}>设为内部</Button>
|
||||
@@ -406,6 +442,40 @@ export default function WorkersPanel() {
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="冻结打手账号"
|
||||
open={Boolean(freezeWorker)}
|
||||
onCancel={() => {
|
||||
setFreezeWorker(null)
|
||||
freezeForm.resetFields()
|
||||
}}
|
||||
onOk={() => freezeForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={freezeForm} layout="vertical" onFinish={submitFreezeWorker}>
|
||||
<Form.Item label="打手">
|
||||
<Typography.Text>
|
||||
{freezeWorker?.displayName || freezeWorker?.username || '-'}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="冻结原因"
|
||||
name="freezeReason"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: '请填写冻结原因或备注' },
|
||||
{ max: 500, message: '冻结原因不能超过 500 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
maxLength={500}
|
||||
placeholder="例如:多次超时未完成订单,经核实后冻结账号"
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="重置打手密码"
|
||||
open={Boolean(resetPasswordWorker)}
|
||||
|
||||
@@ -277,7 +277,13 @@ export function reprocessAdminKuaishouWorkOrderMatches(limit = 100) {
|
||||
|
||||
export function reviewAdminWorkerUser(
|
||||
workerId: number,
|
||||
payload: { status: string; reviewNote?: string; levelId?: number; workerType?: string },
|
||||
payload: {
|
||||
status: string
|
||||
reviewNote?: string
|
||||
freezeReason?: string
|
||||
levelId?: number
|
||||
workerType?: string
|
||||
},
|
||||
) {
|
||||
return apiPost<{ worker: WorkerUser }>(
|
||||
`/api/v1/admin/worker-platform/workers/${workerId}/review`,
|
||||
|
||||
@@ -6,6 +6,8 @@ export type WorkerUser = {
|
||||
status: string
|
||||
workerType: string
|
||||
reviewNote: string
|
||||
freezeReason: string
|
||||
frozenAt: string | null
|
||||
inviteCode: string
|
||||
inviter: null | {
|
||||
workerId: number
|
||||
|
||||
Reference in New Issue
Block a user