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