From 942cbcab680a2384456fa25c590279f3a411a87a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 20 Aug 2026 20:42:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B4=A6=E5=8F=B7=E5=86=BB?= =?UTF-8?q?=E7=BB=93=E4=B8=8EVIP=E5=85=8D=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/046_worker_freeze_reason.sql | 10 +++ .../src/repositories/worker-platform/types.ts | 2 + .../worker-platform/worker-repo.ts | 16 +++-- .../services/worker-platform/admin-service.ts | 12 +++- .../src/services/worker-platform/mappers.ts | 31 ++++++++ .../worker-platform-service.test.ts | 28 ++++++++ .../worker-platform/worker-service.ts | 7 +- .../src/pages/admin/panels/WorkersPanel.tsx | 72 ++++++++++++++++++- .../src/services/admin/worker-platform.ts | 8 ++- apps/frontend/src/types/worker-platform.ts | 2 + 10 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/db/migrations/046_worker_freeze_reason.sql diff --git a/apps/backend/src/db/migrations/046_worker_freeze_reason.sql b/apps/backend/src/db/migrations/046_worker_freeze_reason.sql new file mode 100644 index 00000000..0e94e52c --- /dev/null +++ b/apps/backend/src/db/migrations/046_worker_freeze_reason.sql @@ -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 '最近一次冻结账号的时间'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 7e425ebc..2cc65932 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -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 diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index 9dfa19fa..8a123e57 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index a5dd91b4..5a362ca3 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 1277772f..29083f78 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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)) { diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 10ee957c..bf9500d9 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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') diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 7a77d1eb..44b0abdb 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -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 && diff --git a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx index c3c083c5..c946a820 100644 --- a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx @@ -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(null) const [withdrawalAccountWorker, setWithdrawalAccountWorker] = useState(null) const [resetPasswordWorker, setResetPasswordWorker] = useState(null) + const [freezeWorker, setFreezeWorker] = useState(null) const [withdrawalAccounts, setWithdrawalAccounts] = useState([]) 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() const [resetPasswordForm] = Form.useForm<{ password: string }>() + const [freezeForm] = Form.useForm() 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() { ), }, + { + title: '冻结原因', + width: 220, + render: (_, row) => + row.status === 'rejected' && row.freezeReason ? ( + + {row.freezeReason} + + ) : ( + '-' + ), + }, { title: '操作', width: 340, @@ -306,7 +340,9 @@ export default function WorkersPanel() { ) : null} {row.status !== 'rejected' ? ( - + ) : null} {row.workerType !== 'internal' ? ( @@ -406,6 +442,40 @@ export default function WorkersPanel() { scroll={{ x: 1000 }} /> + { + setFreezeWorker(null) + freezeForm.resetFields() + }} + onOk={() => freezeForm.submit()} + destroyOnHidden + > +
+ + + {freezeWorker?.displayName || freezeWorker?.username || '-'} + + + + + +
+
+ ( `/api/v1/admin/worker-platform/workers/${workerId}/review`, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index bd9b3c61..5f1ab025 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -6,6 +6,8 @@ export type WorkerUser = { status: string workerType: string reviewNote: string + freezeReason: string + frozenAt: string | null inviteCode: string inviter: null | { workerId: number