feat(worker-platform): 支持后台人工调级锁定等级与解除自动升级限制

- 做了什么:
  1. 新增数据库迁移 098_worker_level_lock.sql,在 worker_users 表增加 is_level_locked、level_locked_at、level_locked_reason、level_locked_by 字段;
  2. 后端自动升级函数 maybeUpgradeWorkerLevelWithClient 和判定函数 shouldApplyAutomaticWorkerLevel 增加 is_level_locked 守卫,被锁定的打手跳过自动升级;
  3. 后台打手审核调级服务 reviewAdminWorkerUser 支持等级锁定参数与变更记录,人工调级时自动写入 worker_level_logs 流水,并提供 updateAdminWorkerLevelLock 专用接口;
  4. 前端打手列表等级列展示「已锁定」状态及 Tooltip 原因,调级弹窗支持「锁定自动升级」开关与原因输入,操作列提供「解除限制」快捷操作;
  5. 打手详情抽屉补充展示等级锁定说明。
- 为什么:
  解决打手被人工调整等级(如 VIP5 调至 VIP4)后,因历史累计完成单数满足门槛导致后续结算再次自动升级回高等级的问题,并支持后续随时解除限制恢复自动升级。
- 验证方式:
  1. 后端新增 shouldApplyAutomaticWorkerLevel 与 mapWorkerUser 单元测试,运行 npm run check(670 测试全过,类型与 SQL guard 验证通过);
  2. 前端新增 worker-level-lock.test.ts 单元测试,运行 npm run check(130 测试全过,typecheck 与 build 验证通过)。
