接单平台新增 VIP 等级体系与邀请绑定

- 等级按累计验收通过工单数自动升级(阈值后台可配),VIP1-5 默认配置 10/30/50/100
- 存量打手统一归 VIP1,原 regular 等级停用
- 注册支持邀请码绑定邀请人,每人生成唯一邀请码
- 新增 worker_level_logs 升级流水表
- 个人中心展示邀请码与升级进度,管理端等级表单支持升级阈值配置
This commit is contained in:
yml2213
2026-08-01 14:50:08 +08:00
parent 6e635b2499
commit 51d72cc283
9 changed files with 353 additions and 21 deletions
@@ -16,6 +16,8 @@ import {
getWorkerFinanceRequestById,
getWorkerFinanceRequestSummary,
getWorkerLevelById,
getWorkerLevelByKey,
getWorkerUserByInviteCode,
incrementWorkerSessionVersion,
getWorkOrderById,
getWorkOrderByOrderItemId,
@@ -73,9 +75,12 @@ export type WorkerSession = {
sessionVersion: number
}
const DEFAULT_LEVEL_KEY = 'regular'
const DEFAULT_LEVEL_KEY = 'vip1'
const DEFAULT_LEVEL_NAME = 'VIP1'
const DEFAULT_CATEGORY_KEY = 'default'
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
const INVITE_CODE_LENGTH = 8
const INVITE_CODE_RETRY_LIMIT = 5
const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
'worker_auth_required',
'worker_auth_invalid',
@@ -86,17 +91,21 @@ const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
export async function ensureWorkerPlatformDefaults() {
const now = nowIso()
const level = await upsertWorkerLevel({
levelKey: DEFAULT_LEVEL_KEY,
name: '普通打手',
sortOrder: 100,
status: 'active',
permissionJson: JSON.stringify({
depositFreeAmount: 0,
maxActiveOrders: 3,
}),
now,
})
const existingLevel = await getWorkerLevelByKey(DEFAULT_LEVEL_KEY)
const level =
existingLevel ||
(await upsertWorkerLevel({
levelKey: DEFAULT_LEVEL_KEY,
name: DEFAULT_LEVEL_NAME,
sortOrder: 10,
status: 'active',
permissionJson: JSON.stringify({
depositFreeAmount: 0,
maxActiveOrders: 1,
upgradeThreshold: 0,
}),
now,
}))
const category = await upsertWorkCategory({
categoryKey: DEFAULT_CATEGORY_KEY,
name: '默认分类',
@@ -113,6 +122,7 @@ export async function registerWorker(payload: JsonObject = {}) {
const password = normalizePassword(payload.password)
const displayName = String(payload.displayName || payload.nickname || username).trim()
const phone = String(payload.phone || '').trim()
const inviteCode = String(payload.inviteCode || '').trim().toUpperCase()
validateWorkerUsername(username)
validateWorkerPassword(password)
@@ -124,6 +134,20 @@ export async function registerWorker(payload: JsonObject = {}) {
})
}
const inviter = inviteCode ? await getWorkerUserByInviteCode(inviteCode) : null
if (inviteCode && !inviter) {
throw createHttpError('邀请码无效', {
statusCode: 400,
errorCode: 'worker_invite_code_invalid',
})
}
if (inviter && inviter.status !== 'active') {
throw createHttpError('邀请人账号不可用', {
statusCode: 400,
errorCode: 'worker_inviter_invalid',
})
}
const defaults = await ensureWorkerPlatformDefaults()
const now = nowIso()
const created = await createWorkerUser({
@@ -133,6 +157,8 @@ export async function registerWorker(payload: JsonObject = {}) {
phone,
levelId: defaults.level?.id || null,
status: 'pending_review',
inviterId: inviter ? Number(inviter.id) : null,
inviteCode: await generateInviteCode(now),
now,
})
@@ -149,6 +175,23 @@ export async function registerWorker(payload: JsonObject = {}) {
}
}
async function generateInviteCode(now: string): Promise<string> {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) {
const candidate = Array.from(
{ length: INVITE_CODE_LENGTH },
() => chars[Math.floor(crypto.randomInt(chars.length))],
).join('')
if (!(await getWorkerUserByInviteCode(candidate))) {
return candidate
}
}
throw createHttpError('邀请码生成失败,请重试', {
statusCode: 500,
errorCode: 'worker_invite_code_generation_failed',
})
}
export async function loginWorker(username: unknown, password: unknown) {
ensureWorkerAuthConfigured()
const normalizedUsername = normalizeUsername(username)
@@ -298,19 +341,61 @@ export async function getWorkerProfile(session: WorkerSession) {
countWorkerAcceptedOrders(session.workerId),
Promise.resolve(getWorkerFinanceConfig()),
])
const permissions = resolveWorkerPermissions(worker)
const levelProgress = await resolveLevelProgress(worker, acceptedOrderCount)
return {
worker: mapWorkerUser(worker),
permissions: resolveWorkerPermissions(worker),
permissions,
summary: {
acceptedOrderCount,
pendingWithdrawAmount: financeSummary.pendingWithdrawAmount,
approvedWithdrawAmount: financeSummary.approvedWithdrawAmount,
pendingRechargeAmount: financeSummary.pendingRechargeAmount,
},
levelProgress,
financeConfig,
}
}
async function resolveLevelProgress(
worker: WorkerUserRow,
acceptedOrderCount: number,
): Promise<{
currentThreshold: number
nextThreshold: number | null
progressPercent: number
} | null> {
if (!worker.level_id) return null
const levels = await listWorkerLevels()
const active = levels.filter((level) => level.status === 'active')
const current = active.find((level) => Number(level.id) === Number(worker.level_id))
if (!current) return null
const currentThreshold = normalizeInteger(safeParseJson(current.permission_json).upgradeThreshold, 0)
const next = active
.filter((level) => normalizeInteger(safeParseJson(level.permission_json).upgradeThreshold, 0) > currentThreshold)
.sort(
(a, b) =>
normalizeInteger(safeParseJson(a.permission_json).upgradeThreshold, 0) -
normalizeInteger(safeParseJson(b.permission_json).upgradeThreshold, 0),
)[0]
const nextThreshold = next
? normalizeInteger(safeParseJson(next.permission_json).upgradeThreshold, 0)
: null
return {
currentThreshold,
nextThreshold,
progressPercent:
nextThreshold && nextThreshold > currentThreshold
? Math.min(
100,
Math.round(
((acceptedOrderCount - currentThreshold) / (nextThreshold - currentThreshold)) * 100,
),
)
: 100,
}
}
export async function listWorkerProfileWalletLedgers(
query: JsonObject = {},
session: WorkerSession,
@@ -798,13 +883,14 @@ export async function saveAdminWorkCategory(payload: JsonObject = {}) {
export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
const now = nowIso()
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
const name = String(payload.name || '').trim() || '普通打手'
const name = String(payload.name || '').trim() || DEFAULT_LEVEL_NAME
const permissions = {
depositFreeAmount: normalizeAmountFen(
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
0,
),
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 3),
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0),
}
const level = await upsertWorkerLevel({
levelKey,
@@ -1564,7 +1650,8 @@ function resolveWorkerPermissions(worker: WorkerUserRow) {
const permission = safeParseJson(worker.level_permission_json)
return {
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 3),
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
}
}
@@ -1588,7 +1675,8 @@ function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
sortOrder: Number(level.sort_order || 100),
status: level.status,
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 3),
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
createdAt: level.created_at,
updatedAt: level.updated_at,
}
@@ -1645,6 +1733,14 @@ function mapWorkerUser(worker: WorkerUserRow) {
phone: worker.phone,
status: worker.status,
reviewNote: worker.review_note || '',
inviteCode: worker.invite_code || '',
inviter: worker.inviter_id
? {
workerId: Number(worker.inviter_id),
username: worker.inviter_username || '',
displayName: worker.inviter_display_name || worker.inviter_username || '',
}
: null,
level: worker.level_id
? {
levelId: Number(worker.level_id),