diff --git a/apps/backend/src/db/migrations/026_vip_auto_accept_evidence.sql b/apps/backend/src/db/migrations/026_vip_auto_accept_evidence.sql
new file mode 100644
index 00000000..fffd15c8
--- /dev/null
+++ b/apps/backend/src/db/migrations/026_vip_auto_accept_evidence.sql
@@ -0,0 +1,16 @@
+-- VIP5 免审核验收:允许无验收图自动结算,并在验收后 24 小时内补图。
+-- 权限保存在等级 JSON 中,避免为单一等级能力新增固定表字段。
+
+UPDATE worker_levels
+SET
+ permission_json = jsonb_set(
+ COALESCE(permission_json, '{}'::jsonb),
+ '{autoAcceptWithoutEvidence}',
+ CASE WHEN level_key = 'vip5' THEN 'true'::jsonb ELSE 'false'::jsonb END,
+ true
+ ),
+ updated_at = NOW()
+WHERE NOT (COALESCE(permission_json, '{}'::jsonb) ? 'autoAcceptWithoutEvidence');
+
+COMMENT ON COLUMN worker_levels.permission_json IS
+ '等级权限:免押额度、最大同时接单量、VIP 免审核及其他等级能力配置';
diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts
index a8f03dc2..54821e6e 100644
--- a/apps/backend/src/routes/worker.ts
+++ b/apps/backend/src/routes/worker.ts
@@ -24,6 +24,7 @@ import {
sendWorkerSmsCode,
saveWorkerOrderAcceptanceDraft,
saveWorkerOrderNote,
+ supplementWorkerAcceptedOrderEvidence,
submitWorkerOrderAcceptance,
} from '../services/worker-platform/index.js'
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
@@ -335,6 +336,24 @@ router.post(
),
)
+router.post(
+ '/orders/:workOrderId/acceptance-evidence',
+ requireActiveWorker,
+ createRouteHandler(
+ (req) =>
+ supplementWorkerAcceptedOrderEvidence(
+ String(req.params.workOrderId || ''),
+ req.body || {},
+ getRequiredWorkerSession(req),
+ ),
+ {
+ successMessage: '验收图片已补充',
+ errorMessage: '补充验收图片失败',
+ scope: '[worker/orders/:workOrderId/acceptance-evidence]',
+ },
+ ),
+)
+
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts
index ada9f610..067f44e2 100644
--- a/apps/backend/src/services/worker-platform/admin-service.ts
+++ b/apps/backend/src/services/worker-platform/admin-service.ts
@@ -164,6 +164,7 @@ export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
const now = nowIso()
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
const existingLevel = await getWorkerLevelByKey(levelKey)
+ const existingPermissions = safeParseJson(existingLevel?.permission_json)
const name = String(payload.name || '').trim() || DEFAULT_LEVEL_NAME
const permissions = {
depositFreeAmount: normalizeAmountFen(
@@ -172,6 +173,10 @@ export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
),
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0),
+ autoAcceptWithoutEvidence: normalizeBoolean(
+ payload.autoAcceptWithoutEvidence,
+ normalizeBoolean(existingPermissions.autoAcceptWithoutEvidence, levelKey === 'vip5'),
+ ),
visibleDelaySeconds: normalizeInteger(payload.visibleDelaySeconds, -1),
}
const level = await upsertWorkerLevel({
diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts
index baf944f3..f84ce7ea 100644
--- a/apps/backend/src/services/worker-platform/mappers.ts
+++ b/apps/backend/src/services/worker-platform/mappers.ts
@@ -98,6 +98,7 @@ export function resolveWorkerPermissions(worker: WorkerUserRow) {
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
+ autoAcceptWithoutEvidence: normalizeBoolean(permission.autoAcceptWithoutEvidence, false),
visibleDelaySeconds: resolveVisibleDelaySeconds(
worker.level_key || '',
permission.visibleDelaySeconds,
@@ -140,6 +141,7 @@ export function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
+ autoAcceptWithoutEvidence: normalizeBoolean(permission.autoAcceptWithoutEvidence, false),
visibleDelaySeconds: resolveVisibleDelaySeconds(
level.level_key,
permission.visibleDelaySeconds,
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 caaf8e45..7cd177f0 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
@@ -8,6 +8,7 @@ import {
normalizeWithdrawChannel,
normalizeRequirementFields,
resolveCollectSubmitTargetWorkOrder,
+ resolveWorkerPermissions,
resolveSkuNameQuantity,
} from './index.js'
@@ -96,6 +97,18 @@ test('提现渠道仅支持支付宝和微信', () => {
assert.throws(() => normalizeWithdrawChannel('bank'))
})
+test('等级免审核后补图权限默认关闭,仅显式开启时生效', () => {
+ assert.equal(resolveWorkerPermissions(buildWorkerUserRow()).autoAcceptWithoutEvidence, false)
+ assert.equal(
+ resolveWorkerPermissions(
+ buildWorkerUserRow({
+ level_permission_json: { autoAcceptWithoutEvidence: true },
+ }),
+ ).autoAcceptWithoutEvidence,
+ true,
+ )
+})
+
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 bd3e657c..31263050 100644
--- a/apps/backend/src/services/worker-platform/worker-service.ts
+++ b/apps/backend/src/services/worker-platform/worker-service.ts
@@ -62,7 +62,7 @@ import {
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
-import { nowIso } from '../../utils/time.js'
+import { addHours, 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'
@@ -115,6 +115,7 @@ import {
} from './mappers.js'
const WORKER_DAILY_WITHDRAW_LIMIT = 3
+const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24
export type WorkerSession = {
sessionId: string
@@ -1266,10 +1267,13 @@ export async function submitWorkerOrderAcceptance(
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
+ const worker = await getRequiredWorker(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
}
+ const canAutoAcceptWithoutEvidence =
+ !workOrder.sharing_enabled && resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
const now = nowIso()
if (
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
@@ -1305,7 +1309,7 @@ export async function submitWorkerOrderAcceptance(
payload,
workOrder.draft_acceptance_json,
)
- if (imageUrls.length === 0) {
+ if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
throw createHttpError('请上传验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
@@ -1316,6 +1320,14 @@ export async function submitWorkerOrderAcceptance(
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: now,
+ ...(canAutoAcceptWithoutEvidence
+ ? {
+ reviewMode: 'vip_auto',
+ evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending',
+ evidenceDueAt:
+ imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
+ }
+ : {}),
}
const updated = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
@@ -1331,11 +1343,14 @@ export async function submitWorkerOrderAcceptance(
eventType: 'acceptance_submitted',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
- payloadJson: JSON.stringify(acceptance),
+ payloadJson: JSON.stringify({
+ imageCount: imageUrls.length,
+ autoAccepted: canAutoAcceptWithoutEvidence,
+ evidenceDueAt: acceptance.evidenceDueAt || null,
+ }),
now,
})
- const worker = await getRequiredWorker(session.workerId)
- if (worker.worker_type === 'internal') {
+ if (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) {
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
@@ -1350,7 +1365,7 @@ export async function submitWorkerOrderAcceptance(
})
}
if (!acceptedOrder) {
- throw createHttpError('内部打手免审核验收失败,请稍后重试', {
+ throw createHttpError('免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_internal_auto_accept_failed',
})
@@ -1359,10 +1374,14 @@ export async function submitWorkerOrderAcceptance(
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
- eventType: 'accepted_by_internal',
+ eventType: worker.worker_type === 'internal' ? 'accepted_by_internal' : 'accepted_by_vip',
fromStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
- payloadJson: JSON.stringify({ autoAccepted: true }),
+ payloadJson: JSON.stringify({
+ autoAccepted: true,
+ evidenceStatus: acceptance.evidenceStatus || 'complete',
+ evidenceDueAt: acceptance.evidenceDueAt || null,
+ }),
now,
})
return {
@@ -1374,6 +1393,111 @@ export async function submitWorkerOrderAcceptance(
}
}
+/** VIP 自动验收订单在 24 小时内追加验收图片,不改变订单状态和结算结果。 */
+export async function supplementWorkerAcceptedOrderEvidence(
+ workOrderId: number | string,
+ payload: JsonObject = {},
+ session: WorkerSession,
+) {
+ requireActiveWorkerSession(session)
+ const workOrder = await getRequiredWorkOrder(workOrderId)
+ const worker = await getRequiredWorker(session.workerId)
+ if (workOrder.sharing_enabled) {
+ throw createHttpError('拼单订单暂不支持免审核后补图', {
+ statusCode: 409,
+ errorCode: 'work_order_vip_evidence_sharing_not_supported',
+ })
+ }
+ if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
+ throw createHttpError('只能补充自己的订单验收图片', {
+ statusCode: 403,
+ errorCode: 'work_order_owner_required',
+ })
+ }
+ if (workOrder.status !== WORK_ORDER_STATUS.ACCEPTED) {
+ throw createHttpError('只有已验收订单可以补充验收图片', {
+ statusCode: 409,
+ errorCode: 'work_order_evidence_supplement_status_invalid',
+ })
+ }
+
+ const now = nowIso()
+ const previous = safeParseJson(workOrder.acceptance_json)
+ if (String(previous.reviewMode || '').trim() !== 'vip_auto') {
+ throw createHttpError('该订单不支持验收后补图', {
+ statusCode: 403,
+ errorCode: 'work_order_evidence_supplement_not_allowed',
+ })
+ }
+ const evidenceDueAt = String(previous.evidenceDueAt || '').trim()
+ if (!evidenceDueAt || Date.parse(evidenceDueAt) <= Date.now()) {
+ throw createHttpError('补充验收图片已超过 24 小时时限', {
+ statusCode: 409,
+ errorCode: 'work_order_evidence_supplement_expired',
+ })
+ }
+
+ const existingFiles = normalizeUploadedFiles(previous.files)
+ const uploadedFiles = normalizeUploadedFiles(payload.files)
+ const mergedFiles = [...existingFiles]
+ const fileIdentities = new Set(
+ existingFiles.map((file) => file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl),
+ )
+ for (const file of uploadedFiles) {
+ const identity = file.objectKey || file.url || file.mediumUrl || file.thumbnailUrl
+ if (!identity || fileIdentities.has(identity)) continue
+ fileIdentities.add(identity)
+ mergedFiles.push(file)
+ }
+
+ const previousImageUrls = normalizeStringArray(previous.imageUrls)
+ const imageUrls = [
+ ...new Set([
+ ...previousImageUrls,
+ ...mergedFiles.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
+ ]),
+ ]
+ if (
+ mergedFiles.length === existingFiles.length &&
+ imageUrls.length === previousImageUrls.length
+ ) {
+ throw createHttpError('请上传新的验收图片', {
+ statusCode: 400,
+ errorCode: 'work_order_evidence_supplement_image_required',
+ })
+ }
+
+ const acceptance = {
+ ...previous,
+ files: mergedFiles,
+ imageUrls,
+ evidenceStatus: 'complete',
+ evidenceSupplementedAt: now,
+ updatedAt: now,
+ }
+ const updated = await updateWorkOrderAcceptanceRecord({
+ workOrderId: workOrder.id,
+ acceptanceJson: JSON.stringify(acceptance),
+ now,
+ })
+ await createWorkOrderEvent({
+ workOrderId: workOrder.id,
+ actorType: 'worker',
+ actorId: String(session.workerId),
+ eventType: 'acceptance_evidence_supplemented',
+ fromStatus: WORK_ORDER_STATUS.ACCEPTED,
+ toStatus: WORK_ORDER_STATUS.ACCEPTED,
+ payloadJson: JSON.stringify({
+ addedImageCount: mergedFiles.length - existingFiles.length,
+ totalImageCount: imageUrls.length,
+ }),
+ now,
+ })
+ return {
+ order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
+ }
+}
+
async function submitWorkerSharingAcceptance(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx
index 941f3c0e..59988e3b 100644
--- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx
+++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx
@@ -129,6 +129,19 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
)}`,
color: 'purple',
}
+ case 'accepted_by_vip':
+ return {
+ title: 'VIP 免审核验收通过,订单完成',
+ description: readPayload(event, 'evidenceDueAt') ? '可在 24 小时内补充验收图片' : undefined,
+ color: 'purple',
+ }
+ case 'acceptance_evidence_supplemented': {
+ const addedImageCount = readPayload(event, 'addedImageCount')
+ return {
+ title: `已验收后补充验收图片${addedImageCount ? `(新增 ${addedImageCount} 张)` : ''}`,
+ color: 'purple',
+ }
+ }
case 'accepted':
case 'accepted_by_admin':
return {
diff --git a/apps/frontend/src/pages/admin/panels/LevelsPanel.tsx b/apps/frontend/src/pages/admin/panels/LevelsPanel.tsx
index 51d93b54..6fe53d54 100644
--- a/apps/frontend/src/pages/admin/panels/LevelsPanel.tsx
+++ b/apps/frontend/src/pages/admin/panels/LevelsPanel.tsx
@@ -8,6 +8,7 @@ import {
InputNumber,
Modal,
Space,
+ Switch,
Table,
Tag,
Typography,
@@ -46,6 +47,7 @@ export default function LevelsPanel() {
depositFreeAmount: level.depositFreeAmount,
maxActiveOrders: level.maxActiveOrders,
upgradeThreshold: level.upgradeThreshold,
+ autoAcceptWithoutEvidence: level.autoAcceptWithoutEvidence,
visibleDelaySeconds: level.visibleDelaySeconds,
})
}
@@ -61,6 +63,7 @@ export default function LevelsPanel() {
depositFreeAmount?: number
maxActiveOrders?: number
upgradeThreshold?: number
+ autoAcceptWithoutEvidence?: boolean
visibleDelaySeconds?: number
}) {
try {
@@ -81,6 +84,7 @@ export default function LevelsPanel() {
depositFreeAmount: level.depositFreeAmount,
maxActiveOrders: level.maxActiveOrders,
upgradeThreshold: level.upgradeThreshold,
+ autoAcceptWithoutEvidence: level.autoAcceptWithoutEvidence,
visibleDelaySeconds: level.visibleDelaySeconds,
status: level.status === 'active' ? 'disabled' : 'active',
})
@@ -116,6 +120,12 @@ export default function LevelsPanel() {
},
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
{ title: '升级所需累计完成', dataIndex: 'upgradeThreshold' },
+ {
+ title: '免审核后补图',
+ width: 130,
+ render: (_, row) =>
+ row.autoAcceptWithoutEvidence ?