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
@@ -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,