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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user