diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 5d5ae4c1..3ce933a9 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -3,11 +3,13 @@ import express from "express"; import process from "node:process"; import adminRouter from "./routes/admin.js"; +import collectRouter from "./routes/collect.js"; import claimsRouter from "./routes/claims.js"; import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js"; import kuaishouIndustryRouter from "./routes/kuaishou-industry.js"; import open91Router from "./routes/open-91.js"; import shortLinksRouter from "./routes/short-links.js"; +import workerRouter from "./routes/worker.js"; import { accessLogMiddleware } from "./middleware/access-log.js"; import { createCorsMiddleware } from "./middleware/cors.js"; import { buildHealthPayload, type StartupState } from "./startup/state.js"; @@ -103,6 +105,8 @@ export function createApp({ app.use("/api/v1/open/kuaishou-industry", kuaishouIndustryRouter); app.use("/api/v1/open/kuaishou-feifei", kuaishouFeifeiRouter); app.use("/api/v1/claim", claimsRouter); + app.use("/api/v1/collect", collectRouter); + app.use("/api/v1/worker", workerRouter); app.use("/api/v1/admin", adminRouter); app.use("/s", shortLinksRouter); diff --git a/apps/backend/src/db/migrations/004_worker_order_platform.sql b/apps/backend/src/db/migrations/004_worker_order_platform.sql new file mode 100644 index 00000000..36d42bde --- /dev/null +++ b/apps/backend/src/db/migrations/004_worker_order_platform.sql @@ -0,0 +1,131 @@ +-- 004_worker_order_platform.sql —— 接单平台一期结构。 +-- +-- 说明:接单平台状态独立于 fulfillment_tasks.task_status,避免影响现有自动履约状态机。 + +CREATE TABLE IF NOT EXISTS worker_levels ( + id BIGSERIAL PRIMARY KEY, + level_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + status TEXT NOT NULL DEFAULT 'active', + permission_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS worker_users ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + level_id BIGINT REFERENCES worker_levels(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending_review', + review_note TEXT NOT NULL DEFAULT '', + session_version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + reviewed_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS worker_wallets ( + worker_id BIGINT PRIMARY KEY REFERENCES worker_users(id) ON DELETE CASCADE, + available_amount INTEGER NOT NULL DEFAULT 0, + frozen_deposit_amount INTEGER NOT NULL DEFAULT 0, + total_credited_amount INTEGER NOT NULL DEFAULT 0, + total_settled_amount INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS worker_wallet_ledgers ( + id BIGSERIAL PRIMARY KEY, + worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE, + ledger_type TEXT NOT NULL, + amount INTEGER NOT NULL, + balance_after INTEGER NOT NULL, + frozen_after INTEGER NOT NULL, + related_work_order_id BIGINT, + audit_status TEXT NOT NULL DEFAULT 'approved', + note TEXT NOT NULL DEFAULT '', + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS work_categories ( + id BIGSERIAL PRIMARY KEY, + category_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS work_orders ( + id BIGSERIAL PRIMARY KEY, + work_order_no TEXT NOT NULL UNIQUE, + order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, + order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, + task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, + platform_order_id TEXT NOT NULL DEFAULT '', + product_name TEXT NOT NULL, + category_id BIGINT REFERENCES work_categories(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending_material', + reward_amount INTEGER NOT NULL DEFAULT 0, + required_deposit_amount INTEGER NOT NULL DEFAULT 0, + deposit_threshold_amount INTEGER NOT NULL DEFAULT 20000, + assigned_worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL, + material_json JSONB NOT NULL DEFAULT '{}'::jsonb, + requirement_json JSONB NOT NULL DEFAULT '{}'::jsonb, + acceptance_json JSONB NOT NULL DEFAULT '{}'::jsonb, + problem_note TEXT NOT NULL DEFAULT '', + published_at TIMESTAMPTZ, + assigned_at TIMESTAMPTZ, + submitted_at TIMESTAMPTZ, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS work_order_events ( + id BIGSERIAL PRIMARY KEY, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + actor_type TEXT NOT NULL DEFAULT '', + actor_id TEXT NOT NULL DEFAULT '', + event_type TEXT NOT NULL, + from_status TEXT NOT NULL DEFAULT '', + to_status TEXT NOT NULL DEFAULT '', + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_worker_users_status + ON worker_users(status, id DESC); +CREATE INDEX IF NOT EXISTS idx_worker_users_level + ON worker_users(level_id); +CREATE INDEX IF NOT EXISTS idx_worker_wallet_ledgers_worker_created + ON worker_wallet_ledgers(worker_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_work_orders_status + ON work_orders(status, id DESC); +CREATE INDEX IF NOT EXISTS idx_work_orders_worker_status + ON work_orders(assigned_worker_id, status, id DESC); +CREATE INDEX IF NOT EXISTS idx_work_orders_platform_order + ON work_orders(platform_order_id); +CREATE INDEX IF NOT EXISTS idx_work_order_events_order_created + ON work_order_events(work_order_id, created_at DESC); + +ALTER TABLE worker_wallet_ledgers + ADD CONSTRAINT fk_worker_wallet_ledgers_work_order + FOREIGN KEY (related_work_order_id) REFERENCES work_orders(id) ON DELETE SET NULL; + +COMMENT ON TABLE worker_levels IS '接单员等级与权限配置'; +COMMENT ON COLUMN worker_levels.permission_json IS '等级权限:免押额度、最大同时接单量等'; +COMMENT ON TABLE worker_users IS '接单员账号,注册后需后台审核'; +COMMENT ON TABLE worker_wallets IS '接单员余额与冻结押金汇总'; +COMMENT ON TABLE worker_wallet_ledgers IS '接单员钱包流水,人工充值、冻结、解冻、结算均记录'; +COMMENT ON TABLE work_categories IS '接单大厅分类'; +COMMENT ON TABLE work_orders IS '接单平台工单,独立于现有履约任务状态机'; +COMMENT ON COLUMN work_orders.reward_amount IS '接单员看到并结算的金额,单位分'; +COMMENT ON COLUMN work_orders.required_deposit_amount IS '单笔工单所需冻结押金,单位分'; +COMMENT ON TABLE work_order_events IS '接单工单状态与操作流水'; diff --git a/apps/backend/src/domain/work-order-status.ts b/apps/backend/src/domain/work-order-status.ts new file mode 100644 index 00000000..28e162aa --- /dev/null +++ b/apps/backend/src/domain/work-order-status.ts @@ -0,0 +1,25 @@ +export const WORK_ORDER_STATUS = { + PENDING_MATERIAL: 'pending_material', + UNASSIGNED: 'unassigned', + OPEN: 'open', + IN_PROGRESS: 'in_progress', + PENDING_ACCEPTANCE: 'pending_acceptance', + PROBLEM: 'problem', + ACCEPTED: 'accepted', + CANCELLED: 'cancelled', +} as const + +export type WorkOrderStatus = (typeof WORK_ORDER_STATUS)[keyof typeof WORK_ORDER_STATUS] | (string & {}) + +const FINAL_STATUSES = new Set([ + WORK_ORDER_STATUS.ACCEPTED, + WORK_ORDER_STATUS.CANCELLED, +]) + +export function normalizeWorkOrderStatus(value: unknown): WorkOrderStatus { + return String(value || '').trim() as WorkOrderStatus +} + +export function isWorkOrderFinalStatus(value: unknown): boolean { + return FINAL_STATUSES.has(normalizeWorkOrderStatus(value)) +} diff --git a/apps/backend/src/repositories/worker-platform-repo.ts b/apps/backend/src/repositories/worker-platform-repo.ts new file mode 100644 index 00000000..6750ee24 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform-repo.ts @@ -0,0 +1,838 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../db/client.js' + +export type WorkerLevelRow = { + id: number + level_key: string + name: string + sort_order: number + status: string + permission_json: string | Record + created_at: string + updated_at: string +} + +export type WorkerUserRow = { + id: number + username: string + password_hash: string + display_name: string + phone: string + level_id: number | null + status: string + review_note: string + session_version: number + created_at: string + updated_at: string + reviewed_at: string | null + level_key?: string + level_name?: string + level_permission_json?: string | Record + available_amount?: number + frozen_deposit_amount?: number +} + +export type WorkerWalletRow = { + worker_id: number + available_amount: number + frozen_deposit_amount: number + total_credited_amount: number + total_settled_amount: number + created_at: string + updated_at: string +} + +export type WorkCategoryRow = { + id: number + category_key: string + name: string + sort_order: number + status: string + created_at: string + updated_at: string +} + +export type WorkOrderRow = { + id: number + work_order_no: string + order_id: number | null + order_item_id: number | null + task_id: number | null + platform_order_id: string + product_name: string + category_id: number | null + status: string + reward_amount: number + required_deposit_amount: number + deposit_threshold_amount: number + assigned_worker_id: number | null + material_json: string | Record + requirement_json: string | Record + acceptance_json: string | Record + problem_note: string + published_at: string | null + assigned_at: string | null + submitted_at: string | null + accepted_at: string | null + created_at: string + updated_at: string + category_name?: string + worker_username?: string + worker_display_name?: string +} + +type ListInput = { + page?: number + pageSize?: number + status?: string + keyword?: string + workerId?: number +} + +type CreateWorkerInput = { + username: string + passwordHash: string + displayName: string + phone: string + levelId: number | null + status: string + now: string +} + +type CreateWorkOrderInput = { + workOrderNo: string + orderId?: number | null + orderItemId?: number | null + taskId?: number | null + platformOrderId: string + productName: string + categoryId?: number | null + status: string + rewardAmount: number + requiredDepositAmount: number + depositThresholdAmount: number + materialJson: string + requirementJson: string + now: string +} + +const WORKER_USER_SELECT = ` + SELECT + wu.*, + wl.level_key, + wl.name AS level_name, + wl.permission_json AS level_permission_json, + ww.available_amount, + ww.frozen_deposit_amount + FROM worker_users wu + LEFT JOIN worker_levels wl ON wl.id = wu.level_id + LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id +` + +const WORK_ORDER_SELECT = ` + SELECT + wo.*, + wc.name AS category_name, + wu.username AS worker_username, + wu.display_name AS worker_display_name + FROM work_orders wo + LEFT JOIN work_categories wc ON wc.id = wo.category_id + LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id +` + +export async function getWorkerLevelByKey(levelKey: string): Promise { + const result = await query( + 'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1', + [String(levelKey || '').trim()], + ) + return result.rows[0] || null +} + +export async function getWorkerLevelById(levelId: number | string): Promise { + const result = await query('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [Number(levelId)]) + return result.rows[0] || null +} + +export async function listWorkerLevels(): Promise { + const result = await query( + 'SELECT * FROM worker_levels ORDER BY sort_order ASC, id ASC', + ) + return result.rows +} + +export async function upsertWorkerLevel(input: { + levelKey: string + name: string + sortOrder: number + status: string + permissionJson: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO worker_levels ( + level_key, name, sort_order, status, permission_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) + ON CONFLICT (level_key) DO UPDATE + SET + name = EXCLUDED.name, + sort_order = EXCLUDED.sort_order, + status = EXCLUDED.status, + permission_json = EXCLUDED.permission_json, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + input.levelKey, + input.name, + input.sortOrder, + input.status, + input.permissionJson, + input.now, + input.now, + ], + ) + return result.rows[0] || null +} + +export async function getWorkerUserByUsername(username: string): Promise { + const result = await query( + `${WORKER_USER_SELECT} WHERE wu.username = $1 LIMIT 1`, + [String(username || '').trim().toLowerCase()], + ) + 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)], + ) + return result.rows[0] || null +} + +export async function createWorkerUser(input: CreateWorkerInput): Promise { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>( + ` + INSERT INTO worker_users ( + username, password_hash, display_name, phone, level_id, status, + created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id + `, + [ + input.username, + input.passwordHash, + input.displayName, + input.phone, + input.levelId, + input.status, + input.now, + input.now, + ], + ) + const workerId = Number(result.rows[0]?.id || 0) + await ensureWorkerWalletWithClient(client, workerId, input.now) + return getWorkerUserByIdWithClient(client, workerId) + }) +} + +export async function updateWorkerUser( + workerId: number | string, + patch: Partial>, +): Promise { + const current = await getWorkerUserById(workerId) + if (!current) return null + const next = { ...current, ...patch } + + await query( + ` + UPDATE worker_users + SET + status = $1, + review_note = $2, + level_id = $3, + session_version = $4, + reviewed_at = $5, + updated_at = $6 + WHERE id = $7 + `, + [ + next.status, + next.review_note || '', + next.level_id || null, + Number(next.session_version || 1), + next.reviewed_at || null, + next.updated_at, + Number(workerId), + ], + ) + return getWorkerUserById(workerId) +} + +export async function listWorkerUsers({ + page = 1, + pageSize = 20, + status = '', + keyword = '', +}: ListInput = {}): Promise<{ items: WorkerUserRow[]; total: number }> { + const { whereClause, params } = buildWorkerUserWhere({ status, keyword }) + const totalResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM worker_users wu ${whereClause}`, + params, + ) + + const offset = (page - 1) * pageSize + params.push(pageSize, offset) + const itemsResult = await query( + `${WORKER_USER_SELECT} + ${whereClause} + ORDER BY wu.id DESC + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params, + ) + return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } +} + +export async function ensureWorkerWallet(workerId: number | string, now: string): Promise { + return withTransaction(async (client) => { + await ensureWorkerWalletWithClient(client, Number(workerId), now) + return getWorkerWalletWithClient(client, Number(workerId)) + }) +} + +export async function getWorkerWallet(workerId: number | string): Promise { + const result = await query( + 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1', + [Number(workerId)], + ) + return result.rows[0] || null +} + +export async function addWorkerWalletCredit(input: { + workerId: number + amount: number + note: string + payloadJson: string + now: string +}): Promise { + return withTransaction(async (client) => { + await ensureWorkerWalletWithClient(client, input.workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, input.workerId) + const nextAvailable = Number(wallet?.available_amount || 0) + input.amount + const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + await client.query( + ` + UPDATE worker_wallets + SET + available_amount = $1, + total_credited_amount = total_credited_amount + $2, + updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, input.amount, input.now, input.workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + audit_status, note, payload_json, created_at + ) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7) + `, + [input.workerId, input.amount, nextAvailable, nextFrozen, input.note, input.payloadJson, input.now], + ) + return getWorkerWalletWithClient(client, input.workerId) + }) +} + +export async function getWorkCategoryByKey(categoryKey: string): Promise { + const result = await query( + 'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1', + [String(categoryKey || '').trim()], + ) + return result.rows[0] || null +} + +export async function listWorkCategories(): Promise { + const result = await query( + `SELECT * FROM work_categories WHERE status = 'active' ORDER BY sort_order ASC, id ASC`, + ) + return result.rows +} + +export async function upsertWorkCategory(input: { + categoryKey: string + name: string + sortOrder: number + status: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (category_key) DO UPDATE + SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at + RETURNING * + `, + [input.categoryKey, input.name, input.sortOrder, input.status, input.now, input.now], + ) + return result.rows[0] || null +} + +export async function createWorkOrder(input: CreateWorkOrderInput): Promise { + const result = await query<{ id: number }>( + ` + INSERT INTO work_orders ( + work_order_no, order_id, order_item_id, task_id, platform_order_id, + product_name, category_id, status, reward_amount, required_deposit_amount, + deposit_threshold_amount, material_json, requirement_json, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12::jsonb, $13::jsonb, $14, $15 + ) + RETURNING id + `, + [ + input.workOrderNo, + input.orderId || null, + input.orderItemId || null, + input.taskId || null, + input.platformOrderId, + input.productName, + input.categoryId || null, + input.status, + input.rewardAmount, + input.requiredDepositAmount, + input.depositThresholdAmount, + input.materialJson, + input.requirementJson, + input.now, + input.now, + ], + ) + return getWorkOrderById(result.rows[0]?.id || 0) +} + +export async function getWorkOrderById(workOrderId: number | string): Promise { + const result = await query( + `${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, + [Number(workOrderId)], + ) + return result.rows[0] || null +} + +export async function findPendingMaterialWorkOrderByPlatformOrderId( + platformOrderId: string, +): Promise { + const result = await query( + `${WORK_ORDER_SELECT} + WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' + ORDER BY wo.id DESC + LIMIT 1`, + [String(platformOrderId || '').trim()], + ) + return result.rows[0] || null +} + +export async function listWorkOrders({ + page = 1, + pageSize = 20, + status = '', + keyword = '', + workerId = 0, +}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { + const { whereClause, params } = buildWorkOrderWhere({ status, keyword, workerId }) + const totalResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_orders wo + ${whereClause} + `, + params, + ) + const offset = (page - 1) * pageSize + params.push(pageSize, offset) + const itemsResult = await query( + `${WORK_ORDER_SELECT} + ${whereClause} + ORDER BY wo.id DESC + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params, + ) + return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } +} + +export async function updateWorkOrder( + workOrderId: number | string, + patch: Partial>, +): Promise { + const current = await getWorkOrderById(workOrderId) + if (!current) return null + const next = { ...current, ...patch } + await query( + ` + UPDATE work_orders + SET + status = $1, + assigned_worker_id = $2, + material_json = $3::jsonb, + acceptance_json = $4::jsonb, + problem_note = $5, + published_at = $6, + assigned_at = $7, + submitted_at = $8, + accepted_at = $9, + updated_at = $10 + WHERE id = $11 + `, + [ + next.status, + next.assigned_worker_id || null, + toJsonString(next.material_json), + toJsonString(next.acceptance_json), + next.problem_note || '', + next.published_at || null, + next.assigned_at || null, + next.submitted_at || null, + next.accepted_at || null, + next.updated_at, + Number(workOrderId), + ], + ) + return getWorkOrderById(workOrderId) +} + +export async function grabWorkOrder(input: { + workOrderId: number + workerId: number + depositAmount: number + now: string +}): Promise { + return withTransaction(async (client) => { + await ensureWorkerWalletWithClient(client, input.workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, input.workerId) + const available = Number(wallet?.available_amount || 0) + if (available < input.depositAmount) { + return null + } + + const result = await client.query<{ id: number }>( + ` + UPDATE work_orders + SET + status = 'in_progress', + assigned_worker_id = $1, + assigned_at = $2, + updated_at = $2 + WHERE id = $3 + AND status = 'open' + AND assigned_worker_id IS NULL + RETURNING id + `, + [input.workerId, input.now, input.workOrderId], + ) + if (!result.rows[0]) { + return null + } + + if (input.depositAmount > 0) { + const nextAvailable = available - input.depositAmount + const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + input.depositAmount + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, nextFrozen, input.now, input.workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_freeze', $2, $3, $4, $5, '抢单冻结押金', '{}'::jsonb, $6) + `, + [input.workerId, -input.depositAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + ) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'grabbed', + fromStatus: 'open', + toStatus: 'in_progress', + payloadJson: '{}', + now: input.now, + }) + + return getWorkOrderByIdWithClient(client, input.workOrderId) + }) +} + +export async function acceptWorkOrderAndSettle(input: { + workOrderId: number + now: string +}): Promise { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM work_orders + WHERE id = $1 + AND status = 'pending_acceptance' + FOR UPDATE + `, + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder) { + return null + } + + await client.query( + ` + UPDATE work_orders + SET status = 'accepted', accepted_at = $1, updated_at = $1 + WHERE id = $2 + `, + [input.now, input.workOrderId], + ) + + const workerId = Number(workOrder.assigned_worker_id || 0) + if (workerId > 0) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const frozenLedgerResult = await client.query<{ amount: number }>( + ` + SELECT COALESCE(ABS(SUM(amount)), 0)::int AS amount + FROM worker_wallet_ledgers + WHERE worker_id = $1 + AND related_work_order_id = $2 + AND ledger_type = 'deposit_freeze' + `, + [workerId, input.workOrderId], + ) + const releaseAmount = Math.min( + Number(frozenLedgerResult.rows[0]?.amount || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + const rewardAmount = Number(workOrder.reward_amount || 0) + const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + rewardAmount + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + + await client.query( + ` + UPDATE worker_wallets + SET + available_amount = $1, + frozen_deposit_amount = $2, + total_settled_amount = total_settled_amount + $3, + updated_at = $4 + WHERE worker_id = $5 + `, + [nextAvailable, nextFrozen, rewardAmount, input.now, workerId], + ) + + if (releaseAmount > 0) { + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '验收通过释放押金', '{}'::jsonb, $6) + `, + [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + ) + } + + if (rewardAmount > 0) { + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'reward_settlement', $2, $3, $4, $5, '验收通过结算报酬', '{}'::jsonb, $6) + `, + [workerId, rewardAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + ) + } + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: '', + eventType: 'accepted', + fromStatus: 'pending_acceptance', + toStatus: 'accepted', + payloadJson: '{}', + now: input.now, + }) + + return getWorkOrderByIdWithClient(client, input.workOrderId) + }) +} + +export async function countWorkerActiveOrders(workerId: number | string): Promise { + const result = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_orders + WHERE assigned_worker_id = $1 + AND status IN ('in_progress', 'pending_acceptance', 'problem') + `, + [Number(workerId)], + ) + return Number(result.rows[0]?.total || 0) +} + +export async function createWorkOrderEvent(input: { + workOrderId: number + actorType: string + actorId: string + eventType: string + fromStatus?: string + toStatus?: string + payloadJson?: string + now: string +}): Promise { + await query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) + `, + [ + input.workOrderId, + input.actorType, + input.actorId, + input.eventType, + input.fromStatus || '', + input.toStatus || '', + input.payloadJson || '{}', + input.now, + ], + ) +} + +async function createWorkOrderEventWithClient( + client: PoolClient, + input: Parameters[0], +) { + await client.query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) + `, + [ + input.workOrderId, + input.actorType, + input.actorId, + input.eventType, + input.fromStatus || '', + input.toStatus || '', + input.payloadJson || '{}', + input.now, + ], + ) +} + +async function ensureWorkerWalletWithClient(client: PoolClient, workerId: number, now: string): Promise { + if (!workerId) return + await client.query( + ` + INSERT INTO worker_wallets ( + worker_id, available_amount, frozen_deposit_amount, + total_credited_amount, total_settled_amount, created_at, updated_at + ) VALUES ($1, 0, 0, 0, 0, $2, $3) + ON CONFLICT (worker_id) DO NOTHING + `, + [workerId, now, now], + ) +} + +async function getWorkerWalletWithClient(client: PoolClient, workerId: number): Promise { + const result = await client.query( + 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1', + [workerId], + ) + return result.rows[0] || null +} + +async function getWorkerUserByIdWithClient(client: PoolClient, workerId: number | string): Promise { + const result = await client.query( + `${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, + [Number(workerId)], + ) + return result.rows[0] || null +} + +async function getWorkOrderByIdWithClient(client: PoolClient, workOrderId: number | string): Promise { + const result = await client.query( + `${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, + [Number(workOrderId)], + ) + return result.rows[0] || null +} + +function buildWorkerUserWhere({ status = '', keyword = '' }: Pick) { + const filters: string[] = [] + const params: unknown[] = [] + if (status) { + params.push(status) + filters.push(`wu.status = $${params.length}`) + } + if (keyword) { + params.push(`%${keyword}%`) + filters.push(`(wu.username ILIKE $${params.length} OR wu.display_name ILIKE $${params.length} OR wu.phone ILIKE $${params.length})`) + } + return { + whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', + params, + } +} + +function buildWorkOrderWhere({ status = '', keyword = '', workerId = 0 }: ListInput) { + const filters: string[] = [] + const params: unknown[] = [] + if (status) { + params.push(status) + filters.push(`wo.status = $${params.length}`) + } + if (keyword) { + params.push(`%${keyword}%`) + filters.push(`(wo.work_order_no ILIKE $${params.length} OR wo.platform_order_id ILIKE $${params.length} OR wo.product_name ILIKE $${params.length})`) + } + if (workerId) { + params.push(workerId) + filters.push(`wo.assigned_worker_id = $${params.length}`) + } + return { + whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', + params, + } +} + +function toJsonString(value: unknown): string { + if (typeof value === 'string') return value || '{}' + return JSON.stringify(value || {}) +} diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 1e12d1d1..9e46cf85 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -11,6 +11,7 @@ import platformConfigRouter from "./admin/platform-config.js"; import { requireAdminSession } from "./admin/session.js"; import tasksRouter from "./admin/tasks.js"; import usersRouter from "./admin/users.js"; +import workerPlatformRouter from "./admin/worker-platform.js"; import { buildNotFoundPayload } from "../utils/http.js"; const router = Router(); @@ -25,6 +26,7 @@ router.use(kuaishouIndustryRouter); router.use(ordersRouter); router.use(tasksRouter); router.use(cloudtentaclesRecordsRouter); +router.use(workerPlatformRouter); router.use(devMockRouter); router.use((req, res) => { diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts new file mode 100644 index 00000000..66e4b802 --- /dev/null +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -0,0 +1,171 @@ +import { Router } from 'express' + +import { + acceptAdminWorkOrder, + creditAdminWorkerWallet, + createAdminMockWorkOrder, + getAdminWorkerPlatformSummary, + listAdminWorkerLevels, + listAdminWorkerUsers, + listAdminWorkOrders, + markAdminWorkOrderProblem, + publishAdminWorkOrder, + reviewAdminWorkerUser, + saveAdminWorkerLevel, +} from '../../services/worker-platform/worker-platform-service.js' +import { createJsonHandler, requireAdminRoles } from './session.js' + +const router = Router() + +router.get( + '/worker-platform/summary', + createJsonHandler(() => getAdminWorkerPlatformSummary(), { + successMessage: 'ok', + errorMessage: '读取接单平台概览失败', + scope: '[admin/worker-platform/summary]', + }), +) + +router.get( + '/worker-platform/levels', + requireAdminRoles(['admin', 'operator']), + createJsonHandler(() => listAdminWorkerLevels(), { + successMessage: 'ok', + errorMessage: '读取接单等级失败', + scope: '[admin/worker-platform/levels]', + }), +) + +router.post( + '/worker-platform/levels', + requireAdminRoles(['admin']), + createJsonHandler((req) => saveAdminWorkerLevel(req.body || {}), { + successMessage: '接单等级已保存', + errorMessage: '保存接单等级失败', + scope: '[admin/worker-platform/levels]', + audit: (_req, data) => ({ + action: 'worker_level_saved', + targetType: 'worker_level', + targetId: String((data as { level?: { levelKey?: string } })?.level?.levelKey || ''), + data: data && typeof data === 'object' ? data as Record : {}, + }), + }), +) + +router.get( + '/worker-platform/workers', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => listAdminWorkerUsers(req.query), { + successMessage: 'ok', + errorMessage: '读取接单员列表失败', + scope: '[admin/worker-platform/workers]', + }), +) + +router.post( + '/worker-platform/workers/:workerId/review', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}), { + successMessage: '接单员审核状态已更新', + errorMessage: '审核接单员失败', + scope: '[admin/worker-platform/workers/:workerId/review]', + audit: (req, data) => ({ + action: 'worker_reviewed', + targetType: 'worker', + targetId: String(req.params.workerId || ''), + data: data && typeof data === 'object' ? data as Record : {}, + }), + }), +) + +router.post( + '/worker-platform/workers/:workerId/wallet-credit', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => creditAdminWorkerWallet(String(req.params.workerId || ''), req.body || {}), { + successMessage: '接单员余额已增加', + errorMessage: '人工充值失败', + scope: '[admin/worker-platform/workers/:workerId/wallet-credit]', + audit: (req, data) => ({ + action: 'worker_wallet_credited', + targetType: 'worker', + targetId: String(req.params.workerId || ''), + data: data && typeof data === 'object' ? data as Record : {}, + }), + }), +) + +router.get( + '/worker-platform/orders', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => listAdminWorkOrders(req.query), { + successMessage: 'ok', + errorMessage: '读取接单订单失败', + scope: '[admin/worker-platform/orders]', + }), +) + +router.post( + '/worker-platform/orders/mock', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => createAdminMockWorkOrder(req.body || {}), { + successMessage: '接单工单 Mock 已生成', + errorMessage: '生成接单工单 Mock 失败', + scope: '[admin/worker-platform/orders/mock]', + audit: (_req, data) => ({ + action: 'work_order_mock_created', + targetType: 'work_order', + targetId: String((data as { order?: { workOrderId?: number } })?.order?.workOrderId || ''), + data: data && typeof data === 'object' ? data as Record : {}, + }), + }), +) + +router.post( + '/worker-platform/orders/:workOrderId/publish', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => publishAdminWorkOrder(String(req.params.workOrderId || '')), { + successMessage: '订单已发布到抢单大厅', + errorMessage: '发布接单订单失败', + scope: '[admin/worker-platform/orders/:workOrderId/publish]', + audit: (req, data) => ({ + action: 'work_order_published', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? data as Record : {}, + }), + }), +) + +router.post( + '/worker-platform/orders/:workOrderId/problem', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => markAdminWorkOrderProblem(String(req.params.workOrderId || ''), req.body || {}), { + successMessage: '订单已标记为问题单', + errorMessage: '标记问题单失败', + scope: '[admin/worker-platform/orders/:workOrderId/problem]', + audit: (req, data) => ({ + action: 'work_order_marked_problem', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? data as Record : {}, + }), + }), +) + +router.post( + '/worker-platform/orders/:workOrderId/accept', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => acceptAdminWorkOrder(String(req.params.workOrderId || '')), { + successMessage: '订单已验收通过', + errorMessage: '验收接单订单失败', + scope: '[admin/worker-platform/orders/:workOrderId/accept]', + audit: (req, data) => ({ + action: 'work_order_accepted', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? data as Record : {}, + }), + }), +) + +export default router diff --git a/apps/backend/src/routes/collect.ts b/apps/backend/src/routes/collect.ts new file mode 100644 index 00000000..7c1243e5 --- /dev/null +++ b/apps/backend/src/routes/collect.ts @@ -0,0 +1,43 @@ +import { Router } from 'express' + +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js' +import { + collectLookupWorkOrder, + collectSubmitWorkOrder, +} from '../services/worker-platform/worker-platform-service.js' +import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' + +const router = Router() + +const collectRateLimit = createRateLimitMiddleware({ + scope: 'collect', + windowMs: 60_000, + max: 60, + key: getBodyFieldRateLimitKey('orderNo'), +}) + +router.post( + '/lookup', + collectRateLimit, + createRouteHandler((req) => collectLookupWorkOrder(req.body || {}), { + successMessage: 'ok', + errorMessage: '查询待完善订单失败', + scope: '[collect/lookup]', + }), +) + +router.post( + '/submit', + collectRateLimit, + createRouteHandler((req) => collectSubmitWorkOrder(req.body || {}), { + successMessage: '资料已提交', + errorMessage: '提交补充资料失败', + scope: '[collect/submit]', + }), +) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts new file mode 100644 index 00000000..4ec755df --- /dev/null +++ b/apps/backend/src/routes/worker.ts @@ -0,0 +1,130 @@ +import { Router } from 'express' + +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js' +import { + getWorkerProfile, + getWorkerSessionSummary, + grabWorkerHallOrder, + listWorkerHallOrders, + listWorkerMyOrders, + loginWorker, + registerWorker, + submitWorkerOrderAcceptance, +} from '../services/worker-platform/worker-platform-service.js' +import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' +import { + extractWorkerBearerToken, + getRequiredWorkerSession, + requireActiveWorker, + requireWorkerSession, +} from './worker/session.js' + +const router = Router() + +router.post( + '/auth/register', + createRateLimitMiddleware({ + scope: 'worker:register', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), + }), + createRouteHandler((req) => registerWorker(req.body || {}), { + successMessage: '注册成功,等待后台审核', + errorMessage: '接单账号注册失败', + scope: '[worker/auth/register]', + }), +) + +router.post( + '/auth/login', + createRateLimitMiddleware({ + scope: 'worker:login', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), + }), + createRouteHandler((req) => loginWorker(req.body?.username, req.body?.password), { + successMessage: '登录成功', + errorMessage: '接单端登录失败', + scope: '[worker/auth/login]', + }), +) + +router.get( + '/auth/session', + createRouteHandler((req) => getWorkerSessionSummary(extractWorkerBearerToken(req)), { + successMessage: 'ok', + errorMessage: '读取接单登录态失败', + scope: '[worker/auth/session]', + }), +) + +router.post( + '/auth/logout', + createRouteHandler(() => ({ success: true }), { + successMessage: '已退出登录', + errorMessage: '退出失败', + scope: '[worker/auth/logout]', + }), +) + +router.use(requireWorkerSession) + +router.get( + '/profile', + createRouteHandler((req) => getWorkerProfile(getRequiredWorkerSession(req)), { + successMessage: 'ok', + errorMessage: '读取接单员信息失败', + scope: '[worker/profile]', + }), +) + +router.get( + '/hall/orders', + requireActiveWorker, + createRouteHandler((req) => listWorkerHallOrders(req.query, getRequiredWorkerSession(req)), { + successMessage: 'ok', + errorMessage: '读取抢单大厅失败', + scope: '[worker/hall/orders]', + }), +) + +router.post( + '/hall/orders/:workOrderId/grab', + requireActiveWorker, + createRouteHandler((req) => grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), { + successMessage: '抢单成功', + errorMessage: '抢单失败', + scope: '[worker/hall/orders/:workOrderId/grab]', + }), +) + +router.get( + '/orders', + requireActiveWorker, + createRouteHandler((req) => listWorkerMyOrders(req.query, getRequiredWorkerSession(req)), { + successMessage: 'ok', + errorMessage: '读取我的订单失败', + scope: '[worker/orders]', + }), +) + +router.post( + '/orders/:workOrderId/submit-acceptance', + requireActiveWorker, + createRouteHandler( + (req) => submitWorkerOrderAcceptance(String(req.params.workOrderId || ''), req.body || {}, getRequiredWorkerSession(req)), + { + successMessage: '验收资料已提交', + errorMessage: '提交验收资料失败', + scope: '[worker/orders/:workOrderId/submit-acceptance]', + }, + ), +) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router diff --git a/apps/backend/src/routes/worker/session.ts b/apps/backend/src/routes/worker/session.ts new file mode 100644 index 00000000..bae67685 --- /dev/null +++ b/apps/backend/src/routes/worker/session.ts @@ -0,0 +1,50 @@ +import type { NextFunction, Request, Response } from 'express' + +import { + requireActiveWorkerSession, + verifyWorkerSessionToken, + type WorkerSession, +} from '../../services/worker-platform/worker-platform-service.js' +import { createHttpError, sendRouteError } from '../../utils/http.js' + +export async function requireWorkerSession(req: Request, res: Response, next: NextFunction): Promise { + try { + req.workerSession = await verifyWorkerSessionToken(extractWorkerBearerToken(req)) + next() + } catch (error) { + sendRouteError(res, error, '接单端鉴权失败', '[worker/auth]') + } +} + +export function requireActiveWorker(req: Request, res: Response, next: NextFunction): void { + try { + requireActiveWorkerSession(req.workerSession) + next() + } catch (error) { + sendRouteError(res, error, '接单账号审核状态无效', '[worker/auth/active]') + } +} + +export function getRequiredWorkerSession(req: Request): WorkerSession { + if (req.workerSession) { + return req.workerSession + } + + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'worker_auth_required', + }) +} + +export function extractWorkerBearerToken(req: Request): string { + const authorization = String(req.headers.authorization || '').trim() + const matched = authorization.match(/^Bearer\s+(.+)$/i) + if (!matched?.[1]) { + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'worker_auth_required', + }) + } + + return matched[1].trim() +} diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.ts b/apps/backend/src/services/worker-platform/worker-platform-service.ts new file mode 100644 index 00000000..9b7ebf8c --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-platform-service.ts @@ -0,0 +1,974 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + acceptWorkOrderAndSettle, + addWorkerWalletCredit, + countWorkerActiveOrders, + createWorkOrder, + createWorkOrderEvent, + createWorkerUser, + findPendingMaterialWorkOrderByPlatformOrderId, + getWorkCategoryByKey, + getWorkOrderById, + getWorkerLevelById, + getWorkerLevelByKey, + getWorkerUserById, + getWorkerUserByUsername, + grabWorkOrder, + listWorkCategories, + listWorkOrders, + listWorkerLevels, + listWorkerUsers, + updateWorkOrder, + updateWorkerUser, + upsertWorkCategory, + upsertWorkerLevel, + type WorkOrderRow, + type WorkerLevelRow, + type WorkerUserRow, +} from '../../repositories/worker-platform-repo.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { randomId } from '../../utils/random.js' +import { nowIso, addHours } from '../../utils/time.js' +import { + normalizePage, + normalizePageSize, + safeParseJson, +} from '../admin/admin-query-utils.js' + +export type WorkerSession = { + sessionId: string + workerId: number + username: string + status: string + expiresAt: string + sessionVersion: number +} + +const DEFAULT_LEVEL_KEY = 'regular' +const DEFAULT_CATEGORY_KEY = 'default' +const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000 + +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 category = await upsertWorkCategory({ + categoryKey: DEFAULT_CATEGORY_KEY, + name: '默认分类', + sortOrder: 100, + status: 'active', + now, + }) + return { level, category } +} + +export async function registerWorker(payload: JsonObject = {}) { + ensureWorkerAuthConfigured() + const username = normalizeUsername(payload.username) + const password = normalizePassword(payload.password) + const displayName = String(payload.displayName || payload.nickname || username).trim() + const phone = String(payload.phone || '').trim() + + validateWorkerUsername(username) + validateWorkerPassword(password) + + if (await getWorkerUserByUsername(username)) { + throw createHttpError('接单账号已存在', { + statusCode: 409, + errorCode: 'worker_user_exists', + }) + } + + const defaults = await ensureWorkerPlatformDefaults() + const now = nowIso() + const created = await createWorkerUser({ + username, + passwordHash: hashWorkerPassword(password), + displayName, + phone, + levelId: defaults.level?.id || null, + status: 'pending_review', + now, + }) + + if (!created) { + throw createHttpError('接单账号注册失败', { + statusCode: 500, + errorCode: 'worker_register_failed', + }) + } + + return { + worker: mapWorkerUser(created), + reviewRequired: true, + } +} + +export async function loginWorker(username: unknown, password: unknown) { + ensureWorkerAuthConfigured() + const normalizedUsername = normalizeUsername(username) + const normalizedPassword = normalizePassword(password) + + if (!normalizedUsername || !normalizedPassword) { + throw createHttpError('缺少接单账号或密码', { + statusCode: 400, + errorCode: 'worker_credentials_required', + }) + } + + const worker = await getWorkerUserByUsername(normalizedUsername) + if (!worker || !verifyWorkerPassword(normalizedPassword, worker.password_hash)) { + throw createHttpError('账号或密码错误', { + statusCode: 401, + errorCode: 'worker_login_failed', + }) + } + + if (worker.status === 'disabled') { + throw createHttpError('接单账号已停用', { + statusCode: 403, + errorCode: 'worker_disabled', + }) + } + + return createWorkerSession(worker) +} + +export async function verifyWorkerSessionToken(token: unknown): Promise { + ensureWorkerAuthConfigured() + const normalizedToken = String(token || '').trim() + if (!normalizedToken) { + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'worker_auth_required', + }) + } + + const [encodedPayload, signature] = normalizedToken.split('.') + if (!encodedPayload || !signature || !safeCompare(signature, signWorkerPayload(encodedPayload))) { + throw createHttpError('接单登录态无效', { + statusCode: 401, + errorCode: 'worker_auth_invalid', + }) + } + + let payload: JsonObject = {} + try { + payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) + } catch { + throw createHttpError('接单登录态无效', { + statusCode: 401, + errorCode: 'worker_auth_invalid', + }) + } + + if (String(payload.typ || '') !== 'worker') { + throw createHttpError('接单登录态无效', { + statusCode: 401, + errorCode: 'worker_auth_invalid', + }) + } + + const expiresAt = String(payload.exp || '').trim() + if (!expiresAt || Date.parse(expiresAt) <= Date.now()) { + throw createHttpError('登录已过期,请重新登录', { + statusCode: 401, + errorCode: 'worker_auth_expired', + }) + } + + const workerId = Number(payload.uid || 0) + const worker = await getWorkerUserById(workerId) + if (!worker || worker.status === 'disabled') { + throw createHttpError('接单账号已不可用,请重新登录', { + statusCode: 401, + errorCode: 'worker_auth_user_invalid', + }) + } + + const currentSessionVersion = normalizeSessionVersion(worker.session_version) + if (Number(payload.ver || 0) !== currentSessionVersion) { + throw createHttpError('登录态已失效,请重新登录', { + statusCode: 401, + errorCode: 'worker_auth_stale', + }) + } + + return { + sessionId: String(payload.sid || '').trim(), + workerId: Number(worker.id), + username: worker.username, + status: worker.status, + expiresAt, + sessionVersion: currentSessionVersion, + } +} + +export async function getWorkerSessionSummary(token: unknown) { + const session = await verifyWorkerSessionToken(token) + const worker = await getRequiredWorker(session.workerId) + return { + authenticated: true, + expiresAt: session.expiresAt, + worker: mapWorkerUser(worker), + } +} + +export function requireActiveWorkerSession(session: WorkerSession | null | undefined) { + if (session?.status === 'active') { + return + } + + throw createHttpError('接单账号尚未通过审核', { + statusCode: 403, + errorCode: 'worker_not_active', + }) +} + +export async function getWorkerProfile(session: WorkerSession) { + const worker = await getRequiredWorker(session.workerId) + return { + worker: mapWorkerUser(worker), + permissions: resolveWorkerPermissions(worker), + } +} + +export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) { + requireActiveWorkerSession(session) + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listWorkOrders({ + page, + pageSize, + status: WORK_ORDER_STATUS.OPEN, + keyword: String(query.keyword || '').trim(), + }) + const worker = await getRequiredWorker(session.workerId) + const permissions = resolveWorkerPermissions(worker) + return { + items: items.map((item) => mapWorkOrderForWorker(item, permissions)), + pagination: { page, pageSize, total }, + } +} + +export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const permissions = resolveWorkerPermissions(worker) + const activeCount = await countWorkerActiveOrders(worker.id) + if (activeCount >= permissions.maxActiveOrders) { + throw createHttpError('已达到当前等级最大同时接单量', { + statusCode: 409, + errorCode: 'worker_active_order_limit', + }) + } + + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { + throw createHttpError('该订单已不可抢', { + statusCode: 409, + errorCode: 'work_order_not_open', + }) + } + + const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions) + if (Number(worker.available_amount || 0) < freezeAmount) { + throw createHttpError('余额不足,无法冻结所需押金', { + statusCode: 409, + errorCode: 'worker_deposit_insufficient', + }) + } + + const grabbed = await grabWorkOrder({ + workOrderId: Number(workOrder.id), + workerId: Number(worker.id), + depositAmount: freezeAmount, + now: nowIso(), + }) + if (!grabbed) { + throw createHttpError('抢单失败,订单可能已被抢走', { + statusCode: 409, + errorCode: 'work_order_grab_conflict', + }) + } + + return { order: mapWorkOrderForWorker(grabbed, permissions) } +} + +export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) { + requireActiveWorkerSession(session) + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const worker = await getRequiredWorker(session.workerId) + const { items, total } = await listWorkOrders({ + page, + pageSize, + status: String(query.status || '').trim(), + keyword: String(query.keyword || '').trim(), + workerId: worker.id, + }) + const permissions = resolveWorkerPermissions(worker) + return { + items: items.map((item) => mapWorkOrderForWorker(item, permissions)), + pagination: { page, pageSize, total }, + } +} + +export async function submitWorkerOrderAcceptance( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const workOrder = await getRequiredWorkOrder(workOrderId) + if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) { + throw createHttpError('只能提交自己的订单', { + statusCode: 403, + errorCode: 'work_order_owner_required', + }) + } + if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)) { + throw createHttpError('当前状态不能提交验收', { + statusCode: 409, + errorCode: 'work_order_submit_status_invalid', + }) + } + + const now = nowIso() + const acceptance = { + note: String(payload.note || '').trim(), + imageUrls: normalizeStringArray(payload.imageUrls), + submittedAt: now, + } + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + acceptance_json: acceptance, + submitted_at: now, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'worker', + actorId: String(session.workerId), + eventType: 'acceptance_submitted', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + payloadJson: JSON.stringify(acceptance), + now, + }) + return { order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(await getRequiredWorker(session.workerId))) } +} + +export async function collectLookupWorkOrder(payload: JsonObject = {}) { + const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() + if (!orderNo) { + throw createHttpError('请填写订单号', { + statusCode: 400, + errorCode: 'collect_order_no_required', + }) + } + const workOrder = await findPendingMaterialWorkOrderByPlatformOrderId(orderNo) + if (!workOrder) { + throw createHttpError('未找到待完善订单,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'collect_order_not_found', + }) + } + return { + order: mapWorkOrderPublic(workOrder), + fields: resolveRequirementFields(workOrder), + } +} + +export async function collectSubmitWorkOrder(payload: JsonObject = {}) { + const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() + if (!orderNo) { + throw createHttpError('请填写订单号', { + statusCode: 400, + errorCode: 'collect_order_no_required', + }) + } + const workOrder = await findPendingMaterialWorkOrderByPlatformOrderId(orderNo) + if (!workOrder) { + throw createHttpError('未找到待完善订单,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'collect_order_not_found', + }) + } + + const fields = resolveRequirementFields(workOrder) + const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) + const complete = fields.every((field) => !field.required || String(submittedFields[field.key] || '').trim()) + const now = nowIso() + const material = { + ...safeParseJson(workOrder.material_json), + collect: { + fields: submittedFields, + submittedAt: now, + complete, + }, + } + const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL + const updated = await updateWorkOrder(workOrder.id, { + status: nextStatus, + material_json: material, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'collect', + actorId: orderNo, + eventType: 'material_submitted', + fromStatus: workOrder.status, + toStatus: nextStatus, + payloadJson: JSON.stringify({ fields: submittedFields, complete }), + now, + }) + return { + order: mapWorkOrderPublic(updated || workOrder), + complete, + } +} + +export async function listAdminWorkerLevels() { + await ensureWorkerPlatformDefaults() + return { items: (await listWorkerLevels()).map(mapWorkerLevel) } +} + +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 permissions = { + depositFreeAmount: normalizeAmountFen(payload.depositFreeAmount ?? payload.depositFreeAmountYuan, 0), + maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 3), + } + const level = await upsertWorkerLevel({ + levelKey, + name, + sortOrder: normalizeInteger(payload.sortOrder, 100), + status: String(payload.status || 'active').trim() === 'disabled' ? 'disabled' : 'active', + permissionJson: JSON.stringify(permissions), + now, + }) + return { level: mapWorkerLevel(level) } +} + +export async function listAdminWorkerUsers(query: JsonObject = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listWorkerUsers({ + page, + pageSize, + status: String(query.status || '').trim(), + keyword: String(query.keyword || '').trim(), + }) + return { + items: items.map(mapWorkerUser), + pagination: { page, pageSize, total }, + } +} + +export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) { + const worker = await getRequiredWorker(workerId) + const status = normalizeReviewStatus(payload.status) + const levelId = normalizeOptionalId(payload.levelId) || worker.level_id || (await ensureWorkerPlatformDefaults()).level?.id || null + const now = nowIso() + const updated = await updateWorkerUser(worker.id, { + status, + level_id: levelId, + review_note: String(payload.reviewNote || payload.note || '').trim(), + session_version: normalizeSessionVersion(worker.session_version) + 1, + reviewed_at: now, + updated_at: now, + }) + return { worker: mapWorkerUser(updated || worker) } +} + +export async function creditAdminWorkerWallet(workerId: number | string, payload: JsonObject = {}) { + await getRequiredWorker(workerId) + const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) + if (amount <= 0) { + throw createHttpError('充值金额必须大于 0', { + statusCode: 400, + errorCode: 'worker_credit_amount_invalid', + }) + } + const wallet = await addWorkerWalletCredit({ + workerId: Number(workerId), + amount, + note: String(payload.note || '后台人工充值').trim(), + payloadJson: JSON.stringify({ source: 'admin_manual_credit' }), + now: nowIso(), + }) + return { wallet: mapWallet(wallet) } +} + +export async function listAdminWorkOrders(query: JsonObject = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listWorkOrders({ + page, + pageSize, + status: String(query.status || '').trim(), + keyword: String(query.keyword || '').trim(), + }) + return { + items: items.map(mapWorkOrderAdmin), + pagination: { page, pageSize, total }, + } +} + +export async function createAdminMockWorkOrder(payload: JsonObject = {}) { + const defaults = await ensureWorkerPlatformDefaults() + const now = nowIso() + const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 2500) + const depositThresholdAmount = normalizeAmountFen( + payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, + DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ) + const requiredDepositAmount = payload.requiredDepositAmount === undefined + ? (rewardAmount > depositThresholdAmount ? rewardAmount : 0) + : normalizeAmountFen(payload.requiredDepositAmount, 0) + const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true' + const fields = normalizeRequirementFields(payload.fields) + const platformOrderId = String(payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`).trim() + const productName = String(payload.productName || '指挥官秘钥1个').trim() + const material = materialComplete + ? { + collect: { + complete: true, + submittedAt: now, + fields: fields.reduce>((result, field) => { + result[field.key] = String(field.mockValue || `${field.label}测试值`) + return result + }, {}), + }, + } + : {} + const workOrder = await createWorkOrder({ + workOrderNo: randomId('WO'), + platformOrderId, + productName, + categoryId: defaults.category?.id || null, + status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL, + rewardAmount, + requiredDepositAmount, + depositThresholdAmount, + materialJson: JSON.stringify(material), + requirementJson: JSON.stringify({ fields }), + now, + }) + if (!workOrder) { + throw createHttpError('接单工单创建失败', { + statusCode: 500, + errorCode: 'work_order_create_failed', + }) + } + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: 'dev_mock', + eventType: 'mock_created', + fromStatus: '', + toStatus: workOrder.status, + payloadJson: JSON.stringify({ platformOrderId, productName, rewardAmount }), + now, + }) + return { order: mapWorkOrderAdmin(workOrder) } +} + +export async function publishAdminWorkOrder(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) { + throw createHttpError('只有未分配订单可以发布到大厅', { + statusCode: 409, + errorCode: 'work_order_publish_status_invalid', + }) + } + const now = nowIso() + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.OPEN, + published_at: now, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'published', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.OPEN, + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +export async function markAdminWorkOrderProblem(workOrderId: number | string, payload: JsonObject = {}) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(workOrder.status as never)) { + throw createHttpError('当前状态不能标记问题单', { + statusCode: 409, + errorCode: 'work_order_problem_status_invalid', + }) + } + const now = nowIso() + const note = String(payload.note || payload.problemNote || '').trim() + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.PROBLEM, + problem_note: note, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'marked_problem', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.PROBLEM, + payloadJson: JSON.stringify({ note }), + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +export async function acceptAdminWorkOrder(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { + throw createHttpError('只有待验收订单可以通过验收', { + statusCode: 409, + errorCode: 'work_order_accept_status_invalid', + }) + } + const now = nowIso() + const updated = await acceptWorkOrderAndSettle({ workOrderId: workOrder.id, now }) + if (!updated) { + throw createHttpError('验收失败,订单状态可能已变化', { + statusCode: 409, + errorCode: 'work_order_accept_conflict', + }) + } + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +export async function getAdminWorkerPlatformSummary() { + const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([ + listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }), + listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.PENDING_MATERIAL }), + listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.OPEN }), + listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.IN_PROGRESS }), + ]) + return { + pendingWorkerCount: pendingWorkers.total, + pendingMaterialCount: pendingMaterial.total, + openOrderCount: openOrders.total, + inProgressOrderCount: inProgressOrders.total, + } +} + +function createWorkerSession(worker: WorkerUserRow) { + const issuedAt = nowIso() + const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12)) + const payload = { + typ: 'worker', + sid: crypto.randomBytes(12).toString('hex'), + uid: Number(worker.id), + usr: worker.username, + status: worker.status, + ver: normalizeSessionVersion(worker.session_version), + iat: issuedAt, + exp: expiresAt, + } + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') + return { + token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`, + expiresAt, + worker: mapWorkerUser(worker), + } +} + +function ensureWorkerAuthConfigured() { + if (String(runtimeConfig.admin?.sessionSecret || '').trim()) { + return + } + + throw createHttpError('接单登录态签名密钥未配置,请设置 ADMIN_SESSION_SECRET', { + statusCode: 503, + errorCode: 'worker_auth_not_configured', + }) +} + +function hashWorkerPassword(password: string): string { + const salt = crypto.randomBytes(16).toString('hex') + const derived = crypto.scryptSync(password, salt, 64).toString('hex') + return `scrypt$${salt}$${derived}` +} + +function verifyWorkerPassword(password: string, storedHash: string): boolean { + const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') + if (algorithm !== 'scrypt' || !salt || !expectedHash) return false + return safeCompare(crypto.scryptSync(password, salt, 64).toString('hex'), expectedHash) +} + +function signWorkerPayload(encodedPayload: string): string { + return crypto + .createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || '')) + .update(`worker:${encodedPayload}`) + .digest('base64url') +} + +function safeCompare(input: unknown, expected: unknown): boolean { + const left = Buffer.from(String(input || ''), 'utf8') + const right = Buffer.from(String(expected || ''), 'utf8') + if (left.length !== right.length) return false + return crypto.timingSafeEqual(left, right) +} + +async function getRequiredWorker(workerId: number | string): Promise { + const worker = await getWorkerUserById(workerId) + if (!worker) { + throw createHttpError('接单账号不存在', { + statusCode: 404, + errorCode: 'worker_not_found', + }) + } + return worker +} + +async function getRequiredWorkOrder(workOrderId: number | string): Promise { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder) { + throw createHttpError('接单订单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + return workOrder +} + +function resolveWorkerPermissions(worker: WorkerUserRow) { + const permission = safeParseJson(worker.level_permission_json) + return { + depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), + maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 3), + } +} + +function resolveFreezeDepositAmount(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) { + return Math.max(0, Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0)) +} + +function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { + if (!level) return null + const permission = safeParseJson(level.permission_json) + return { + levelId: Number(level.id), + levelKey: level.level_key, + name: level.name, + sortOrder: Number(level.sort_order || 100), + status: level.status, + depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), + maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 3), + createdAt: level.created_at, + updatedAt: level.updated_at, + } +} + +function mapWorkerUser(worker: WorkerUserRow) { + return { + workerId: Number(worker.id), + username: worker.username, + displayName: worker.display_name || worker.username, + phone: worker.phone, + status: worker.status, + reviewNote: worker.review_note || '', + level: worker.level_id + ? { + levelId: Number(worker.level_id), + levelKey: worker.level_key || '', + name: worker.level_name || '', + permissions: resolveWorkerPermissions(worker), + } + : null, + wallet: { + availableAmount: Number(worker.available_amount || 0), + frozenDepositAmount: Number(worker.frozen_deposit_amount || 0), + }, + createdAt: worker.created_at, + updatedAt: worker.updated_at, + reviewedAt: worker.reviewed_at, + } +} + +function mapWorkOrderAdmin(workOrder: WorkOrderRow) { + return { + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + platformOrderId: workOrder.platform_order_id, + productName: workOrder.product_name, + categoryName: workOrder.category_name || '', + status: workOrder.status, + rewardAmount: Number(workOrder.reward_amount || 0), + requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), + depositThresholdAmount: Number(workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT), + material: safeParseJson(workOrder.material_json), + requirement: safeParseJson(workOrder.requirement_json), + acceptance: safeParseJson(workOrder.acceptance_json), + problemNote: workOrder.problem_note || '', + worker: workOrder.assigned_worker_id + ? { + workerId: Number(workOrder.assigned_worker_id), + username: workOrder.worker_username || '', + displayName: workOrder.worker_display_name || '', + } + : null, + publishedAt: workOrder.published_at, + assignedAt: workOrder.assigned_at, + submittedAt: workOrder.submitted_at, + acceptedAt: workOrder.accepted_at, + createdAt: workOrder.created_at, + updatedAt: workOrder.updated_at, + } +} + +function mapWorkOrderForWorker(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) { + return { + ...mapWorkOrderAdmin(workOrder), + freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), + } +} + +function mapWorkOrderPublic(workOrder: WorkOrderRow) { + return { + workOrderNo: workOrder.work_order_no, + platformOrderId: workOrder.platform_order_id, + productName: workOrder.product_name, + status: workOrder.status, + } +} + +function mapWallet(wallet: Awaited>) { + return { + workerId: Number(wallet?.worker_id || 0), + availableAmount: Number(wallet?.available_amount || 0), + frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0), + totalCreditedAmount: Number(wallet?.total_credited_amount || 0), + totalSettledAmount: Number(wallet?.total_settled_amount || 0), + } +} + +function resolveRequirementFields(workOrder: WorkOrderRow) { + return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields) +} + +function normalizeRequirementFields(value: unknown) { + const rawFields = Array.isArray(value) ? value : [] + const fields = rawFields + .map((item) => { + const source = item && typeof item === 'object' && !Array.isArray(item) ? item as JsonObject : {} + const key = String(source.key || '').trim() + if (!key) return null + return { + key, + label: String(source.label || key).trim(), + required: source.required !== false, + mockValue: String(source.mockValue || '').trim(), + } + }) + .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => Boolean(item)) + + return fields.length > 0 + ? fields + : [ + { key: 'gameAccount', label: '游戏账号', required: true, mockValue: 'test_account' }, + { key: 'serverName', label: '区服', required: true, mockValue: '安卓一区' }, + { key: 'roleName', label: '角色名', required: true, mockValue: '测试角色' }, + ] +} + +function normalizeSubmittedFields(value: unknown) { + const source = value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {} + return Object.fromEntries( + Object.entries(source) + .filter(([key]) => !['orderNo', 'platformOrderId', 'fields', 'material'].includes(key)) + .map(([key, currentValue]) => [key, String(currentValue || '').trim()]), + ) +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.map((item) => String(item || '').trim()).filter(Boolean) +} + +function normalizeReviewStatus(value: unknown) { + const status = String(value || '').trim() + if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status + return 'pending_review' +} + +function normalizeUsername(value: unknown): string { + return String(value || '').trim().toLowerCase() +} + +function normalizePassword(value: unknown): string { + return String(value || '').trim() +} + +function validateWorkerUsername(username: string) { + if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) { + throw createHttpError('接单账号格式无效,需为 3-32 位字母数字或 ._-', { + statusCode: 400, + errorCode: 'worker_username_invalid', + }) + } +} + +function validateWorkerPassword(password: string) { + if (password.length < 6) { + throw createHttpError('接单密码至少 6 位', { + statusCode: 400, + errorCode: 'worker_password_invalid', + }) + } +} + +function normalizeSessionVersion(value: unknown): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 +} + +function normalizeOptionalId(value: unknown): number | null { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function normalizeInteger(value: unknown, fallback: number): number { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +function normalizePositiveInteger(value: unknown, fallback: number): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizeAmountFen(value: unknown, fallback: number): number { + const text = String(value ?? '').trim() + if (!text) return fallback + const parsed = Number(text) + if (!Number.isFinite(parsed) || parsed < 0) return fallback + return Math.round(parsed * 100) +} diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts index 919b8815..2cd611bf 100644 --- a/apps/backend/src/types/express.d.ts +++ b/apps/backend/src/types/express.d.ts @@ -1,9 +1,11 @@ import type { AdminSession } from '../services/admin/admin-auth-service.js' +import type { WorkerSession } from '../services/worker-platform/worker-platform-service.js' declare global { namespace Express { interface Request { adminSession?: AdminSession | null + workerSession?: WorkerSession | null rawBody?: string requestId?: string } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index bf9c0614..a2fd618a 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -3,7 +3,9 @@ import { Spin } from 'antd' import { HashRouter, Navigate, Outlet, Route, Routes, useLocation } from 'react-router' import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' +import { hasWorkerSession } from '@/utils/worker-auth' import AdminLayout from '@/layouts/AdminLayout' +import WorkerLayout from '@/layouts/WorkerLayout' const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) @@ -22,8 +24,14 @@ const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage')) const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage')) const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage')) +const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage')) const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage')) +const CollectPage = lazy(() => import('@/pages/collect/CollectPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) +const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage')) +const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage')) +const WorkerOrdersPage = lazy(() => import('@/pages/worker/WorkerOrdersPage')) +const WorkerProfilePage = lazy(() => import('@/pages/worker/WorkerProfilePage')) function RequireAdmin() { const location = useLocation() @@ -51,6 +59,16 @@ function LoginRoute() { return } +function RequireWorker() { + const location = useLocation() + + if (!hasWorkerSession()) { + return + } + + return +} + export default function App() { return ( @@ -66,6 +84,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -77,7 +96,17 @@ export default function App() { + } /> + }> + }> + } /> + } /> + } /> + } /> + + } /> + } /> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 504a3d20..c715375e 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -11,6 +11,7 @@ import { SettingOutlined, ShopOutlined, TeamOutlined, + TrophyOutlined, UnorderedListOutlined, UserOutlined, } from '@ant-design/icons' @@ -55,6 +56,7 @@ export default function AdminLayout() { { key: '/admin/dashboard', icon: , label: '概览' }, { key: '/admin/orders', icon: , label: '订单' }, { key: '/admin/tasks', icon: , label: '任务' }, + { key: '/admin/worker-platform', icon: , label: '接单平台' }, { key: '/admin/kuaishou-industry', icon: , label: '电子凭证' }, { key: '/admin/cloudtentacles-records', icon: , label: '发货记录' }, ] @@ -204,6 +206,7 @@ export default function AdminLayout() { function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/admin/orders')) return '/admin/orders' if (pathname.startsWith('/admin/tasks')) return '/admin/tasks' + if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform' if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry' if (pathname === '/admin/platform-shops') return '/admin/platform-shops' return pathname diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx new file mode 100644 index 00000000..49ecc69d --- /dev/null +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -0,0 +1,98 @@ +import { + ContainerOutlined, + LogoutOutlined, + ShoppingOutlined, + UserOutlined, +} from '@ant-design/icons' +import { App, Button, Layout, Menu, Space, Typography } from 'antd' +import type { MenuProps } from 'antd' +import { Outlet, useLocation, useNavigate } from 'react-router' + +import { logoutWorker } from '@/services/worker' +import { + clearWorkerSession, + getWorkerStatus, + getWorkerUsername, +} from '@/utils/worker-auth' + +const menuItems: MenuProps['items'] = [ + { key: '/worker/hall', icon: , label: '抢单大厅' }, + { key: '/worker/orders', icon: , label: '我的订单' }, + { key: '/worker/profile', icon: , label: '个人中心' }, +] + +export default function WorkerLayout() { + const navigate = useNavigate() + const location = useLocation() + const { message } = App.useApp() + const username = getWorkerUsername() || '接单员' + const status = getWorkerStatus() + + async function submitLogout() { + try { + await logoutWorker() + } catch { + // 接单端退出为无状态操作,本地清理优先。 + } finally { + clearWorkerSession() + message.success('已退出登录') + navigate('/worker/login', { replace: true }) + } + } + + return ( + + +
+
+
+ 趣游通 + 接单平台 +
+
+
+ {username} + {formatWorkerStatus(status)} +
+ navigate(String(key))} + /> +
+ +
+
+
+ + + + {formatWorkerStatus(status)} + + + + + + +
+ ) +} + +function resolveSelectedKey(pathname: string) { + if (pathname.startsWith('/worker/orders')) return '/worker/orders' + if (pathname.startsWith('/worker/profile')) return '/worker/profile' + return '/worker/hall' +} + +function formatWorkerStatus(status: string) { + if (status === 'active') return '已审核' + if (status === 'pending_review') return '待审核' + if (status === 'rejected') return '审核未通过' + if (status === 'disabled') return '已停用' + return '未登录' +} diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index dfb08252..484145a0 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -1,5 +1,6 @@ import axios from 'axios' import { clearAdminSession, getAdminToken } from '@/utils/admin-auth' +import { clearWorkerSession, getWorkerToken } from '@/utils/worker-auth' export interface ApiEnvelope { code: number @@ -66,6 +67,22 @@ http.interceptors.response.use( } } + if (error?.config?.url && String(error.config.url).startsWith('/api/v1/worker')) { + const status = Number(error?.response?.status || 0) + const errorCode = String(error?.response?.data?.errorCode || '').trim() + + if (status === 401 && shouldClearWorkerSession(errorCode)) { + clearWorkerSession() + + if ( + window.location.hash.startsWith('#/worker') && + !window.location.hash.startsWith('#/worker/login') + ) { + window.location.hash = '#/worker/login' + } + } + } + return Promise.reject(normalizedError) }, ) @@ -79,6 +96,14 @@ http.interceptors.request.use((config) => { } } + if (String(config.url || '').startsWith('/api/v1/worker')) { + const token = getWorkerToken() + + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + } + return config }) @@ -110,6 +135,16 @@ function shouldClearAdminSession(errorCode: string) { ].includes(errorCode) } +function shouldClearWorkerSession(errorCode: string) { + return [ + 'worker_auth_required', + 'worker_auth_invalid', + 'worker_auth_expired', + 'worker_auth_user_invalid', + 'worker_auth_stale', + ].includes(errorCode) +} + function request(config: Parameters>>[0]) { return http.request, ApiEnvelope>(config) } diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx new file mode 100644 index 00000000..44a423fd --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -0,0 +1,513 @@ +import { + CheckOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useState } from 'react' + +import PageHeader from '@/components/admin/PageHeader' +import { + acceptAdminWorkOrder, + createAdminMockWorkOrder, + creditAdminWorkerWallet, + fetchAdminWorkerLevels, + fetchAdminWorkerPlatformSummary, + fetchAdminWorkerUsers, + fetchAdminWorkOrders, + markAdminWorkOrderProblem, + publishAdminWorkOrder, + reviewAdminWorkerUser, + saveAdminWorkerLevel, +} from '@/services/admin' +import type { WorkOrder, WorkerLevel, WorkerUser } from '@/types/worker-platform' + +export default function AdminWorkerPlatformPage() { + return ( +
+ + + }, + { key: 'workers', label: '接单员', children: }, + { key: 'levels', label: '等级权限', children: }, + ]} + /> +
+ ) +} + +function SummaryCards() { + const summaryQuery = useQuery({ + queryKey: ['admin-worker-platform-summary'], + queryFn: () => fetchAdminWorkerPlatformSummary(), + }) + const summary = summaryQuery.data?.data + + return ( +
+ + + + + + + + + + + + +
+ ) +} + +function WorkOrdersPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [status, setStatus] = useState('') + const [problemOrder, setProblemOrder] = useState(null) + const [problemForm] = Form.useForm() + + const ordersQuery = useQuery({ + queryKey: ['admin-worker-platform-orders', status], + queryFn: () => fetchAdminWorkOrders({ status }), + }) + + async function refreshAll() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }), + ]) + } + + async function runAction(action: () => Promise, successMessage: string) { + try { + await action() + message.success(successMessage) + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '操作失败') + } + } + + async function submitProblem(values: { note?: string }) { + if (!problemOrder) return + await runAction( + () => markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''), + '已标记问题单', + ) + setProblemOrder(null) + problemForm.resetFields() + } + + const columns: TableColumnsType = [ + { + title: '订单', + minWidth: 300, + render: (_, row) => ( +
+ {row.productName} + + {row.platformOrderId} · {row.workOrderNo} + +
+ ), + }, + { title: '金额', width: 110, render: (_, row) => formatMoney(row.rewardAmount) }, + { title: '押金', width: 110, render: (_, row) => formatMoney(row.requiredDepositAmount) }, + { + title: '状态', + width: 130, + render: (_, row) => {formatStatus(row.status)}, + }, + { + title: '接单员', + width: 140, + render: (_, row) => row.worker?.displayName || row.worker?.username || '-', + }, + { + title: '操作', + width: 260, + render: (_, row) => ( + + {row.status === 'unassigned' ? ( + + ) : null} + {row.status === 'pending_acceptance' ? ( + + ) : null} + {['in_progress', 'pending_acceptance'].includes(row.status) ? ( + + ) : null} + + ), + }, + ] + + return ( +
+ + + + + + + + + + + + + + + + + + + + ) +} + +function WorkersPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [status, setStatus] = useState('') + const [creditWorker, setCreditWorker] = useState(null) + const [creditForm] = Form.useForm() + + const workersQuery = useQuery({ + queryKey: ['admin-worker-platform-workers', status], + queryFn: () => fetchAdminWorkerUsers({ status }), + }) + + async function refreshWorkers() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-workers'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }), + ]) + } + + async function review(worker: WorkerUser, nextStatus: string) { + try { + await reviewAdminWorkerUser(worker.workerId, { status: nextStatus }) + message.success('接单员状态已更新') + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '操作失败') + } + } + + async function submitCredit(values: { amount?: number; note?: string }) { + if (!creditWorker) return + try { + await creditAdminWorkerWallet(creditWorker.workerId, { + amount: Number(values.amount || 0), + note: values.note, + }) + message.success('余额已增加') + setCreditWorker(null) + creditForm.resetFields() + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '充值失败') + } + } + + const columns: TableColumnsType = [ + { + title: '接单员', + minWidth: 220, + render: (_, row) => ( +
+ {row.displayName || row.username} + {row.username} +
+ ), + }, + { title: '等级', width: 140, render: (_, row) => row.level?.name || '-' }, + { title: '余额', width: 120, render: (_, row) => formatMoney(row.wallet.availableAmount) }, + { title: '冻结', width: 120, render: (_, row) => formatMoney(row.wallet.frozenDepositAmount) }, + { + title: '状态', + width: 130, + render: (_, row) => {formatWorkerStatus(row.status)}, + }, + { + title: '操作', + width: 260, + render: (_, row) => ( + + {row.status !== 'active' ? : null} + {row.status !== 'rejected' ? : null} + + + ), + }, + ] + + return ( + + + + + + + ) +} + +function LevelsPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const levelsQuery = useQuery({ + queryKey: ['admin-worker-platform-levels'], + queryFn: () => fetchAdminWorkerLevels(), + }) + const [form] = Form.useForm() + + async function saveLevel(values: { levelKey: string; name: string; depositFreeAmount?: number; maxActiveOrders?: number }) { + try { + await saveAdminWorkerLevel(values) + message.success('等级已保存') + await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-levels'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } + } + + const columns: TableColumnsType = [ + { title: '等级', dataIndex: 'name' }, + { title: '标识', dataIndex: 'levelKey' }, + { title: '免押额度', render: (_, row) => formatMoney(row.depositFreeAmount) }, + { title: '最大同时接单', dataIndex: 'maxActiveOrders' }, + { title: '状态', dataIndex: 'status' }, + ] + + return ( +
+ +
+ + + + + + + + + + + + + + + +
+
+ + + rowKey="levelId" + loading={levelsQuery.isLoading} + dataSource={levelsQuery.data?.data.items || []} + columns={columns} + pagination={false} + /> + +
+ ) +} + +function formatMoney(value: number | undefined) { + return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` +} + +function formatStatus(status: string) { + const labels: Record = { + pending_material: '待完善', + unassigned: '未分配', + open: '待抢单', + in_progress: '代练中', + pending_acceptance: '待验收', + problem: '问题单', + accepted: '已验收', + cancelled: '已取消', + } + return labels[status] || status +} + +function resolveStatusColor(status: string) { + if (status === 'accepted') return 'green' + if (status === 'problem') return 'red' + if (status === 'pending_acceptance') return 'gold' + if (status === 'open') return 'blue' + return 'default' +} + +function formatWorkerStatus(status: string) { + const labels: Record = { + pending_review: '待审核', + active: '已通过', + rejected: '已拒绝', + disabled: '已停用', + } + return labels[status] || status +} diff --git a/apps/frontend/src/pages/collect/CollectPage.tsx b/apps/frontend/src/pages/collect/CollectPage.tsx new file mode 100644 index 00000000..5b33db80 --- /dev/null +++ b/apps/frontend/src/pages/collect/CollectPage.tsx @@ -0,0 +1,106 @@ +import { SearchOutlined } from '@ant-design/icons' +import { App, Button, Card, Form, Input, Result, Space, Typography } from 'antd' +import { useState } from 'react' + +import { lookupCollectOrder, submitCollectOrder } from '@/services/worker' +import type { CollectField } from '@/types/worker-platform' + +type LookupOrder = { + workOrderNo: string + platformOrderId: string + productName: string + status: string +} + +export default function CollectPage() { + const { message } = App.useApp() + const [orderNo, setOrderNo] = useState('') + const [order, setOrder] = useState(null) + const [fields, setFields] = useState([]) + const [lookupLoading, setLookupLoading] = useState(false) + const [submitLoading, setSubmitLoading] = useState(false) + const [complete, setComplete] = useState(false) + + async function submitLookup(values: { orderNo: string }) { + setLookupLoading(true) + setComplete(false) + try { + const response = await lookupCollectOrder(values.orderNo) + setOrderNo(values.orderNo) + setOrder(response.data.order) + setFields(response.data.fields) + } catch (error) { + setOrder(null) + setFields([]) + message.error(error instanceof Error ? error.message : '查询失败') + } finally { + setLookupLoading(false) + } + } + + async function submitMaterial(values: Record) { + setSubmitLoading(true) + try { + const response = await submitCollectOrder({ orderNo, fields: values }) + setComplete(response.data.complete) + message.success(response.data.complete ? '资料已完善' : '资料已提交') + } catch (error) { + message.error(error instanceof Error ? error.message : '提交失败') + } finally { + setSubmitLoading(false) + } + } + + return ( +
+ + 订单资料补充 + + 请填写正确订单号,匹配成功后继续补充资料。 + + +
+ + + + + + +
+ + {complete ? ( + + ) : null} + + {order && !complete ? ( + + + 订单号:{order.platformOrderId} + +
+ {fields.map((field) => ( + + + + ))} + +
+
+ ) : null} +
+
+ ) +} diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx new file mode 100644 index 00000000..4bc6665b --- /dev/null +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -0,0 +1,102 @@ +import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { App, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from 'antd' +import { useState } from 'react' + +import { fetchWorkerHallOrders, grabWorkerOrder } from '@/services/worker' +import type { WorkOrder } from '@/types/worker-platform' + +export default function WorkerHallPage() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [keyword, setKeyword] = useState('') + const [grabbingId, setGrabbingId] = useState(0) + + const ordersQuery = useQuery({ + queryKey: ['worker-hall-orders', keyword], + queryFn: () => fetchWorkerHallOrders({ keyword }), + retry: false, + }) + + const orders = ordersQuery.data?.data.items || [] + + async function submitGrab(order: WorkOrder) { + setGrabbingId(order.workOrderId) + try { + await grabWorkerOrder(order.workOrderId) + message.success('抢单成功') + await queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '抢单失败') + } finally { + setGrabbingId(0) + } + } + + return ( +
+
+ 抢单大厅 + + } + onChange={(event) => setKeyword(event.target.value)} + onPressEnter={() => void ordersQuery.refetch()} + /> + + +
+ + {ordersQuery.isLoading ? : null} + {ordersQuery.error ? ( + + + {ordersQuery.error instanceof Error ? ordersQuery.error.message : '读取抢单大厅失败'} + + + ) : null} + + {!ordersQuery.isLoading && orders.length === 0 ? ( + + ) : ( +
+ {orders.map((order) => ( + + + + + {order.productName} + + {order.categoryName || '默认分类'} + + 订单号:{order.platformOrderId} + + {formatMoney(order.rewardAmount)} + + + 所需冻结押金:{formatMoney(order.freezeDepositAmount ?? order.requiredDepositAmount)} + + + + + ))} +
+ )} +
+ ) +} + +function formatMoney(value: number | undefined) { + return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` +} diff --git a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx new file mode 100644 index 00000000..5e806cfc --- /dev/null +++ b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx @@ -0,0 +1,119 @@ +import { LockOutlined, UserOutlined } from '@ant-design/icons' +import { App, Button, Card, Form, Input, Tabs, Typography } from 'antd' +import { useState } from 'react' +import { Navigate, useNavigate } from 'react-router' + +import { loginWorker, registerWorker } from '@/services/worker' +import { hasWorkerSession, setWorkerSession } from '@/utils/worker-auth' + +export default function WorkerLoginPage() { + const navigate = useNavigate() + const { message } = App.useApp() + const [loading, setLoading] = useState(false) + + if (hasWorkerSession()) { + return + } + + async function submitLogin(values: { username: string; password: string }) { + setLoading(true) + try { + const response = await loginWorker(values) + setWorkerSession(response.data.token, response.data.expiresAt, response.data.worker) + message.success('登录成功') + navigate('/worker/hall', { replace: true }) + } catch (error) { + message.error(error instanceof Error ? error.message : '登录失败') + } finally { + setLoading(false) + } + } + + async function submitRegister(values: { + username: string + password: string + displayName?: string + phone?: string + }) { + setLoading(true) + try { + await registerWorker(values) + message.success('注册成功,等待后台审核') + } catch (error) { + message.error(error instanceof Error ? error.message : '注册失败') + } finally { + setLoading(false) + } + } + + return ( +
+ + 接单平台 + + 账号注册后需要后台审核,通过后可进入抢单大厅。 + + + + } autoComplete="username" /> + + + } autoComplete="current-password" /> + + + + ), + }, + { + key: 'register', + label: '注册', + children: ( +
+ + } autoComplete="username" /> + + + + + + + + + } autoComplete="new-password" /> + + +
+ ), + }, + ]} + /> +
+
+ ) +} diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx new file mode 100644 index 00000000..12f4f6a4 --- /dev/null +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -0,0 +1,164 @@ +import { CheckOutlined, ReloadOutlined } from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Form, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useState } from 'react' + +import { fetchWorkerMyOrders, submitWorkerAcceptance } from '@/services/worker' +import type { WorkOrder } from '@/types/worker-platform' + +const STATUS_OPTIONS = [ + { value: '', label: '全部' }, + { value: 'in_progress', label: '代练中' }, + { value: 'pending_acceptance', label: '待验收' }, + { value: 'problem', label: '问题单' }, + { value: 'accepted', label: '已验收' }, +] + +export default function WorkerOrdersPage() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [status, setStatus] = useState('') + const [submittingOrder, setSubmittingOrder] = useState(null) + const [form] = Form.useForm() + + const ordersQuery = useQuery({ + queryKey: ['worker-my-orders', status], + queryFn: () => fetchWorkerMyOrders({ status }), + retry: false, + }) + + async function submitAcceptance(values: { note?: string; imageUrls?: string }) { + if (!submittingOrder) return + try { + await submitWorkerAcceptance(submittingOrder.workOrderId, { + note: values.note, + imageUrls: String(values.imageUrls || '') + .split('\n') + .map((item) => item.trim()) + .filter(Boolean), + }) + message.success('已提交验收') + setSubmittingOrder(null) + form.resetFields() + await queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '提交失败') + } + } + + const columns: TableColumnsType = [ + { + title: '订单', + minWidth: 280, + render: (_, row) => ( +
+ {row.productName} + {row.platformOrderId} +
+ ), + }, + { + title: '金额', + width: 130, + render: (_, row) => {formatMoney(row.rewardAmount)}, + }, + { + title: '状态', + width: 140, + render: (_, row) => {formatStatus(row.status)}, + }, + { + title: '问题备注', + dataIndex: 'problemNote', + minWidth: 180, + render: (value) => String(value || '-'), + }, + { + title: '操作', + width: 140, + render: (_, row) => + ['in_progress', 'problem'].includes(row.status) ? ( + + ) : null, + }, + ] + + return ( +
+
+ 我的订单 + +