新增接单平台第一期闭环
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 '接单工单状态与操作流水';
|
||||
@@ -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<WorkOrderStatus>([
|
||||
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))
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
requirement_json: string | Record<string, unknown>
|
||||
acceptance_json: string | Record<string, unknown>
|
||||
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<WorkerLevelRow | null> {
|
||||
const result = await query<WorkerLevelRow>(
|
||||
'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<WorkerLevelRow | null> {
|
||||
const result = await query<WorkerLevelRow>('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [Number(levelId)])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listWorkerLevels(): Promise<WorkerLevelRow[]> {
|
||||
const result = await query<WorkerLevelRow>(
|
||||
'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<WorkerLevelRow | null> {
|
||||
const result = await query<WorkerLevelRow>(
|
||||
`
|
||||
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<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(
|
||||
`${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<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(
|
||||
`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`,
|
||||
[Number(workerId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function createWorkerUser(input: CreateWorkerInput): Promise<WorkerUserRow | null> {
|
||||
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<Pick<WorkerUserRow, 'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at'>>,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
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<WorkerUserRow>(
|
||||
`${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<WorkerWalletRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
await ensureWorkerWalletWithClient(client, Number(workerId), now)
|
||||
return getWorkerWalletWithClient(client, Number(workerId))
|
||||
})
|
||||
}
|
||||
|
||||
export async function getWorkerWallet(workerId: number | string): Promise<WorkerWalletRow | null> {
|
||||
const result = await query<WorkerWalletRow>(
|
||||
'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<WorkerWalletRow | null> {
|
||||
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<WorkCategoryRow | null> {
|
||||
const result = await query<WorkCategoryRow>(
|
||||
'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1',
|
||||
[String(categoryKey || '').trim()],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listWorkCategories(): Promise<WorkCategoryRow[]> {
|
||||
const result = await query<WorkCategoryRow>(
|
||||
`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<WorkCategoryRow | null> {
|
||||
const result = await query<WorkCategoryRow>(
|
||||
`
|
||||
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<WorkOrderRow | null> {
|
||||
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<WorkOrderRow | null> {
|
||||
const result = await query<WorkOrderRow>(
|
||||
`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`,
|
||||
[Number(workOrderId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findPendingMaterialWorkOrderByPlatformOrderId(
|
||||
platformOrderId: string,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const result = await query<WorkOrderRow>(
|
||||
`${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<WorkOrderRow>(
|
||||
`${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<Pick<
|
||||
WorkOrderRow,
|
||||
| 'status'
|
||||
| 'assigned_worker_id'
|
||||
| 'material_json'
|
||||
| 'acceptance_json'
|
||||
| 'problem_note'
|
||||
| 'published_at'
|
||||
| 'assigned_at'
|
||||
| 'submitted_at'
|
||||
| 'accepted_at'
|
||||
| 'updated_at'
|
||||
>>,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
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<WorkOrderRow | null> {
|
||||
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<WorkOrderRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
`
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<typeof createWorkOrderEvent>[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<void> {
|
||||
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<WorkerWalletRow | null> {
|
||||
const result = await client.query<WorkerWalletRow>(
|
||||
'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<WorkerUserRow | null> {
|
||||
const result = await client.query<WorkerUserRow>(
|
||||
`${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<WorkOrderRow | null> {
|
||||
const result = await client.query<WorkOrderRow>(
|
||||
`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`,
|
||||
[Number(workOrderId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
function buildWorkerUserWhere({ status = '', keyword = '' }: Pick<ListInput, 'status' | 'keyword'>) {
|
||||
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 || {})
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
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<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<void> {
|
||||
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()
|
||||
}
|
||||
@@ -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<WorkerSession> {
|
||||
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<Record<string, string>>((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<WorkerUserRow> {
|
||||
const worker = await getWorkerUserById(workerId)
|
||||
if (!worker) {
|
||||
throw createHttpError('接单账号不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'worker_not_found',
|
||||
})
|
||||
}
|
||||
return worker
|
||||
}
|
||||
|
||||
async function getRequiredWorkOrder(workOrderId: number | string): Promise<WorkOrderRow> {
|
||||
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<ReturnType<typeof addWorkerWalletCredit>>) {
|
||||
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)
|
||||
}
|
||||
Vendored
+2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user