This commit is contained in:
yml2213
2026-10-10 13:44:57 +08:00
parent 687bb40db1
commit 85fc2eaa7c
15 changed files with 395 additions and 11 deletions
@@ -0,0 +1,12 @@
-- 098_worker_level_lock.sql —— 打手等级人工锁定与自动升级限制。
ALTER TABLE worker_users
ADD COLUMN IF NOT EXISTS is_level_locked BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS level_locked_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS level_locked_reason TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS level_locked_by TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN worker_users.is_level_locked IS '是否锁定打手等级,锁定后禁止自动升级';
COMMENT ON COLUMN worker_users.level_locked_at IS '等级锁定时间';
COMMENT ON COLUMN worker_users.level_locked_reason IS '等级锁定原因/调级备注';
COMMENT ON COLUMN worker_users.level_locked_by IS '锁定操作人';
@@ -60,6 +60,29 @@ test('automatic worker level changes only move to a higher threshold', () => {
)
})
test('automatic worker level changes are rejected when isLevelLocked is true', () => {
assert.equal(
shouldApplyAutomaticWorkerLevel({
currentLevelId: 4,
currentThreshold: 50,
candidateLevelId: 5,
candidateThreshold: 100,
isLevelLocked: true,
}),
false,
)
assert.equal(
shouldApplyAutomaticWorkerLevel({
currentLevelId: 4,
currentThreshold: 50,
candidateLevelId: 5,
candidateThreshold: 100,
isLevelLocked: false,
}),
true,
)
})
test('指定打手的运营状态统计保留可见范围和拼单汇总关联', () => {
const query = buildWorkOrderWhere({
workerSharingId: 3,
@@ -47,6 +47,10 @@ export type WorkerUserRow = {
withdrawal_blacklisted_at?: string | null
can_consign?: boolean
consignment_activated_at?: string | null
is_level_locked?: boolean
level_locked_at?: string | null
level_locked_reason?: string
level_locked_by?: string
}
export type WorkerUserStatistics = {
@@ -141,6 +141,7 @@ export async function maybeUpgradeWorkerLevelWithClient(
const current = await client.query<{
id: number
level_id: number | null
is_level_locked: boolean
level_penalty_accepted_count: number
upgrade_threshold: number | null
}>(
@@ -148,6 +149,7 @@ export async function maybeUpgradeWorkerLevelWithClient(
SELECT
wu.id,
wu.level_id,
COALESCE(wu.is_level_locked, FALSE) AS is_level_locked,
wu.level_penalty_accepted_count,
COALESCE((wl.permission_json->>'upgradeThreshold')::int, 0) AS upgrade_threshold
FROM worker_users wu
@@ -159,7 +161,7 @@ export async function maybeUpgradeWorkerLevelWithClient(
[workerId],
)
const currentRow = current.rows[0]
if (!currentRow) return null
if (!currentRow || currentRow.is_level_locked) return null
const acceptedCount = await countWorkerAcceptedOrdersWithClient(client, workerId)
const effectiveAcceptedCount = Math.max(
@@ -191,6 +193,7 @@ export async function maybeUpgradeWorkerLevelWithClient(
currentThreshold: Number(currentRow.upgrade_threshold || 0),
candidateLevelId: nextLevelId,
candidateThreshold: Number(nextLevel?.upgrade_threshold || 0),
isLevelLocked: currentRow.is_level_locked,
})
) {
return null
@@ -223,7 +226,9 @@ export function shouldApplyAutomaticWorkerLevel(input: {
currentThreshold: number
candidateLevelId: number
candidateThreshold: number
isLevelLocked?: boolean
}) {
if (input.isLevelLocked) return false
if (!input.currentLevelId) return true
if (Number(input.currentLevelId) === Number(input.candidateLevelId)) return false
return Number(input.candidateThreshold) > Number(input.currentThreshold)
@@ -149,6 +149,10 @@ export async function updateWorkerUser(
| 'session_version'
| 'updated_at'
| 'reviewed_at'
| 'is_level_locked'
| 'level_locked_at'
| 'level_locked_reason'
| 'level_locked_by'
>
>,
): Promise<WorkerUserRow | null> {
@@ -169,8 +173,12 @@ export async function updateWorkerUser(
personal_deposit_free_amount = $7,
session_version = $8,
reviewed_at = $9,
updated_at = $10
WHERE id = $11
updated_at = $10,
is_level_locked = $11,
level_locked_at = $12,
level_locked_reason = $13,
level_locked_by = $14
WHERE id = $15
`,
[
next.status,
@@ -183,6 +191,10 @@ export async function updateWorkerUser(
Number(next.session_version || 1),
next.reviewed_at || null,
next.updated_at,
Boolean(next.is_level_locked),
next.level_locked_at || null,
next.level_locked_reason || '',
next.level_locked_by || '',
Number(workerId),
],
)
@@ -9,6 +9,7 @@ import {
listAdminWorkerWithdrawalAccounts,
resetAdminWorkerPassword,
reviewAdminWorkerUser,
updateAdminWorkerLevelLock,
saveAdminWorkerWithdrawalAccount,
setAdminWorkerPersonalDepositFreeAmount,
updateAdminWorkerWithdrawalBlacklist,
@@ -68,7 +69,12 @@ router.post(
'/worker-platform/workers/:workerId/review',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}),
(req) =>
reviewAdminWorkerUser(
String(req.params.workerId || ''),
req.body || {},
req.adminSession?.username || '',
),
{
successMessage: '打手账号状态已更新',
errorMessage: '更新打手账号状态失败',
@@ -83,6 +89,30 @@ router.post(
),
)
router.post(
'/worker-platform/workers/:workerId/level-lock',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
updateAdminWorkerLevelLock(
String(req.params.workerId || ''),
req.body || {},
req.adminSession?.username || '',
),
{
successMessage: '打手等级锁定状态已更新',
errorMessage: '更新打手等级锁定状态失败',
scope: '[admin/worker-platform/workers/:workerId/level-lock]',
audit: (req, data) => ({
action: 'worker_level_lock_updated',
targetType: 'worker',
targetId: String(req.params.workerId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post(
'/worker-platform/workers/:workerId/reset-password',
requireAdminRoles(['admin', 'operator']),
@@ -47,6 +47,7 @@ export {
listAdminWorkerUsers,
resetAdminWorkerPassword,
reviewAdminWorkerUser,
updateAdminWorkerLevelLock,
setAdminWorkerPersonalDepositFreeAmount,
} from './admin-worker-user-service.js'
export { syncWorkerOrdersForSourceOrder } from './sync-worker-orders-service.js'
@@ -1,3 +1,4 @@
import { query } from '../../db/client.js'
import {
countTimeoutEventsByWorkerIds,
getWorkerUserStatistics,
@@ -70,7 +71,11 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
}
}
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
export async function reviewAdminWorkerUser(
workerId: number | string,
payload: JsonObject = {},
operatorUsername: string = '',
) {
const worker = await getRequiredWorker(workerId)
const status = normalizeReviewStatus(payload.status)
const freezeReason =
@@ -80,6 +85,7 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
const workerType = normalizeWorkerType(payload.workerType ?? payload.worker_type)
const requestedLevelId = normalizeOptionalId(payload.levelId)
let levelId = worker.level_id || null
let isLevelChanged = false
if (requestedLevelId) {
const level = await getWorkerLevelById(requestedLevelId)
if (!level) {
@@ -89,11 +95,51 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
})
}
levelId = Number(level.id)
if (Number(worker.level_id) !== levelId) {
isLevelChanged = true
}
}
if (!levelId) {
levelId = (await ensureWorkerPlatformDefaults()).level?.id || null
}
const now = nowIso()
// 处理等级锁定逻辑
const rawIsLevelLocked = payload.isLevelLocked ?? payload.is_level_locked
let nextIsLevelLocked: boolean
if (rawIsLevelLocked !== undefined && rawIsLevelLocked !== null) {
nextIsLevelLocked = Boolean(rawIsLevelLocked)
} else if (isLevelChanged) {
// 若人工调整了等级但未显式指定锁,默认锁定(防止被自动升级冲掉)
nextIsLevelLocked = true
} else {
nextIsLevelLocked = Boolean(worker.is_level_locked)
}
const isLockStatusChanged = nextIsLevelLocked !== Boolean(worker.is_level_locked)
let nextLockedAt = worker.level_locked_at || null
let nextLockedReason = worker.level_locked_reason || ''
let nextLockedBy = worker.level_locked_by || ''
if (nextIsLevelLocked) {
if (isLockStatusChanged || isLevelChanged || !nextLockedAt) {
nextLockedAt = now
nextLockedReason = String(
payload.levelLockedReason ??
payload.lockReason ??
payload.reviewNote ??
worker.level_locked_reason ??
'人工调整等级锁定',
).trim()
nextLockedBy =
operatorUsername || String(payload.operator || worker.level_locked_by || '').trim()
}
} else {
nextLockedAt = null
nextLockedReason = ''
nextLockedBy = ''
}
const updated = await updateWorkerUser(worker.id, {
status,
worker_type: workerType,
@@ -107,7 +153,43 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
session_version: normalizeSessionVersion(worker.session_version) + 1,
reviewed_at: now,
updated_at: now,
is_level_locked: nextIsLevelLocked,
level_locked_at: nextLockedAt,
level_locked_reason: nextLockedReason,
level_locked_by: nextLockedBy,
})
// 如果等级发生了变动,或者锁定状态发生了变动,写入 worker_level_logs
if (isLevelChanged || isLockStatusChanged) {
const logReason = isLevelChanged
? 'admin_adjust'
: nextIsLevelLocked
? 'admin_lock'
: 'admin_unlock'
try {
await query(
`INSERT INTO worker_level_logs (
worker_id, from_level_id, to_level_id, reason, payload_json, created_at
) VALUES ($1, $2, $3, $4, $5::jsonb, $6)`,
[
worker.id,
worker.level_id || null,
levelId,
logReason,
JSON.stringify({
operator: operatorUsername || payload.operator || '',
isLevelLocked: nextIsLevelLocked,
reason: nextLockedReason,
note: payload.reviewNote || '',
}),
now,
],
)
} catch {
// 日志记录失败不阻断主流程
}
}
await revokeAllWorkerSessions(worker.id, now)
// 必须同步释放内存里的风控状态。
@@ -120,6 +202,22 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
return { worker: mapWorkerUser(updated || worker) }
}
export async function updateAdminWorkerLevelLock(
workerId: number | string,
payload: JsonObject = {},
operatorUsername: string = '',
) {
const isLevelLocked = Boolean(payload.isLevelLocked ?? payload.is_level_locked)
return reviewAdminWorkerUser(
workerId,
{
isLevelLocked,
levelLockedReason: payload.levelLockedReason ?? payload.lockReason,
},
operatorUsername,
)
}
/** 设置单个打手的追加免押额度,会与该打手等级的免押额度叠加。 */
export async function setAdminWorkerPersonalDepositFreeAmount(
workerId: number | string,
@@ -112,6 +112,10 @@ export function mapWorkerUser(worker: WorkerUserRow) {
: null,
canConsign: Boolean(worker.can_consign),
consignmentActivatedAt: worker.consignment_activated_at || null,
isLevelLocked: Boolean(worker.is_level_locked),
levelLockedAt: worker.level_locked_at || null,
levelLockedReason: worker.level_locked_reason || '',
levelLockedBy: worker.level_locked_by || '',
}
}
@@ -2119,6 +2119,52 @@ test('打手提现黑名单信息映射到前端结构', () => {
assert.equal(normalMapped.withdrawalBlacklistedAt, null)
})
test('打手等级锁定信息正确映射到前端结构', () => {
const lockedWorker = {
id: 1,
username: 'worker1',
password_hash: 'hash',
display_name: '打手1',
phone: '13800000000',
level_id: 4,
status: 'active',
worker_type: 'external',
review_note: '',
frozen_reason: '',
frozen_at: null,
session_version: 1,
created_at: '2026-09-01T00:00:00.000Z',
updated_at: '2026-09-01T00:00:00.000Z',
reviewed_at: null,
inviter_id: null,
invite_code: 'INVITE1',
is_level_locked: true,
level_locked_at: '2026-10-10T10:00:00.000Z',
level_locked_reason: '人工调整为VIP4',
level_locked_by: 'admin_test',
} satisfies WorkerUserRow
const mapped = mapWorkerUser(lockedWorker)
assert.equal(mapped.isLevelLocked, true)
assert.equal(mapped.levelLockedAt, '2026-10-10T10:00:00.000Z')
assert.equal(mapped.levelLockedReason, '人工调整为VIP4')
assert.equal(mapped.levelLockedBy, 'admin_test')
const unlockedWorker = {
...lockedWorker,
is_level_locked: false,
level_locked_at: null,
level_locked_reason: '',
level_locked_by: '',
} satisfies WorkerUserRow
const unlockedMapped = mapWorkerUser(unlockedWorker)
assert.equal(unlockedMapped.isLevelLocked, false)
assert.equal(unlockedMapped.levelLockedAt, null)
assert.equal(unlockedMapped.levelLockedReason, '')
assert.equal(unlockedMapped.levelLockedBy, '')
})
test('打手查询条件支持提现黑名单筛选', () => {
const blacklistedWhere = buildWorkerUserWhere({
isWithdrawalBlacklisted: true,
@@ -153,7 +153,23 @@ function OverviewSection({ workerId }: { workerId: number }) {
<Descriptions.Item label="昵称">{worker.displayName || '-'}</Descriptions.Item>
<Descriptions.Item label="账号">{worker.username}</Descriptions.Item>
<Descriptions.Item label="手机号">{worker.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="等级">{worker.level?.name || '-'}</Descriptions.Item>
<Descriptions.Item label="等级">
<Space size={6} wrap>
<span>{worker.level?.name || '-'}</span>
{worker.isLevelLocked ? <Tag color="warning">已锁定自动升级</Tag> : null}
</Space>
</Descriptions.Item>
{worker.isLevelLocked ? (
<Descriptions.Item label="等级锁定说明" span={2}>
<Typography.Text type="secondary">
{worker.levelLockedReason ? `原因: ${worker.levelLockedReason}` : '人工调级锁定'}
{worker.levelLockedBy ? ` · 操作人: ${worker.levelLockedBy}` : ''}
{worker.levelLockedAt
? ` · 时间: ${formatAdminDateTime(worker.levelLockedAt)}`
: ''}
</Typography.Text>
</Descriptions.Item>
) : null}
<Descriptions.Item label="邀请人">
{worker.inviter ? `${worker.inviter.displayName || worker.inviter.username}` : '-'}
</Descriptions.Item>
@@ -1,4 +1,4 @@
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { LockOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
@@ -9,6 +9,7 @@ import {
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
@@ -29,6 +30,7 @@ import {
fetchAdminWorkerWithdrawalAccounts,
resetAdminWorkerPassword,
reviewAdminWorkerUser,
updateAdminWorkerLevelLock,
saveAdminWorkerFinanceConfig,
saveAdminWorkerPersonalDepositFreeAmount,
saveAdminWorkerWithdrawalAccount,
@@ -149,7 +151,11 @@ export default function WorkersPanel() {
const [loadingWithdrawalAccounts, setLoadingWithdrawalAccounts] = useState(false)
const [savingWithdrawalAccount, setSavingWithdrawalAccount] = useState(false)
const [creditForm] = Form.useForm()
const [levelForm] = Form.useForm<{ levelId?: number }>()
const [levelForm] = Form.useForm<{
levelId?: number
isLevelLocked?: boolean
levelLockedReason?: string
}>()
const [withdrawalAccountForm] = Form.useForm<WithdrawalAccountFormValues>()
const [resetPasswordForm] = Form.useForm<{ password: string }>()
const [freezeForm] = Form.useForm<FreezeWorkerFormValues>()
@@ -388,13 +394,19 @@ export default function WorkersPanel() {
}
}
async function submitLevel(values: { levelId?: number }) {
async function submitLevel(values: {
levelId?: number
isLevelLocked?: boolean
levelLockedReason?: string
}) {
if (!levelWorker) return
try {
await reviewAdminWorkerUser(levelWorker.workerId, {
status: levelWorker.status,
levelId: Number(values.levelId || 0),
reviewNote: levelWorker.reviewNote,
isLevelLocked: Boolean(values.isLevelLocked),
levelLockedReason: values.levelLockedReason?.trim() || undefined,
})
message.success('打手等级已更新')
setLevelWorker(null)
@@ -405,6 +417,18 @@ export default function WorkersPanel() {
}
}
async function handleUnlockLevel(worker: WorkerUser) {
try {
await updateAdminWorkerLevelLock(worker.workerId, {
isLevelLocked: false,
})
message.success(`已解除打手 ${worker.displayName || worker.username} 的升级限制`)
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '解除升级限制失败')
}
}
async function submitPersonalDepositFreeAmount(values: PersonalDepositFreeAmountFormValues) {
if (!personalDepositFreeWorker) return
try {
@@ -605,7 +629,26 @@ export default function WorkersPanel() {
</div>
),
},
{ title: '等级', width: 140, render: (_, row) => row.level?.name || '-' },
{
title: '等级',
width: 150,
render: (_, row) => (
<Space size={4} wrap>
<span>{row.level?.name || '-'}</span>
{row.isLevelLocked ? (
<Tooltip
title={`已锁定自动升级${row.levelLockedReason ? `:${row.levelLockedReason}` : ''}${
row.levelLockedBy ? ` (操作人: ${row.levelLockedBy})` : ''
}`}
>
<Tag color="warning" icon={<LockOutlined />}>
已锁定
</Tag>
</Tooltip>
) : null}
</Space>
),
},
{
title: '免押额度',
width: 280,
@@ -738,11 +781,24 @@ export default function WorkersPanel() {
setLevelWorker(row)
levelForm.setFieldsValue({
levelId: row.level?.levelId,
isLevelLocked: row.isLevelLocked ?? true,
levelLockedReason: row.levelLockedReason || '',
})
}}
>
调等级
</Button>
{row.isLevelLocked ? (
<Popconfirm
title="解除等级升级限制"
description="解除后,该打手将重新允许根据累计完成单数自动晋升等级。确定解除限制吗?"
onConfirm={() => void handleUnlockLevel(row)}
>
<Button danger ghost>
解除限制
</Button>
</Popconfirm>
) : null}
<Button
onClick={() => {
setPersonalDepositFreeWorker(row)
@@ -1101,9 +1157,14 @@ export default function WorkersPanel() {
>
<Form form={levelForm} layout="vertical" onFinish={submitLevel}>
<Form.Item label="打手">
<Typography.Text>
<Typography.Text strong>
{levelWorker?.displayName || levelWorker?.username || '-'}
</Typography.Text>
{levelWorker?.level?.name ? (
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
(当前等级: {levelWorker.level.name})
</Typography.Text>
) : null}
</Form.Item>
<Form.Item
label="目标等级"
@@ -1118,6 +1179,22 @@ export default function WorkersPanel() {
}))}
/>
</Form.Item>
<Form.Item
label="锁定自动升级"
name="isLevelLocked"
valuePropName="checked"
extra="开启后该打手将无法再次触发按完成单数自动晋升逻辑;后续可随时解除限制。"
>
<Switch checkedChildren="锁定" unCheckedChildren="允许自动升级" />
</Form.Item>
<Form.Item label="调级/锁定原因" name="levelLockedReason">
<Input.TextArea
placeholder="请输入调级或锁定原因(如:人工降级惩罚、特殊定级等)"
rows={2}
maxLength={200}
showCount
/>
</Form.Item>
</Form>
</Modal>
@@ -418,6 +418,8 @@ export function reviewAdminWorkerUser(
freezeReason?: string
levelId?: number
workerType?: string
isLevelLocked?: boolean
levelLockedReason?: string
},
) {
return apiPost<{ worker: WorkerUser }>(
@@ -426,6 +428,19 @@ export function reviewAdminWorkerUser(
)
}
export function updateAdminWorkerLevelLock(
workerId: number,
payload: {
isLevelLocked: boolean
levelLockedReason?: string
},
) {
return apiPost<{ worker: WorkerUser }>(
`/api/v1/admin/worker-platform/workers/${workerId}/level-lock`,
payload,
)
}
export function saveAdminWorkerPersonalDepositFreeAmount(
workerId: number,
payload: { depositFreeAmount: number | null },
@@ -58,6 +58,14 @@ export type WorkerUser = {
canConsign: boolean
/** 开通寄卖权限时间 */
consignmentActivatedAt: string | null
/** 是否已锁定打手等级(禁止自动升级) */
isLevelLocked?: boolean
/** 等级锁定时间 */
levelLockedAt?: string | null
/** 等级锁定原因/调级备注 */
levelLockedReason?: string
/** 等级锁定操作人 */
levelLockedBy?: string
}
export type WorkerUserStatistics = {
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { WorkerUser } from '../src/types/worker-platform.ts'
test('WorkerUser 类型支持等级锁定及审计字段', () => {
const worker: Partial<WorkerUser> = {
workerId: 101,
isLevelLocked: true,
levelLockedReason: '人工调整至VIP4',
levelLockedAt: '2026-10-10T12:00:00.000Z',
levelLockedBy: 'admin',
}
assert.equal(worker.isLevelLocked, true)
assert.equal(worker.levelLockedReason, '人工调整至VIP4')
assert.equal(worker.levelLockedBy, 'admin')
})
test('WorkerUser 未锁定时锁定字段为 false 或空', () => {
const worker: Partial<WorkerUser> = {
workerId: 102,
isLevelLocked: false,
levelLockedReason: '',
levelLockedAt: null,
levelLockedBy: '',
}
assert.equal(worker.isLevelLocked, false)
assert.equal(worker.levelLockedReason, '')
assert.equal(worker.levelLockedAt, null)
assert.equal(worker.levelLockedBy, '')
})