From 51d72cc283fef1e02ada25328595f335e34f24a0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 1 Aug 2026 14:50:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8D=95=E5=B9=B3=E5=8F=B0=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20VIP=20=E7=AD=89=E7=BA=A7=E4=BD=93=E7=B3=BB=E4=B8=8E?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 等级按累计验收通过工单数自动升级(阈值后台可配),VIP1-5 默认配置 10/30/50/100 - 存量打手统一归 VIP1,原 regular 等级停用 - 注册支持邀请码绑定邀请人,每人生成唯一邀请码 - 新增 worker_level_logs 升级流水表 - 个人中心展示邀请码与升级进度,管理端等级表单支持升级阈值配置 --- .../007_worker_vip_levels_and_invite.sql | 55 ++++++++ .../src/repositories/worker-platform-repo.ts | 86 +++++++++++- .../worker-platform-service.ts | 130 +++++++++++++++--- .../pages/admin/AdminWorkerPlatformPage.tsx | 16 ++- .../src/pages/worker/WorkerLoginPage.tsx | 4 + .../src/pages/worker/WorkerProfilePage.tsx | 66 +++++++++ .../src/services/admin/worker-platform.ts | 1 + apps/frontend/src/services/worker.ts | 1 + apps/frontend/src/types/worker-platform.ts | 15 ++ 9 files changed, 353 insertions(+), 21 deletions(-) create mode 100644 apps/backend/src/db/migrations/007_worker_vip_levels_and_invite.sql diff --git a/apps/backend/src/db/migrations/007_worker_vip_levels_and_invite.sql b/apps/backend/src/db/migrations/007_worker_vip_levels_and_invite.sql new file mode 100644 index 00000000..9cf27390 --- /dev/null +++ b/apps/backend/src/db/migrations/007_worker_vip_levels_and_invite.sql @@ -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 后台调整'; diff --git a/apps/backend/src/repositories/worker-platform-repo.ts b/apps/backend/src/repositories/worker-platform-repo.ts index 968d2136..bfb8d28f 100644 --- a/apps/backend/src/repositories/worker-platform-repo.ts +++ b/apps/backend/src/repositories/worker-platform-repo.ts @@ -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 @@ -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 { + const result = await query( + `${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 { const result = await query(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [ Number(workerId), @@ -338,8 +357,9 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise { + 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 { const result = await query( '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) }) } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.ts b/apps/backend/src/services/worker-platform/worker-platform-service.ts index 37b15cf6..51d73218 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.ts @@ -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 { + 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), diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 8715f35b..5f6ea560 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -1790,6 +1790,7 @@ function LevelsPanel() { name: string depositFreeAmount?: number maxActiveOrders?: number + upgradeThreshold?: number }) { try { await saveAdminWorkerLevel(values) @@ -1810,6 +1811,7 @@ function LevelsPanel() { render: (_, row) => formatMoney(row.depositFreeAmount), }, { title: '最大同时接单', dataIndex: 'maxActiveOrders' }, + { title: '升级所需累计完成', dataIndex: 'upgradeThreshold' }, { title: '状态', dataIndex: 'status' }, ] @@ -1820,10 +1822,11 @@ function LevelsPanel() { form={form} layout="inline" initialValues={{ - levelKey: 'regular', - name: '普通打手', + levelKey: 'vip1', + name: 'VIP1', depositFreeAmount: 0, - maxActiveOrders: 3, + maxActiveOrders: 1, + upgradeThreshold: 0, }} onFinish={saveLevel} > @@ -1847,6 +1850,13 @@ function LevelsPanel() { + + + + + {levelProgress && levelProgress.nextThreshold !== null ? ( + + + 已累计完成 {summary?.acceptedOrderCount || 0} 单, + 距下一等级还需 {Math.max(0, levelProgress.nextThreshold - (summary?.acceptedOrderCount || 0))} 单 + + `${percent}%`} + /> + + ) : ( + + 已累计完成 {summary?.acceptedOrderCount || 0} 单,当前已是最高等级 + + )} + @@ -542,6 +589,25 @@ export default function WorkerProfilePage() { {worker.level?.name || '-'} + + + {worker.inviteCode || '-'} + {worker.inviteCode ? ( +