feat(consignment): 自动匹配改为发布前即时匹配,manual 保留内部等待
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
resolveWorkOrderOperationalStatus,
|
||||
} from './mappers.js'
|
||||
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
|
||||
import { tryMatchConsignmentWorkOrderBeforePublish } from './consignment-match-engine-service.js'
|
||||
import { getWorkerHallConfig } from './worker-hall-config-service.js'
|
||||
import {
|
||||
USER_PAYMENT_AMOUNT_EDITABLE_STATUSES,
|
||||
@@ -363,6 +364,42 @@ export async function publishAdminWorkOrder(
|
||||
})
|
||||
}
|
||||
|
||||
// 自动匹配:发布到大厅前先试一次内部库存;命中后工单直接 in_progress,不进大厅。
|
||||
const matchedByConsignment = await tryMatchConsignmentWorkOrderBeforePublish(
|
||||
Number(workOrder.id),
|
||||
).catch(() => false)
|
||||
if (matchedByConsignment) {
|
||||
const currentOrder = await getRequiredWorkOrder(workOrder.id)
|
||||
let voucherConsume: VoucherConsumeScheduleResult
|
||||
try {
|
||||
voucherConsume = await scheduleVoucherConsumeForWorkOrder(
|
||||
currentOrder,
|
||||
'work_order_published',
|
||||
)
|
||||
} catch (error) {
|
||||
voucherConsume = {
|
||||
ok: false,
|
||||
voucherCount: 0,
|
||||
consumedCount: 0,
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 1,
|
||||
voucherCodes: [],
|
||||
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
|
||||
queuedCount: 0,
|
||||
jobIds: [],
|
||||
dueAt: '',
|
||||
status: 'failed',
|
||||
}
|
||||
}
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id) })
|
||||
return {
|
||||
order: mapWorkOrderAdmin(currentOrder),
|
||||
voucherConsume,
|
||||
hallVisible: false,
|
||||
matchedByConsignment: true,
|
||||
}
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
|
||||
const published = await publishUnassignedWorkOrder({
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const ENGINE = new URL('./consignment-match-engine-service.ts', import.meta.url)
|
||||
const PUBLISH = new URL('./admin-work-order-management-service.ts', import.meta.url)
|
||||
const PUBLIC = new URL('./order-service-public-service.ts', import.meta.url)
|
||||
|
||||
test('发布前即时匹配:只对 auto 生效,且允许从 unassigned 起始', async () => {
|
||||
const source = await readFile(ENGINE, 'utf8')
|
||||
const start = source.indexOf('export async function tryMatchConsignmentWorkOrderBeforePublish')
|
||||
assert.ok(start >= 0, '找不到 tryMatchConsignmentWorkOrderBeforePublish')
|
||||
const end = source.indexOf('export async function enterConsignmentInternalMatching', start)
|
||||
const body = source.slice(start, end > start ? end : start + 5000)
|
||||
assert.ok(body.includes("profile.config.matching.mode !== 'auto'"), '只处理 auto 模式')
|
||||
assert.ok(
|
||||
body.includes('startStatuses: [WORK_ORDER_STATUS.UNASSIGNED]'),
|
||||
'必须允许从待分配直接匹配,否则 apply 的状态校验会拒绝',
|
||||
)
|
||||
assert.ok(body.includes("phase: 'before_publish'"), '失败日志要能区分发布前匹配')
|
||||
})
|
||||
|
||||
test('进入内部匹配只对 manual 生效:auto 不再占用等待窗口', async () => {
|
||||
const source = await readFile(ENGINE, 'utf8')
|
||||
const start = source.indexOf('export async function enterConsignmentInternalMatching')
|
||||
assert.ok(start >= 0, '找不到 enterConsignmentInternalMatching')
|
||||
const body = source.slice(start, start + 2000)
|
||||
assert.ok(body.includes("profile.config.matching.mode !== 'manual'"))
|
||||
assert.ok(!body.includes("mode === 'off'"), '旧的 off 判断必须被 manual 判断替换')
|
||||
})
|
||||
|
||||
test('发布入口先试匹配:命中直接 in_progress,不进大厅', async () => {
|
||||
const source = await readFile(PUBLISH, 'utf8')
|
||||
const start = source.indexOf('export async function publishAdminWorkOrder')
|
||||
assert.ok(start >= 0, '找不到 publishAdminWorkOrder')
|
||||
const body = source.slice(start, start + 6000)
|
||||
const tryIndex = body.indexOf('tryMatchConsignmentWorkOrderBeforePublish')
|
||||
const publishIndex = body.indexOf('publishUnassignedWorkOrder')
|
||||
assert.ok(tryIndex >= 0, '发布前必须调 tryMatchConsignmentWorkOrderBeforePublish')
|
||||
assert.ok(publishIndex > tryIndex, '试匹配必须在发布大厅之前')
|
||||
assert.ok(body.includes('matchedByConsignment: true'))
|
||||
assert.ok(body.includes('hallVisible: false'))
|
||||
})
|
||||
|
||||
test('补资料链路先试匹配,未命中才进入 manual 内部匹配', async () => {
|
||||
const source = await readFile(PUBLIC, 'utf8')
|
||||
const tryIndex = source.indexOf('tryMatchConsignmentWorkOrderBeforePublish(')
|
||||
const enterIndex = source.indexOf('enterConsignmentInternalMatching(')
|
||||
assert.ok(tryIndex >= 0, '补资料后必须调用发布前试匹配')
|
||||
assert.ok(enterIndex > tryIndex, '试匹配必须在进入内部匹配之前')
|
||||
})
|
||||
|
||||
test('内部匹配超时:fallbackToHall=false 时重新匹配,最多 3 次', async () => {
|
||||
const source = await readFile(ENGINE, 'utf8')
|
||||
assert.ok(source.includes('export const MAX_INTERNAL_MATCH_ATTEMPTS = 3'))
|
||||
const start = source.indexOf('export async function expireConsignmentInternalMatching')
|
||||
assert.ok(start >= 0, '找不到 expireConsignmentInternalMatching')
|
||||
const body = source.slice(start, start + 3000)
|
||||
assert.ok(body.includes('fallbackToHall'), '必须读取超时回大厅开关')
|
||||
assert.ok(body.includes('MAX_INTERNAL_MATCH_ATTEMPTS'), '必须有重试次数上限')
|
||||
assert.ok(body.includes('startConsignmentRematch'), '关闭回大厅时要重新匹配')
|
||||
assert.ok(body.includes("status = 'open'"), '超过次数或失败后仍要回大厅兜底')
|
||||
})
|
||||
@@ -339,6 +339,136 @@ export async function resolveProfileForWorkOrder(
|
||||
return resolveConsignmentProfileForCategory(workOrder.category_id)
|
||||
}
|
||||
|
||||
/** 内部匹配超时后最多重新匹配的次数(fallbackToHall=false 时生效)。 */
|
||||
export const MAX_INTERNAL_MATCH_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* 发布前即时匹配(仅 matching.mode='auto'):
|
||||
* 工单仍在 unassigned 时直接尝试占库,命中后由 apply 事务改成 in_progress,
|
||||
* 调用方据此跳过大厅发布;未命中返回 false,由调用方继续走原发布流程。
|
||||
*
|
||||
* 与 30 秒轮询的区别:不等 internal_matching 窗口,发布动作发生时立刻试一次,
|
||||
* 失败直接进大厅;写入的匹配日志 phase=before_publish,便于区分来源。
|
||||
*/
|
||||
export async function tryMatchConsignmentWorkOrderBeforePublish(
|
||||
workOrderId: number,
|
||||
): Promise<boolean> {
|
||||
const workOrder = await getWorkOrderById(workOrderId)
|
||||
if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
if (!profile || profile.config.matching.mode !== 'auto') return false
|
||||
|
||||
const startedAt = Date.now()
|
||||
const logBase = {
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no || '',
|
||||
profileId: profile.profileId,
|
||||
categoryId: profile.categoryId,
|
||||
triggerType: 'auto' as const,
|
||||
actorType: 'system',
|
||||
actorId: 'consignment_match',
|
||||
}
|
||||
|
||||
try {
|
||||
const inventories = await listActiveConsignmentInventoriesForMatching()
|
||||
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
|
||||
allowPartial: profile.config.matching.exactOnly === false,
|
||||
preferLowReward: profile.config.matching.preferLowReward !== false,
|
||||
orderSpecOverride: await resolveOrderSpecOverride(workOrder, profile),
|
||||
})
|
||||
if (candidates.length === 0) {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'skipped',
|
||||
reasonCode: 'no_candidate',
|
||||
reasonText: '发布前没有符合条件的在库库存',
|
||||
candidateCount: inventories.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: { inventoryCount: inventories.length, phase: 'before_publish' },
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const context: ConsignmentMatchContext = {
|
||||
matchType: 'auto',
|
||||
// 发布前工单还没进 internal_matching,必须显式允许从 unassigned 起步,
|
||||
// 否则 applyWholeOrderMatch / applySharingMatch 的默认状态校验会直接拒绝。
|
||||
startStatuses: [WORK_ORDER_STATUS.UNASSIGNED],
|
||||
partial: candidates.some(({ evaluation }) => !evaluation.exact),
|
||||
specConflicts: candidates.flatMap(({ evaluation }) => [
|
||||
...evaluation.conflicts,
|
||||
...evaluation.unknowns,
|
||||
]),
|
||||
}
|
||||
|
||||
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
|
||||
const created = await applySharingMatch({ workOrder, profile }, candidates, context)
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: created > 0 ? 'matched' : 'failed',
|
||||
reasonCode: created > 0 ? 'auto_sharing_matched_before_publish' : 'sharing_not_applied',
|
||||
reasonText: created > 0 ? '' : '发布前拼单匹配未创建任何份额',
|
||||
candidateCount: candidates.length,
|
||||
matchedCount: created,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: {
|
||||
phase: 'before_publish',
|
||||
partial: context.partial,
|
||||
specConflicts: context.specConflicts,
|
||||
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
|
||||
},
|
||||
})
|
||||
return created > 0
|
||||
}
|
||||
|
||||
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
||||
const friendGift = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
|
||||
const chosen = candidates.find((candidate) => {
|
||||
const available = Number(candidate.inventory.remaining_quantity ?? 1)
|
||||
return available >= requiredQuantity && (!friendGift || available === requiredQuantity)
|
||||
})
|
||||
if (!chosen) {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'skipped',
|
||||
reasonCode: 'quantity_insufficient',
|
||||
reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`,
|
||||
candidateCount: candidates.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: { phase: 'before_publish', requiredQuantity },
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, context)
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: applied ? 'matched' : 'failed',
|
||||
reasonCode: applied ? 'auto_matched_before_publish' : 'apply_not_applied',
|
||||
reasonText: applied ? '' : '发布前整单匹配未落库(库存可能已被占用)',
|
||||
candidateCount: candidates.length,
|
||||
matchedCount: applied ? 1 : 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: {
|
||||
phase: 'before_publish',
|
||||
partial: context.partial,
|
||||
inventoryId: Number(chosen.inventory.id),
|
||||
},
|
||||
})
|
||||
return applied
|
||||
} catch (error) {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'failed',
|
||||
reasonCode: 'exception',
|
||||
reasonText: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: { phase: 'before_publish' },
|
||||
}).catch(() => undefined)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 进入「内部匹配中」:命中分类寄卖配置时由资料提交/自动发布链路调用。 */
|
||||
export async function enterConsignmentInternalMatching(
|
||||
workOrderId: number,
|
||||
@@ -348,8 +478,9 @@ export async function enterConsignmentInternalMatching(
|
||||
if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
if (!profile) return false
|
||||
// 关闭匹配:新单直接进大厅,不进入内部匹配队列。
|
||||
if (profile.config.matching.mode === 'off') return false
|
||||
// auto 模式改为「发布时即时匹配、失败直接进大厅」,不再占用内部匹配等待窗口;
|
||||
// 只有 manual 需要把工单留在 internal_matching 等运营人工挑库存。
|
||||
if (profile.config.matching.mode !== 'manual') return false
|
||||
const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)
|
||||
// 0 分钟 = 不等待,直接发大厅。此时不能进入 internal_matching:
|
||||
// addMinutesIso(0) 返回 null,工单会变成「内部匹配中且无截止时间」,
|
||||
@@ -1252,24 +1383,55 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
|
||||
return { scanned: orders.rows.length, matched }
|
||||
}
|
||||
|
||||
/** 内部匹配超时:发回大厅(保留已占库存的拼单份额,剩余份数继续抢单)。 */
|
||||
/**
|
||||
* 内部匹配超时:
|
||||
* - fallbackToHall=true(默认):发回大厅;
|
||||
* - fallbackToHall=false:重新匹配(排除原卖家/库存),最多 MAX_INTERNAL_MATCH_ATTEMPTS 次;
|
||||
* 超过次数或重新匹配失败仍回大厅,避免工单永久悬空。
|
||||
* 拼单已占库存的份额不受影响,剩余份数继续抢单。
|
||||
*/
|
||||
export async function expireConsignmentInternalMatching(options: { now?: string } = {}) {
|
||||
const now = options.now || nowIso()
|
||||
const expired = await query<WorkOrderRow>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET status = 'open',
|
||||
published_at = COALESCE(published_at, $1),
|
||||
hall_visible_at = NULL,
|
||||
internal_match_deadline_at = NULL,
|
||||
updated_at = $1
|
||||
SELECT * FROM work_orders
|
||||
WHERE status = 'internal_matching'
|
||||
AND (internal_match_deadline_at IS NULL OR internal_match_deadline_at <= $1)
|
||||
RETURNING *
|
||||
`,
|
||||
[now],
|
||||
)
|
||||
let expiredCount = 0
|
||||
let rematched = 0
|
||||
for (const workOrder of expired.rows) {
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
const fallbackToHall = profile?.config.internalMatch.fallbackToHall !== false
|
||||
const attempts = Number(workOrder.internal_match_attempts || 0)
|
||||
if (!fallbackToHall && attempts < MAX_INTERNAL_MATCH_ATTEMPTS) {
|
||||
const started = await startConsignmentRematch(Number(workOrder.id), {}, now).catch(
|
||||
() => false,
|
||||
)
|
||||
if (started) {
|
||||
rematched += 1
|
||||
continue
|
||||
}
|
||||
// 重新匹配失败时 startConsignmentRematch 内部已把工单放回大厅,
|
||||
// 下面的 UPDATE 带 status='internal_matching' 条件,不会重复命中。
|
||||
}
|
||||
const updated = await query<{ id: number }>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET status = 'open',
|
||||
published_at = COALESCE(published_at, $1),
|
||||
hall_visible_at = NULL,
|
||||
internal_match_deadline_at = NULL,
|
||||
updated_at = $1
|
||||
WHERE id = $2 AND status = 'internal_matching'
|
||||
RETURNING id
|
||||
`,
|
||||
[now, workOrder.id],
|
||||
)
|
||||
if (!updated.rows[0]) continue
|
||||
expiredCount += 1
|
||||
await query(
|
||||
`
|
||||
INSERT INTO work_order_events (
|
||||
@@ -1277,10 +1439,14 @@ export async function expireConsignmentInternalMatching(options: { now?: string
|
||||
payload_json, created_at
|
||||
) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_expired', 'internal_matching', 'open', $2::jsonb, $3)
|
||||
`,
|
||||
[workOrder.id, JSON.stringify({ expiredAt: now }), now],
|
||||
[
|
||||
workOrder.id,
|
||||
JSON.stringify({ expiredAt: now, attempts, fallbackToHall, rematched: false }),
|
||||
now,
|
||||
],
|
||||
)
|
||||
}
|
||||
return { expired: expired.rows.length }
|
||||
return { expired: expiredCount, rematched }
|
||||
}
|
||||
|
||||
/** 后台手动匹配弹窗:列出同配置在库库存,并标注精确/差异/冲突。 */
|
||||
|
||||
@@ -17,7 +17,10 @@ import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js'
|
||||
import {
|
||||
enterConsignmentInternalMatching,
|
||||
tryMatchConsignmentWorkOrderBeforePublish,
|
||||
} from './consignment-match-engine-service.js'
|
||||
import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js'
|
||||
import {
|
||||
ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES,
|
||||
@@ -387,8 +390,14 @@ export async function submitOrderServiceMaterial(
|
||||
}
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) })
|
||||
|
||||
// 命中分类寄卖配置时先进入内部匹配,不发大厅;超时由调度任务发回大厅。
|
||||
await enterConsignmentInternalMatching(Number(result.order.id)).catch(() => false)
|
||||
// 发布前先试一次自动匹配:命中直接 in_progress,不进大厅;
|
||||
// 未命中再走原逻辑(manual 进内部匹配等待,off/auto 留给发布链路发大厅)。
|
||||
const matchedBeforePublish = await tryMatchConsignmentWorkOrderBeforePublish(
|
||||
Number(result.order.id),
|
||||
).catch(() => false)
|
||||
if (!matchedBeforePublish) {
|
||||
await enterConsignmentInternalMatching(Number(result.order.id)).catch(() => false)
|
||||
}
|
||||
|
||||
if (result.autoPublishEnabled) {
|
||||
void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined)
|
||||
|
||||
@@ -1756,8 +1756,13 @@ export default function ConsignmentConfigPanel() {
|
||||
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="内部匹配等待(分钟)"
|
||||
label="手动匹配等待(分钟)"
|
||||
name={['internalMatch', 'delayMinutes']}
|
||||
extra={
|
||||
matchingMode === 'auto'
|
||||
? '自动匹配在发布时立即匹配,未命中直接进大厅,不使用等待窗口'
|
||||
: '仅手动匹配生效:工单在内部池等待运营挑库存'
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
@@ -1765,6 +1770,24 @@ export default function ConsignmentConfigPanel() {
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
disabled={matchingMode === 'auto'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="超时后回大厅"
|
||||
name={['internalMatch', 'fallbackToHall']}
|
||||
valuePropName="checked"
|
||||
extra={
|
||||
matchingMode === 'auto'
|
||||
? '自动匹配未命中直接进大厅,不涉及此策略'
|
||||
: '关闭后超时重新匹配(最多 3 次),仍未命中回大厅'
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch
|
||||
checkedChildren="回大厅"
|
||||
unCheckedChildren="重新匹配"
|
||||
disabled={matchingMode === 'auto'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
function readPanelSource() {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentConfigPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('自动匹配:手动等待与超时策略在 auto 下禁用并给出说明', () => {
|
||||
const source = readPanelSource()
|
||||
assert.ok(source.includes('label="手动匹配等待(分钟)"'), '等待时长文案必须明确只作用于手动匹配')
|
||||
assert.ok(source.includes('label="超时后回大厅"'), '必须提供超时回大厅开关')
|
||||
assert.ok(
|
||||
source.includes('自动匹配在发布时立即匹配,未命中直接进大厅,不使用等待窗口'),
|
||||
'auto 模式必须说明不使用等待窗口',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('关闭后超时重新匹配(最多 3 次),仍未命中回大厅'),
|
||||
'关闭回大厅时必须说明重试上限',
|
||||
)
|
||||
assert.ok(
|
||||
(source.match(/disabled=\{matchingMode === 'auto'\}/g) || []).length >= 2,
|
||||
'等待时长与超时策略在 auto 下都必须禁用',
|
||||
)
|
||||
assert.ok(!source.includes('label="内部匹配等待(分钟)"'), '旧文案必须移除')
|
||||
})
|
||||
@@ -35,7 +35,7 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/
|
||||
'开启单人寄售上限',
|
||||
'匹配模式',
|
||||
'关闭匹配',
|
||||
'内部匹配等待(分钟)',
|
||||
'手动匹配等待(分钟)',
|
||||
'代练超时(小时)',
|
||||
'重新匹配并退还押金',
|
||||
'开启降价提示',
|
||||
|
||||
Reference in New Issue
Block a user