接单平台新增 VIP 等级体系与邀请绑定
- 等级按累计验收通过工单数自动升级(阈值后台可配),VIP1-5 默认配置 10/30/50/100 - 存量打手统一归 VIP1,原 regular 等级停用 - 注册支持邀请码绑定邀请人,每人生成唯一邀请码 - 新增 worker_level_logs 升级流水表 - 个人中心展示邀请码与升级进度,管理端等级表单支持升级阈值配置
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
-- 007_worker_vip_levels_and_invite.sql —— VIP 等级体系与邀请绑定。
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 等级升级按累计验收通过(accepted)工单数,阈值存 permission_json.upgradeThreshold,后台可配;
|
||||
-- 2. 每个打手拥有唯一邀请码,注册时可填邀请码绑定邀请人(inviter_id);
|
||||
-- 3. 存量打手统一归 VIP1,原 regular 等级停用;
|
||||
-- 4. 佣金结算暂缓(规则未定),本迁移只落邀请关系,后续按规则补佣金。
|
||||
|
||||
-- 1) worker_users 增加邀请字段
|
||||
ALTER TABLE worker_users ADD COLUMN IF NOT EXISTS inviter_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE worker_users ADD COLUMN IF NOT EXISTS invite_code TEXT;
|
||||
|
||||
-- 2) 存量用户填充唯一邀请码(8 位大写字母数字)
|
||||
UPDATE worker_users
|
||||
SET invite_code = UPPER(SUBSTRING(MD5(random()::text || id::text) FROM 1 FOR 8))
|
||||
WHERE invite_code IS NULL;
|
||||
|
||||
ALTER TABLE worker_users ALTER COLUMN invite_code SET NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_users_invite_code ON worker_users(invite_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_users_inviter ON worker_users(inviter_id);
|
||||
|
||||
-- 3) seed VIP1-5 等级(level_key 唯一,重复执行不覆盖)
|
||||
INSERT INTO worker_levels (level_key, name, sort_order, status, permission_json, created_at, updated_at) VALUES
|
||||
('vip1', 'VIP1', 10, 'active', '{"depositFreeAmount":0,"maxActiveOrders":1,"upgradeThreshold":0}', NOW(), NOW()),
|
||||
('vip2', 'VIP2', 20, 'active', '{"depositFreeAmount":0,"maxActiveOrders":3,"upgradeThreshold":10}', NOW(), NOW()),
|
||||
('vip3', 'VIP3', 30, 'active', '{"depositFreeAmount":0,"maxActiveOrders":10,"upgradeThreshold":30}', NOW(), NOW()),
|
||||
('vip4', 'VIP4', 40, 'active', '{"depositFreeAmount":0,"maxActiveOrders":50,"upgradeThreshold":50}', NOW(), NOW()),
|
||||
('vip5', 'VIP5', 50, 'active', '{"depositFreeAmount":0,"maxActiveOrders":100,"upgradeThreshold":100}', NOW(), NOW())
|
||||
ON CONFLICT (level_key) DO NOTHING;
|
||||
|
||||
-- 4) 存量打手统一归 VIP1,原 regular 等级停用
|
||||
UPDATE worker_levels SET status = 'disabled' WHERE level_key = 'regular';
|
||||
UPDATE worker_users
|
||||
SET level_id = (SELECT id FROM worker_levels WHERE level_key = 'vip1')
|
||||
WHERE level_id IS NULL
|
||||
OR level_id = (SELECT id FROM worker_levels WHERE level_key = 'regular');
|
||||
|
||||
-- 5) 打手等级升级记录
|
||||
CREATE TABLE IF NOT EXISTS worker_level_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
from_level_id BIGINT REFERENCES worker_levels(id) ON DELETE SET NULL,
|
||||
to_level_id BIGINT NOT NULL REFERENCES worker_levels(id) ON DELETE SET NULL,
|
||||
reason TEXT NOT NULL DEFAULT 'auto',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_level_logs_worker_created
|
||||
ON worker_level_logs(worker_id, created_at DESC);
|
||||
|
||||
COMMENT ON COLUMN worker_users.inviter_id IS '邀请人(注册时通过邀请码绑定)';
|
||||
COMMENT ON COLUMN worker_users.invite_code IS '本人专属邀请码,注册/邀请他人时使用';
|
||||
COMMENT ON TABLE worker_level_logs IS '打手等级升级流水';
|
||||
COMMENT ON COLUMN worker_level_logs.reason IS '升级原因:auto 自动升级 / admin 后台调整';
|
||||
@@ -26,6 +26,10 @@ export type WorkerUserRow = {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
reviewed_at: string | null
|
||||
inviter_id: number | null
|
||||
invite_code: string
|
||||
inviter_username?: string
|
||||
inviter_display_name?: string
|
||||
level_key?: string
|
||||
level_name?: string
|
||||
level_permission_json?: string | Record<string, unknown>
|
||||
@@ -192,6 +196,8 @@ type CreateWorkerInput = {
|
||||
phone: string
|
||||
levelId: number | null
|
||||
status: string
|
||||
inviterId?: number | null
|
||||
inviteCode: string
|
||||
now: string
|
||||
}
|
||||
|
||||
@@ -218,12 +224,15 @@ const WORKER_USER_SELECT = `
|
||||
wl.level_key,
|
||||
wl.name AS level_name,
|
||||
wl.permission_json AS level_permission_json,
|
||||
inv.username AS inviter_username,
|
||||
inv.display_name AS inviter_display_name,
|
||||
ww.available_amount,
|
||||
ww.frozen_deposit_amount,
|
||||
ww.total_credited_amount,
|
||||
ww.total_settled_amount
|
||||
FROM worker_users wu
|
||||
LEFT JOIN worker_levels wl ON wl.id = wu.level_id
|
||||
LEFT JOIN worker_users inv ON inv.id = wu.inviter_id
|
||||
LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id
|
||||
`
|
||||
|
||||
@@ -325,6 +334,16 @@ export async function getWorkerUserByUsername(username: string): Promise<WorkerU
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerUserByInviteCode(
|
||||
inviteCode: string,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(
|
||||
`${WORKER_USER_SELECT} WHERE wu.invite_code = $1 LIMIT 1`,
|
||||
[String(inviteCode || '').trim().toUpperCase()],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerUserById(workerId: number | string): Promise<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [
|
||||
Number(workerId),
|
||||
@@ -338,8 +357,9 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise<Worker
|
||||
`
|
||||
INSERT INTO worker_users (
|
||||
username, password_hash, display_name, phone, level_id, status,
|
||||
inviter_id, invite_code,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
@@ -349,6 +369,8 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise<Worker
|
||||
input.phone,
|
||||
input.levelId,
|
||||
input.status,
|
||||
input.inviterId || null,
|
||||
input.inviteCode,
|
||||
input.now,
|
||||
input.now,
|
||||
],
|
||||
@@ -824,6 +846,64 @@ export async function countWorkerAcceptedOrders(workerId: number | string): Prom
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
export async function maybeUpgradeWorkerLevelWithClient(
|
||||
client: PoolClient,
|
||||
workerId: number,
|
||||
now: string,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
const acceptedResult = await client.query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM work_orders
|
||||
WHERE assigned_worker_id = $1
|
||||
AND status = 'accepted'
|
||||
`,
|
||||
[workerId],
|
||||
)
|
||||
const acceptedCount = Number(acceptedResult.rows[0]?.total || 0)
|
||||
|
||||
const nextLevelResult = await client.query<{ id: number }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM worker_levels
|
||||
WHERE status = 'active'
|
||||
AND COALESCE((permission_json->>'upgradeThreshold')::int, 0) <= $1
|
||||
ORDER BY COALESCE((permission_json->>'upgradeThreshold')::int, 0) DESC, sort_order ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[acceptedCount],
|
||||
)
|
||||
const nextLevelId = Number(nextLevelResult.rows[0]?.id || 0)
|
||||
if (!nextLevelId) return null
|
||||
|
||||
const current = await client.query<{ id: number; level_id: number | null }>(
|
||||
'SELECT id, level_id FROM worker_users WHERE id = $1 LIMIT 1',
|
||||
[workerId],
|
||||
)
|
||||
const currentRow = current.rows[0]
|
||||
if (!currentRow || Number(currentRow.level_id || 0) === nextLevelId) return null
|
||||
|
||||
await client.query(
|
||||
'UPDATE worker_users SET level_id = $1, updated_at = $2 WHERE id = $3',
|
||||
[nextLevelId, now, workerId],
|
||||
)
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_level_logs (
|
||||
worker_id, from_level_id, to_level_id, reason, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, 'auto', $4::jsonb, $5)
|
||||
`,
|
||||
[
|
||||
workerId,
|
||||
currentRow.level_id || null,
|
||||
nextLevelId,
|
||||
JSON.stringify({ acceptedCount }),
|
||||
now,
|
||||
],
|
||||
)
|
||||
return getWorkerUserByIdWithClient(client, workerId)
|
||||
}
|
||||
|
||||
export async function getWorkCategoryByKey(categoryKey: string): Promise<WorkCategoryRow | null> {
|
||||
const result = await query<WorkCategoryRow>(
|
||||
'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1',
|
||||
@@ -1304,6 +1384,10 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
if (workerId > 0) {
|
||||
await maybeUpgradeWorkerLevelWithClient(client, workerId, input.now)
|
||||
}
|
||||
|
||||
return getWorkOrderByIdWithClient(client, input.workOrderId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user