完善账号冻结与VIP免审核

This commit is contained in:
yml2213
2026-08-20 20:42:03 +08:00
parent e05a6797a3
commit 942cbcab68
10 changed files with 178 additions and 10 deletions
@@ -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