拆分接单平台超长文件,按域组织代码

- worker-platform-service.ts (2733行) 拆为 worker-service/admin-service/mappers + index 聚合
- worker-platform-repo.ts (2456行) 拆为 repositories/worker-platform/ 目录(types/shared/worker-repo/work-order-repo)
- AdminWorkerPlatformPage.tsx (2784行) 拆为主页面 + panels/ 目录(7 个面板文件)
- 引用方统一改指向 index 聚合,行为不变
This commit is contained in:
yml2213
2026-08-01 16:44:20 +08:00
parent c33e859e96
commit 70e64155bc
27 changed files with 7200 additions and 6463 deletions
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveOutstandingDepositAmount } from './worker-platform-repo.js'
import { resolveOutstandingDepositAmount } from './worker-platform/index.js'
test('resolveOutstandingDepositAmount deducts released and deducted history', () => {
assert.equal(
@@ -0,0 +1,4 @@
export * from './types.js'
export * from './shared.js'
export * from './worker-repo.js'
export * from './work-order-repo.js'
@@ -0,0 +1,44 @@
import type { PoolClient } from 'pg'
import type { WorkerWalletRow } from './types.js'
export 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],
)
}
export 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 FOR UPDATE',
[workerId],
)
return result.rows[0] || null
}
export function toJsonString(value: unknown): string {
if (typeof value === 'string') return value
return JSON.stringify(value ?? {})
}
export function toPositiveInteger(value: unknown, fallback: number): number {
const parsed = Number(value ?? '')
if (!Number.isFinite(parsed)) return fallback
return Math.max(0, Math.round(parsed))
}
@@ -0,0 +1,246 @@
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
inviter_id: number | null
invite_code: string
inviter_username?: string
inviter_display_name?: string
level_key?: string
level_name?: string
level_permission_json?: string | Record<string, unknown>
available_amount?: number
frozen_deposit_amount?: number
total_credited_amount?: number
total_settled_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 WorkerWalletLedgerRow = {
id: number
worker_id: number
ledger_type: string
amount: number
balance_after: number
frozen_after: number
related_work_order_id: number | null
audit_status: string
note: string
payload_json: string | Record<string, unknown>
created_at: string
}
export type WorkerFinanceRequestRow = {
id: number
worker_id: number
request_type: string
status: string
amount: number
account_channel: string
account_name: string
account_no: string
note: string
reviewed_note: string
payload_json: string | Record<string, unknown>
created_at: string
updated_at: string
reviewed_at: string | null
worker_username?: string
worker_display_name?: string
worker_phone?: string
}
export type WorkCategoryRow = {
id: number
category_key: string
name: string
sort_order: number
status: string
created_at: string
updated_at: string
}
export type WorkProductRuleRow = {
id: number
rule_key: string
provider: string
platform: string
shop_id: string
sku_code: string
product_name: string
match_type: string
category_id: number | null
enabled: boolean
auto_create: boolean
reward_amount: number
required_deposit_amount: number
deposit_threshold_amount: number
sharing_enabled: boolean
sharing_total_quantity: number
sharing_unit_reward: number
requirement_json: string | Record<string, unknown>
sort_order: number
created_at: string
updated_at: string
category_name?: string
}
export type WorkOrderShareRow = {
id: number
work_order_id: number
worker_id: number
quantity: number
unit_reward: number
share_reward: number
share_deposit: number
status: string
acceptance_json: string | Record<string, unknown>
submitted_at: string | null
accepted_at: string | null
created_at: string
updated_at: string
worker_username?: string
worker_display_name?: 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
sharing_enabled: boolean
sharing_total_quantity: number
sharing_unit_reward: number
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
}
export type ProblemWorkOrderResolutionAction =
| 'return_to_worker'
| 'reopen'
| 'cancel_release'
| 'cancel_deduct'
export type GrabWorkOrderFailureReason =
| 'worker_active_order_limit'
| 'worker_deposit_insufficient'
| 'work_order_not_open'
export type GrabWorkOrderResult = {
order: WorkOrderRow | null
failureReason: GrabWorkOrderFailureReason | null
}
export type ListInput = {
page?: number
pageSize?: number
status?: string
keyword?: string
workerId?: number
categoryId?: number
workerSharingId?: number
}
export type ProductRuleListInput = {
enabled?: boolean | null
keyword?: string
}
export type WalletLedgerListInput = {
page?: number
pageSize?: number
workerId?: number
ledgerType?: string
}
export type FinanceRequestListInput = {
page?: number
pageSize?: number
workerId?: number
status?: string
requestType?: string
keyword?: string
}
export type CreateWorkerInput = {
username: string
passwordHash: string
displayName: string
phone: string
levelId: number | null
status: string
inviterId?: number | null
inviteCode: string
now: string
}
export 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
sharingEnabled?: boolean
sharingTotalQuantity?: number
sharingUnitReward?: number
materialJson: string
requirementJson: string
now: string
}
@@ -0,0 +1,794 @@
import { query, withTransaction } from '../../db/client.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import type { CreateWorkerInput, FinanceRequestListInput, ListInput, WalletLedgerListInput, WorkerFinanceRequestRow, WorkerLevelRow, WorkerUserRow, WorkerWalletLedgerRow, WorkerWalletRow } from './types.js'
import type { PoolClient } from 'pg'
const WORKER_USER_SELECT = `
SELECT
wu.*,
wl.level_key,
wl.name AS level_name,
wl.permission_json AS level_permission_json,
inv.username AS inviter_username,
inv.display_name AS inviter_display_name,
ww.available_amount,
ww.frozen_deposit_amount,
ww.total_credited_amount,
ww.total_settled_amount
FROM worker_users wu
LEFT JOIN worker_levels wl ON wl.id = wu.level_id
LEFT JOIN worker_users inv ON inv.id = wu.inviter_id
LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id
`
const WORKER_FINANCE_REQUEST_SELECT = `
SELECT
wfr.*,
wu.username AS worker_username,
wu.display_name AS worker_display_name,
wu.phone AS worker_phone
FROM worker_finance_requests wfr
LEFT JOIN worker_users wu ON wu.id = wfr.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 countWorkerLevelUsages(levelId: number | string): Promise<number> {
const result = await query<{ total: number }>(
'SELECT COUNT(*)::int AS total FROM worker_users WHERE level_id = $1',
[Number(levelId)],
)
return Number(result.rows[0]?.total || 0)
}
export async function deleteWorkerLevel(levelId: number | string): Promise<{ deleted: boolean }> {
const result = await query<{ id: number }>(
'DELETE FROM worker_levels WHERE id = $1 RETURNING id',
[Number(levelId)],
)
return { deleted: Boolean(result.rows[0]) }
}
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 getWorkerUserByInviteCode(
inviteCode: string,
): Promise<WorkerUserRow | null> {
const result = await query<WorkerUserRow>(
`${WORKER_USER_SELECT} WHERE wu.invite_code = $1 LIMIT 1`,
[String(inviteCode || '').trim().toUpperCase()],
)
return result.rows[0] || null
}
export async function getWorkerUserById(workerId: number | string): Promise<WorkerUserRow | null> {
const result = await query<WorkerUserRow>(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [
Number(workerId),
])
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,
inviter_id, invite_code,
created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`,
[
input.username,
input.passwordHash,
input.displayName,
input.phone,
input.levelId,
input.status,
input.inviterId || null,
input.inviteCode,
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 updateWorkerPassword(input: {
workerId: number
passwordHash: string
sessionVersion: number
now: string
}): Promise<WorkerUserRow | null> {
const result = await query<{ id: number }>(
`
UPDATE worker_users
SET
password_hash = $1,
session_version = $2,
updated_at = $3
WHERE id = $4
RETURNING id
`,
[input.passwordHash, input.sessionVersion, input.now, input.workerId],
)
if (!result.rows[0]) {
return null
}
return getWorkerUserById(input.workerId)
}
export async function incrementWorkerSessionVersion(
workerId: number | string,
now: string,
): Promise<WorkerUserRow | null> {
const result = await query<{ id: number }>(
`
UPDATE worker_users
SET session_version = session_version + 1, updated_at = $2
WHERE id = $1
RETURNING id
`,
[Number(workerId), now],
)
if (!result.rows[0]) {
return null
}
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 listWorkerWalletLedgers({
page = 1,
pageSize = 20,
workerId = 0,
ledgerType = '',
}: WalletLedgerListInput = {}): Promise<{ items: WorkerWalletLedgerRow[]; total: number }> {
const { whereClause, params } = buildWorkerWalletLedgerWhere({
workerId,
ledgerType,
})
const totalResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM worker_wallet_ledgers wwl ${whereClause}`,
params,
)
const offset = (page - 1) * pageSize
params.push(pageSize, offset)
const itemsResult = await query<WorkerWalletLedgerRow>(
`
SELECT *
FROM worker_wallet_ledgers wwl
${whereClause}
ORDER BY wwl.created_at DESC, wwl.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
)
return {
items: itemsResult.rows,
total: Number(totalResult.rows[0]?.total || 0),
}
}
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 createWorkerFinanceRequest(input: {
workerId: number
requestType: string
amount: number
accountChannel: string
accountName: string
accountNo: string
note: string
payloadJson: string
now: string
}): Promise<WorkerFinanceRequestRow | null> {
const result = await query<WorkerFinanceRequestRow>(
`
INSERT INTO worker_finance_requests (
worker_id, request_type, status, amount,
account_channel, account_name, account_no,
note, reviewed_note, payload_json, created_at, updated_at
) VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, '', $8::jsonb, $9, $10)
RETURNING *
`,
[
input.workerId,
input.requestType,
input.amount,
input.accountChannel,
input.accountName,
input.accountNo,
input.note,
input.payloadJson,
input.now,
input.now,
],
)
return result.rows[0] || null
}
export async function getWorkerFinanceRequestById(
requestId: number | string,
): Promise<WorkerFinanceRequestRow | null> {
const result = await query<WorkerFinanceRequestRow>(
`${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`,
[Number(requestId)],
)
return result.rows[0] || null
}
export async function listWorkerFinanceRequests({
page = 1,
pageSize = 20,
workerId = 0,
status = '',
requestType = '',
keyword = '',
}: FinanceRequestListInput = {}): Promise<{ items: WorkerFinanceRequestRow[]; total: number }> {
const { whereClause, params } = buildWorkerFinanceRequestWhere({
workerId,
status,
requestType,
keyword,
})
const totalResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr
LEFT JOIN worker_users wu ON wu.id = wfr.worker_id
${whereClause}`,
params,
)
const offset = (page - 1) * pageSize
params.push(pageSize, offset)
const itemsResult = await query<WorkerFinanceRequestRow>(
`${WORKER_FINANCE_REQUEST_SELECT}
${whereClause}
ORDER BY wfr.created_at DESC, wfr.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
)
return {
items: itemsResult.rows,
total: Number(totalResult.rows[0]?.total || 0),
}
}
export async function reviewWorkerFinanceRequest(input: {
requestId: number
status: string
reviewedNote: string
now: string
}): Promise<{
request: WorkerFinanceRequestRow | null
failureReason: 'request_not_pending' | 'withdraw_insufficient' | null
}> {
return withTransaction(async (client) => {
const currentResult = await client.query<WorkerFinanceRequestRow>(
`
SELECT *
FROM worker_finance_requests
WHERE id = $1
LIMIT 1
FOR UPDATE
`,
[input.requestId],
)
const current = currentResult.rows[0] || null
if (!current) {
return {
request: null,
failureReason: null,
}
}
if (current.status !== 'pending') {
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: 'request_not_pending',
}
}
if (input.status === 'approved') {
await ensureWorkerWalletWithClient(client, Number(current.worker_id), input.now)
const wallet = await getWorkerWalletWithClient(client, Number(current.worker_id))
const availableAmount = Number(wallet?.available_amount || 0)
const frozenAmount = Number(wallet?.frozen_deposit_amount || 0)
const amount = Number(current.amount || 0)
const note =
input.reviewedNote ||
(current.request_type === 'withdraw' ? '提现申请审核通过' : '充值申请审核通过')
if (current.request_type === 'withdraw') {
if (availableAmount < amount) {
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: 'withdraw_insufficient',
}
}
const nextAvailable = availableAmount - amount
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, updated_at = $2
WHERE worker_id = $3
`,
[nextAvailable, input.now, Number(current.worker_id)],
)
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, 'withdraw_paid', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
`,
[
Number(current.worker_id),
-amount,
nextAvailable,
frozenAmount,
note,
JSON.stringify({
requestId: Number(current.id),
requestType: current.request_type,
source: 'admin_finance_review',
}),
input.now,
],
)
} else {
const nextAvailable = availableAmount + amount
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, amount, input.now, Number(current.worker_id)],
)
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)
`,
[
Number(current.worker_id),
amount,
nextAvailable,
frozenAmount,
note,
JSON.stringify({
requestId: Number(current.id),
requestType: current.request_type,
source: 'admin_finance_review',
}),
input.now,
],
)
}
}
await client.query(
`
UPDATE worker_finance_requests
SET
status = $1,
reviewed_note = $2,
reviewed_at = $3,
updated_at = $3
WHERE id = $4
`,
[input.status, input.reviewedNote, input.now, input.requestId],
)
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: null,
}
})
}
export async function getWorkerFinanceRequestSummary(workerId: number | string) {
const result = await query<{
pending_withdraw_amount: number
approved_withdraw_amount: number
pending_recharge_amount: number
}>(
`
SELECT
COALESCE(
SUM(CASE WHEN request_type = 'withdraw' AND status = 'pending' THEN amount ELSE 0 END),
0
)::int AS pending_withdraw_amount,
COALESCE(
SUM(CASE WHEN request_type = 'withdraw' AND status = 'approved' THEN amount ELSE 0 END),
0
)::int AS approved_withdraw_amount,
COALESCE(
SUM(CASE WHEN request_type = 'recharge' AND status = 'pending' THEN amount ELSE 0 END),
0
)::int AS pending_recharge_amount
FROM worker_finance_requests
WHERE worker_id = $1
`,
[Number(workerId)],
)
return {
pendingWithdrawAmount: Number(result.rows[0]?.pending_withdraw_amount || 0),
approvedWithdrawAmount: Number(result.rows[0]?.approved_withdraw_amount || 0),
pendingRechargeAmount: Number(result.rows[0]?.pending_recharge_amount || 0),
}
}
export async function countWorkerAcceptedOrders(workerId: number | string): Promise<number> {
const result = await query<{ total: number }>(
`
SELECT
(SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted')
+
COALESCE(
(SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'),
0
) AS total
`,
[Number(workerId)],
)
return Number(result.rows[0]?.total || 0)
}
export async function maybeUpgradeWorkerLevelWithClient(
client: PoolClient,
workerId: number,
now: string,
): Promise<WorkerUserRow | null> {
const acceptedResult = await client.query<{ total: number }>(
`
SELECT
(SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted')
+
COALESCE(
(SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'),
0
) AS total
`,
[workerId],
)
const acceptedCount = Number(acceptedResult.rows[0]?.total || 0)
const nextLevelResult = await client.query<{ id: number }>(
`
SELECT id
FROM worker_levels
WHERE status = 'active'
AND COALESCE((permission_json->>'upgradeThreshold')::int, 0) <= $1
ORDER BY COALESCE((permission_json->>'upgradeThreshold')::int, 0) DESC, sort_order ASC
LIMIT 1
`,
[acceptedCount],
)
const nextLevelId = Number(nextLevelResult.rows[0]?.id || 0)
if (!nextLevelId) return null
const current = await client.query<{ id: number; level_id: number | null }>(
'SELECT id, level_id FROM worker_users WHERE id = $1 LIMIT 1',
[workerId],
)
const currentRow = current.rows[0]
if (!currentRow || Number(currentRow.level_id || 0) === nextLevelId) return null
await client.query(
'UPDATE worker_users SET level_id = $1, updated_at = $2 WHERE id = $3',
[nextLevelId, now, workerId],
)
await client.query(
`
INSERT INTO worker_level_logs (
worker_id, from_level_id, to_level_id, reason, payload_json, created_at
) VALUES ($1, $2, $3, 'auto', $4::jsonb, $5)
`,
[
workerId,
currentRow.level_id || null,
nextLevelId,
JSON.stringify({ acceptedCount }),
now,
],
)
return getWorkerUserByIdWithClient(client, workerId)
}
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 getWorkerFinanceRequestByIdWithClient(
client: PoolClient,
requestId: number | string,
): Promise<WorkerFinanceRequestRow | null> {
const result = await client.query<WorkerFinanceRequestRow>(
`${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`,
[Number(requestId)],
)
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 buildWorkerWalletLedgerWhere({
workerId = 0,
ledgerType = '',
}: WalletLedgerListInput) {
const filters: string[] = []
const params: unknown[] = []
if (workerId) {
params.push(workerId)
filters.push(`wwl.worker_id = $${params.length}`)
}
if (ledgerType) {
params.push(ledgerType)
filters.push(`wwl.ledger_type = $${params.length}`)
}
return {
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
params,
}
}
function buildWorkerFinanceRequestWhere({
workerId = 0,
status = '',
requestType = '',
keyword = '',
}: FinanceRequestListInput) {
const filters: string[] = []
const params: unknown[] = []
if (workerId) {
params.push(workerId)
filters.push(`wfr.worker_id = $${params.length}`)
}
if (status) {
params.push(status)
filters.push(`wfr.status = $${params.length}`)
}
if (requestType) {
params.push(requestType)
filters.push(`wfr.request_type = $${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}
OR wfr.account_name ILIKE $${params.length}
OR wfr.account_no ILIKE $${params.length})`,
)
}
return {
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
params,
}
}
@@ -28,7 +28,7 @@ import {
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
updateAdminWorkOrderSharing,
} from '../../services/worker-platform/worker-platform-service.js'
} from '../../services/worker-platform/index.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
const router = Router()
+1 -1
View File
@@ -4,7 +4,7 @@ import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middlewa
import {
collectLookupWorkOrder,
collectSubmitWorkOrder,
} from '../services/worker-platform/worker-platform-service.js'
} from '../services/worker-platform/index.js'
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
const router = Router()
+1 -1
View File
@@ -20,7 +20,7 @@ import {
logoutWorkerSession,
registerWorker,
submitWorkerOrderAcceptance,
} from '../services/worker-platform/worker-platform-service.js'
} from '../services/worker-platform/index.js'
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
import {
extractOptionalWorkerBearerToken,
+1 -1
View File
@@ -4,7 +4,7 @@ import {
requireActiveWorkerSession,
verifyWorkerSessionToken,
type WorkerSession,
} from '../../services/worker-platform/worker-platform-service.js'
} from '../../services/worker-platform/index.js'
import { createHttpError, sendRouteError } from '../../utils/http.js'
export async function requireWorkerSession(req: Request, res: Response, next: NextFunction): Promise<void> {
@@ -10,7 +10,7 @@ import {
resolveOrderFulfillmentReadiness,
syncOrderFulfillmentAttachments,
} from '../fulfillment/order-fulfillment-readiness-service.js'
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/worker-platform-service.js'
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/index.js'
import { nowIso } from '../../utils/time.js'
import { logIntegration } from '../../utils/logger.js'
import { createHttpError } from '../../utils/http.js'
@@ -11,7 +11,7 @@ import {
} from '../../../repositories/order-item-repo.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { upsertOrderFromSource } from '../../order/order-service.js'
import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/worker-platform-service.js'
import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/index.js'
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
import { asJsonObject, type JsonObject } from '../../../types/json.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
@@ -0,0 +1,955 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
import {
acceptWorkOrderAndSettle,
addWorkerWalletCredit,
countWorkerAcceptedOrders,
countWorkerActiveOrders,
countWorkCategoryUsages,
countWorkerLevelUsages,
countWorkOrderPendingSharingSubmissions,
cancelWorkerWorkOrder,
countWorkerCancellationsSince,
createWorkOrder,
createWorkOrderEvent,
createWorkerFinanceRequest,
createWorkerUser,
deleteWorkCategory,
deleteWorkerLevel,
getWorkerFinanceRequestById,
getWorkerFinanceRequestSummary,
getWorkCategoryById,
getWorkCategoryByKey,
getWorkerLevelById,
getWorkerLevelByKey,
getWorkerUserByInviteCode,
getWorkerUserById,
getWorkerUserByUsername,
getWorkOrderById,
getWorkOrderByOrderItemId,
getWorkOrderShare,
grabWorkOrder,
incrementWorkerSessionVersion,
joinWorkOrderShare,
listAllWorkCategories,
listPendingMaterialWorkOrdersByPlatformOrderId,
listWorkerFinanceRequests,
listWorkerSharesByWorker,
listWorkerWalletLedgers,
listWorkCategories,
listWorkOrders,
listWorkProductRules,
listWorkerLevels,
listWorkerUsers,
listWorkOrderShares,
listWorkOrderSharesByOrderIds,
resolveProblemWorkOrder,
reviewWorkerFinanceRequest,
submitWorkOrderShareAcceptance,
updateWorkOrder,
updateWorkerPassword,
updateWorkerUser,
upsertWorkCategory,
upsertWorkProductRule,
upsertWorkerLevel,
type WorkCategoryRow,
type WorkerFinanceRequestRow,
type WorkOrderRow,
type WorkProductRuleRow,
type WorkerLevelRow,
type WorkOrderShareRow,
type WorkerWalletLedgerRow,
type WorkerUserRow,
type GrabWorkOrderFailureReason,
type ProblemWorkOrderResolutionAction,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.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'
import {
normalizeStoredFileUrl,
refreshStoredFileUrl,
refreshUploadedFileUrls,
} from '../file-storage/file-storage-service.js'
import {
getWorkerFinanceConfig,
saveWorkerFinanceConfig,
} from './worker-finance-config-service.js'
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js'
import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker } from './worker-service.js'
export async function listAdminWorkerLevels() {
await ensureWorkerPlatformDefaults()
return { items: (await listWorkerLevels()).map(mapWorkerLevel) }
}
export async function listAdminWorkCategories() {
await ensureWorkerPlatformDefaults()
return { items: (await listAllWorkCategories()).map(mapWorkCategory) }
}
export async function saveAdminWorkCategory(payload: JsonObject = {}) {
const now = nowIso()
const categoryKey = normalizeSlugKey(
payload.categoryKey || payload.category_key || payload.name,
DEFAULT_CATEGORY_KEY,
)
const name = String(payload.name || '').trim() || '默认分类'
const category = await upsertWorkCategory({
categoryKey,
name,
sortOrder: normalizeInteger(payload.sortOrder, 100),
status: normalizeEnabledStatus(payload.status),
now,
})
return { category: mapWorkCategory(category) }
}
export async function deleteAdminWorkCategory(categoryId: number | string) {
const category = await getWorkCategoryById(Number(categoryId))
if (!category) {
throw createHttpError('分类不存在', {
statusCode: 404,
errorCode: 'work_category_not_found',
})
}
const usages = await countWorkCategoryUsages(Number(categoryId))
if (usages.workOrderCount > 0 || usages.productRuleCount > 0) {
throw createHttpError(
`该分类已被 ${usages.workOrderCount} 个工单和 ${usages.productRuleCount} 条物品规则使用,无法删除,请先停用`,
{
statusCode: 409,
errorCode: 'work_category_in_use',
},
)
}
const { deleted } = await deleteWorkCategory(Number(categoryId))
return { deleted }
}
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() || DEFAULT_LEVEL_NAME
const permissions = {
depositFreeAmount: normalizeAmountFen(
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
0,
),
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0),
}
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 deleteAdminWorkerLevel(levelId: number | string) {
const level = await getWorkerLevelById(Number(levelId))
if (!level) {
throw createHttpError('等级不存在', {
statusCode: 404,
errorCode: 'worker_level_not_found',
})
}
if (level.level_key === DEFAULT_LEVEL_KEY) {
throw createHttpError('默认等级(VIP1)不可删除', {
statusCode: 409,
errorCode: 'worker_level_default_protected',
})
}
const usageCount = await countWorkerLevelUsages(Number(levelId))
if (usageCount > 0) {
throw createHttpError(`该等级正在被 ${usageCount} 名打手使用,请先调整打手等级后再删除`, {
statusCode: 409,
errorCode: 'worker_level_in_use',
})
}
const { deleted } = await deleteWorkerLevel(Number(levelId))
return { deleted }
}
export async function listAdminWorkProductRules(query: JsonObject = {}) {
await ensureWorkerPlatformDefaults()
const enabledValue = String(query.enabled ?? '').trim()
const enabled = enabledValue
? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase())
: null
const items = await listWorkProductRules({
enabled,
keyword: String(query.keyword || '').trim(),
})
return { items: items.map(mapWorkProductRule) }
}
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
const defaults = await ensureWorkerPlatformDefaults()
const productName = String(
payload.productName || payload.product_name || payload.skuName || '',
).trim()
const skuCode = String(payload.skuCode || payload.sku_code || '').trim()
if (!productName && !skuCode) {
throw createHttpError('请填写商品名或 SKU', {
statusCode: 400,
errorCode: 'work_product_rule_target_required',
})
}
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
const sharingEnabled = normalizeBoolean(
payload.sharingEnabled ?? payload.sharing_enabled,
false,
)
const sharingUnitReward = normalizeAmountFen(
payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
0,
)
const sharingTotalAmount = normalizeAmountFen(
payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
0,
)
const sharingTotalQuantity = resolveSharingQuantity(
sharingTotalAmount,
normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1),
sharingUnitReward,
)
const resolvedSharingTotalAmount =
sharingTotalAmount > 0 ? sharingTotalAmount : sharingUnitReward * sharingTotalQuantity
if (sharingEnabled) {
if (resolvedSharingTotalAmount <= 0) {
throw createHttpError('启用拼单时请填写拼单总价或单价', {
statusCode: 400,
errorCode: 'work_product_rule_sharing_amount_invalid',
})
}
} else if (rewardAmount <= 0) {
throw createHttpError('接单金额必须大于 0', {
statusCode: 400,
errorCode: 'work_product_rule_reward_invalid',
})
}
const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount
const depositThresholdAmount = normalizeAmountFen(
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
)
const requiredDepositAmount =
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
? finalRewardAmount > depositThresholdAmount
? finalRewardAmount
: 0
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
const fields = normalizeRequirementFieldsFromPayload(payload)
const rule = await upsertWorkProductRule({
ruleKey: normalizeSlugKey(
payload.ruleKey || payload.rule_key || skuCode || productName,
`rule-${Date.now()}`,
),
provider: String(payload.provider || '').trim(),
platform: String(payload.platform || '').trim(),
shopId: String(payload.shopId || payload.shop_id || '').trim(),
skuCode,
productName,
matchType: normalizeMatchType(payload.matchType || payload.match_type),
categoryId:
normalizeOptionalId(payload.categoryId || payload.category_id) ||
defaults.category?.id ||
null,
enabled: normalizeBoolean(payload.enabled, true),
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
rewardAmount: finalRewardAmount,
requiredDepositAmount,
depositThresholdAmount,
sharingEnabled,
sharingTotalQuantity,
sharingUnitReward,
requirementJson: JSON.stringify({ fields }),
sortOrder: normalizeInteger(payload.sortOrder, 100),
now: nowIso(),
})
return { rule: mapWorkProductRule(rule) }
}
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 requestedLevelId = normalizeOptionalId(payload.levelId)
let levelId = worker.level_id || null
if (requestedLevelId) {
const level = await getWorkerLevelById(requestedLevelId)
if (!level) {
throw createHttpError('接单等级不存在', {
statusCode: 404,
errorCode: 'worker_level_not_found',
})
}
levelId = Number(level.id)
}
if (!levelId) {
levelId = (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 getAdminWorkerFinanceConfig() {
return getWorkerFinanceConfig()
}
export async function saveAdminWorkerFinanceConfig(payload: JsonObject = {}) {
const config = await saveWorkerFinanceConfig(payload)
return config
}
export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const status = normalizeFinanceRequestStatus(query.status)
const requestType = normalizeFinanceRequestType(query.requestType)
const keyword = String(query.keyword || '').trim()
const { items, total } = await listWorkerFinanceRequests({
page,
pageSize,
status,
requestType,
keyword,
})
return {
items: items.map(mapFinanceRequest),
pagination: { page, pageSize, total },
}
}
export async function reviewAdminWorkerFinanceRequest(
requestId: number | string,
payload: JsonObject = {},
) {
const current = await getWorkerFinanceRequestById(requestId)
if (!current) {
throw createHttpError('资金申请不存在', {
statusCode: 404,
errorCode: 'worker_finance_request_not_found',
})
}
const status = normalizeAdminFinanceReviewStatus(payload.status)
const reviewedNote = String(payload.reviewedNote || payload.note || '').trim()
const reviewed = await reviewWorkerFinanceRequest({
requestId: Number(current.id),
status,
reviewedNote,
now: nowIso(),
})
if (reviewed.failureReason === 'request_not_pending') {
throw createHttpError('该申请已处理,请刷新后重试', {
statusCode: 409,
errorCode: 'worker_finance_request_reviewed',
})
}
if (reviewed.failureReason === 'withdraw_insufficient') {
throw createHttpError('打手余额不足,暂时无法通过该提现申请', {
statusCode: 409,
errorCode: 'worker_finance_request_withdraw_insufficient',
})
}
if (!reviewed.request) {
throw createHttpError('资金申请不存在', {
statusCode: 404,
errorCode: 'worker_finance_request_not_found',
})
}
return {
request: mapFinanceRequest(reviewed.request),
}
}
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(),
})
const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id)))
const sharesByOrderId = new Map<number, WorkOrderShareRow[]>()
for (const share of shares) {
const orderId = Number(share.work_order_id)
const bucket = sharesByOrderId.get(orderId) || []
bucket.push(share)
sharesByOrderId.set(orderId, bucket)
}
return {
items: items.map((item) => {
const orderShares = sharesByOrderId.get(Number(item.id)) || []
const joinedQuantity = orderShares
.filter((share) => share.status !== 'cancelled')
.reduce((sum, share) => sum + Number(share.quantity || 0), 0)
const pendingSubmissionCount = orderShares.filter(
(share) => share.status === 'joined',
).length
return {
...mapWorkOrderAdmin(item),
sharingProgress: {
joinedQuantity,
pendingSubmissionCount,
},
}
}),
pagination: { page, pageSize, total },
}
}
export async function getAdminWorkOrderSharing(workOrderId: number | string) {
const workOrder = await getRequiredWorkOrder(workOrderId)
const shares = await listWorkOrderShares(workOrder.id)
return {
order: mapWorkOrderAdmin(workOrder),
shares: shares.map(mapWorkOrderShare),
}
}
export async function updateAdminWorkOrderSharing(
workOrderId: number | string,
payload: JsonObject = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN].includes(workOrder.status as never)) {
throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', {
statusCode: 409,
errorCode: 'work_order_sharing_config_status_invalid',
})
}
const enabled = normalizeBoolean(
payload.enabled ?? payload.sharingEnabled ?? payload.sharing_enabled,
false,
)
const unitReward = normalizeAmountFen(
payload.unitReward ?? payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
0,
)
const totalAmount = normalizeAmountFen(
payload.totalAmount ?? payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
0,
)
const totalQuantity = resolveSharingQuantity(
totalAmount,
normalizePositiveInteger(
payload.totalQuantity ?? payload.sharingTotalQuantity ?? payload.sharing_total_quantity,
1,
),
unitReward,
)
const resolvedTotalAmount = totalAmount > 0 ? totalAmount : unitReward * totalQuantity
const now = nowIso()
const updated = await updateWorkOrder(workOrder.id, {
sharing_enabled: enabled,
sharing_total_quantity: totalQuantity,
sharing_unit_reward: unitReward,
reward_amount: enabled ? resolvedTotalAmount : workOrder.reward_amount,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: '',
eventType: 'sharing_config_updated',
fromStatus: workOrder.status,
toStatus: updated?.status || workOrder.status,
payloadJson: JSON.stringify({
enabled,
totalQuantity,
unitReward,
totalAmount: resolvedTotalAmount,
}),
now,
})
return { order: mapWorkOrderAdmin(updated || workOrder) }
}
function resolveSharingQuantity(
totalAmount: number,
fallbackQuantity: number,
unitReward: number,
): number {
if (totalAmount > 0 && unitReward > 0) {
return Math.max(1, Math.round(totalAmount / unitReward))
}
return Math.max(1, fallbackQuantity)
}
export async function submitAdminWorkOrderMaterial(
workOrderId: number | string,
payload: JsonObject = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (
![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED].includes(
workOrder.status as never,
)
) {
throw createHttpError('当前状态不能补充资料', {
statusCode: 409,
errorCode: 'work_order_material_status_invalid',
})
}
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,
source: 'admin',
},
}
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: 'admin',
actorId: '',
eventType: 'material_completed',
fromStatus: workOrder.status,
toStatus: nextStatus,
payloadJson: JSON.stringify({ fields: submittedFields, complete }),
now,
})
return { order: mapWorkOrderAdmin(updated || workOrder), complete }
}
export async function syncAdminWorkerOrdersFromSource(payload: JsonObject = {}) {
const order = await resolveSourceOrder(payload)
const orderItems = await listOrderItemsByOrderId(order.id)
const result = await syncWorkerOrdersForSourceOrder(order, orderItems, {
source: 'admin_manual_sync',
autoOnly: false,
})
return result
}
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 unpublishAdminWorkOrder(workOrderId: number | string) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
throw createHttpError('只有待抢单且未被接走的订单可以下架', {
statusCode: 409,
errorCode: 'work_order_unpublish_status_invalid',
})
}
const now = nowIso()
const updated = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.UNASSIGNED,
published_at: null,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: '',
eventType: 'unpublished',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
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 resolveAdminProblemWorkOrder(
workOrderId: number | string,
payload: JsonObject = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (workOrder.status !== WORK_ORDER_STATUS.PROBLEM) {
throw createHttpError('只有问题单可以执行处置', {
statusCode: 409,
errorCode: 'work_order_problem_resolution_status_invalid',
})
}
const action = normalizeProblemResolutionAction(payload.action)
const note = String(payload.note || '').trim()
if (!note) {
throw createHttpError('请填写处置备注', {
statusCode: 400,
errorCode: 'work_order_problem_resolution_note_required',
})
}
const updated = await resolveProblemWorkOrder({
workOrderId: workOrder.id,
action,
note,
now: nowIso(),
})
if (!updated) {
throw createHttpError('问题单处置失败,订单状态可能已变化', {
statusCode: 409,
errorCode: 'work_order_problem_resolution_conflict',
})
}
return { order: mapWorkOrderAdmin(updated) }
}
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 { order: updated, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
now,
})
if (failureReason === 'sharing_pending_submissions') {
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
statusCode: 409,
errorCode: 'work_order_sharing_pending_submissions',
})
}
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,
}
}
export async function syncWorkerOrdersForSourceOrder(
order: OrderRow,
orderItems: OrderItemRow[],
options: { source?: string; autoOnly?: boolean } = {},
) {
const rules = await listWorkProductRules({ enabled: true })
const created: WorkOrderRow[] = []
const skipped: Array<{ orderItemId: number; reason: string }> = []
for (const item of orderItems) {
const rule = resolveMatchingProductRule(order, item, rules)
if (!rule) {
skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' })
continue
}
if (options.autoOnly && !rule.auto_create) {
skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' })
continue
}
if (await getWorkOrderByOrderItemId(item.id)) {
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
continue
}
const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
const rewardAmount = Number(rule.reward_amount || 0)
const depositThresholdAmount = Number(
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
)
const requiredDepositAmount =
Number(rule.required_deposit_amount || 0) ||
(rewardAmount > depositThresholdAmount ? rewardAmount : 0)
const now = nowIso()
const materialComplete = fields.every((field) => !field.required)
const workOrder = await createWorkOrder({
workOrderNo: randomId('WO'),
orderId: Number(order.id),
orderItemId: Number(item.id),
taskId: null,
platformOrderId: order.platform_order_id,
productName: item.sku_name || item.sku_code,
categoryId: rule.category_id,
status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL,
rewardAmount,
requiredDepositAmount,
depositThresholdAmount,
sharingEnabled: rule.sharing_enabled === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
materialJson: JSON.stringify({
source: {
orderId: Number(order.id),
orderItemId: Number(item.id),
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
skuCode: item.sku_code,
skuName: item.sku_name,
quantity: Number(item.quantity || 1),
syncSource: options.source || 'source_order',
},
}),
requirementJson: JSON.stringify({ fields }),
now,
})
if (workOrder) {
created.push(workOrder)
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'system',
actorId: options.source || 'source_order',
eventType: 'source_synced',
fromStatus: '',
toStatus: workOrder.status,
payloadJson: JSON.stringify({
ruleKey: rule.rule_key,
orderId: order.id,
orderItemId: item.id,
}),
now,
})
}
}
return {
created: created.map(mapWorkOrderAdmin),
skipped,
createdCount: created.length,
skippedCount: skipped.length,
}
}
async function resolveSourceOrder(payload: JsonObject): Promise<OrderRow> {
const orderId = normalizeOptionalId(payload.orderId || payload.sourceOrderId)
if (orderId) {
const order = await getOrderById(orderId)
if (order) {
return order
}
throw createHttpError('源订单不存在', {
statusCode: 404,
errorCode: 'source_order_not_found',
})
}
const platformOrderId = String(payload.platformOrderId || payload.orderNo || '').trim()
if (!platformOrderId) {
throw createHttpError('请填写源订单号', {
statusCode: 400,
errorCode: 'source_order_no_required',
})
}
const order = await findLatestOrderByAnyPlatformOrderId(platformOrderId)
if (!order) {
throw createHttpError('未找到源订单,请确认订单号是否正确', {
statusCode: 404,
errorCode: 'source_order_not_found',
})
}
return order
}
@@ -0,0 +1,3 @@
export * from './mappers.js'
export * from './worker-service.js'
export * from './admin-service.js'
@@ -0,0 +1,870 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
import {
acceptWorkOrderAndSettle,
addWorkerWalletCredit,
countWorkerAcceptedOrders,
countWorkerActiveOrders,
countWorkCategoryUsages,
countWorkerLevelUsages,
countWorkOrderPendingSharingSubmissions,
cancelWorkerWorkOrder,
countWorkerCancellationsSince,
createWorkOrder,
createWorkOrderEvent,
createWorkerFinanceRequest,
createWorkerUser,
deleteWorkCategory,
deleteWorkerLevel,
getWorkerFinanceRequestById,
getWorkerFinanceRequestSummary,
getWorkCategoryById,
getWorkCategoryByKey,
getWorkerLevelById,
getWorkerLevelByKey,
getWorkerUserByInviteCode,
getWorkerUserById,
getWorkerUserByUsername,
getWorkOrderById,
getWorkOrderByOrderItemId,
getWorkOrderShare,
grabWorkOrder,
incrementWorkerSessionVersion,
joinWorkOrderShare,
listAllWorkCategories,
listPendingMaterialWorkOrdersByPlatformOrderId,
listWorkerFinanceRequests,
listWorkerSharesByWorker,
listWorkerWalletLedgers,
listWorkCategories,
listWorkOrders,
listWorkProductRules,
listWorkerLevels,
listWorkerUsers,
listWorkOrderShares,
listWorkOrderSharesByOrderIds,
resolveProblemWorkOrder,
reviewWorkerFinanceRequest,
submitWorkOrderShareAcceptance,
updateWorkOrder,
updateWorkerPassword,
updateWorkerUser,
upsertWorkCategory,
upsertWorkProductRule,
upsertWorkerLevel,
type WorkCategoryRow,
type WorkerFinanceRequestRow,
type WorkOrderRow,
type WorkProductRuleRow,
type WorkerLevelRow,
type WorkOrderShareRow,
type WorkerWalletLedgerRow,
type WorkerUserRow,
type GrabWorkOrderFailureReason,
type ProblemWorkOrderResolutionAction,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.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'
import {
normalizeStoredFileUrl,
refreshStoredFileUrl,
refreshUploadedFileUrls,
} from '../file-storage/file-storage-service.js'
import {
getWorkerFinanceConfig,
saveWorkerFinanceConfig,
} from './worker-finance-config-service.js'
export const DEFAULT_LEVEL_KEY = 'vip1'
export const DEFAULT_LEVEL_NAME = 'VIP1'
export const DEFAULT_CATEGORY_KEY = 'default'
export const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
export const INVITE_CODE_LENGTH = 8
export const INVITE_CODE_RETRY_LIMIT = 5
export const WORKER_CANCEL_LIMIT_PER_WINDOW = 5
export const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000
export 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),
}
}
export function ensureWorkerAuthConfigured() {
if (String(runtimeConfig.admin?.sessionSecret || '').trim()) {
return
}
throw createHttpError('接单登录态签名密钥未配置,请设置 ADMIN_SESSION_SECRET', {
statusCode: 503,
errorCode: 'worker_auth_not_configured',
})
}
export 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}`
}
export 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)
}
export function signWorkerPayload(encodedPayload: string): string {
return crypto
.createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || ''))
.update(`worker:${encodedPayload}`)
.digest('base64url')
}
export 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)
}
export function resolveWorkerPermissions(worker: WorkerUserRow) {
const permission = safeParseJson(worker.level_permission_json)
return {
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
}
}
export function resolveFreezeDepositAmount(
workOrder: WorkOrderRow,
permissions: { depositFreeAmount: number },
) {
return Math.max(
0,
Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0),
)
}
export 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, 1),
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
createdAt: level.created_at,
updatedAt: level.updated_at,
}
}
export function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
if (!category) return null
return {
categoryId: Number(category.id),
categoryKey: category.category_key,
name: category.name,
sortOrder: Number(category.sort_order || 100),
status: category.status,
createdAt: category.created_at,
updatedAt: category.updated_at,
}
}
export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
if (!rule) return null
const requirement = safeParseJson(rule.requirement_json)
return {
ruleId: Number(rule.id),
ruleKey: rule.rule_key,
provider: rule.provider || '',
platform: rule.platform || '',
shopId: rule.shop_id || '',
skuCode: rule.sku_code || '',
productName: rule.product_name || '',
matchType: rule.match_type || 'contains',
categoryId: rule.category_id ? Number(rule.category_id) : null,
categoryName: rule.category_name || '',
enabled: Boolean(rule.enabled),
autoCreate: Boolean(rule.auto_create),
rewardAmount: Number(rule.reward_amount || 0),
requiredDepositAmount: Number(rule.required_deposit_amount || 0),
depositThresholdAmount: Number(
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
),
sharing: {
enabled: rule.sharing_enabled === true,
totalQuantity: Number(rule.sharing_total_quantity || 1),
unitReward: Number(rule.sharing_unit_reward || 0),
},
requirement: {
fields: normalizeRequirementFields(requirement.fields),
},
sortOrder: Number(rule.sort_order || 100),
createdAt: rule.created_at,
updatedAt: rule.updated_at,
}
}
export 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 || '',
inviteCode: worker.invite_code || '',
inviter: worker.inviter_id
? {
workerId: Number(worker.inviter_id),
username: worker.inviter_username || '',
displayName: worker.inviter_display_name || worker.inviter_username || '',
}
: null,
level: worker.level_id
? {
levelId: Number(worker.level_id),
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),
totalCreditedAmount: Number(worker.total_credited_amount || 0),
totalSettledAmount: Number(worker.total_settled_amount || 0),
},
createdAt: worker.created_at,
updatedAt: worker.updated_at,
reviewedAt: worker.reviewed_at,
}
}
export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) {
if (!ledger) return null
return {
ledgerId: Number(ledger.id),
ledgerType: ledger.ledger_type,
amount: Number(ledger.amount || 0),
balanceAfter: Number(ledger.balance_after || 0),
frozenAfter: Number(ledger.frozen_after || 0),
relatedWorkOrderId: ledger.related_work_order_id
? Number(ledger.related_work_order_id)
: null,
auditStatus: ledger.audit_status || '',
note: ledger.note || '',
payload: safeParseJson(ledger.payload_json),
createdAt: ledger.created_at,
}
}
export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) {
if (!request) return null
return {
requestId: Number(request.id),
requestType: request.request_type || '',
status: request.status || '',
amount: Number(request.amount || 0),
accountChannel: request.account_channel || '',
accountName: request.account_name || '',
accountNo: request.account_no || '',
note: request.note || '',
reviewedNote: request.reviewed_note || '',
payload: safeParseJson(request.payload_json),
createdAt: request.created_at,
updatedAt: request.updated_at,
reviewedAt: request.reviewed_at,
worker: request.worker_id
? {
workerId: Number(request.worker_id),
username: request.worker_username || '',
displayName: request.worker_display_name || request.worker_username || '',
phone: request.worker_phone || '',
}
: null,
}
}
export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
return {
workOrderId: Number(workOrder.id),
orderId: workOrder.order_id ? Number(workOrder.order_id) : null,
orderItemId: workOrder.order_item_id ? Number(workOrder.order_item_id) : null,
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),
sharing: {
enabled: workOrder.sharing_enabled === true,
totalQuantity: Number(workOrder.sharing_total_quantity || 1),
unitReward: Number(workOrder.sharing_unit_reward || 0),
},
depositThresholdAmount: Number(
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
),
material: safeParseJson(workOrder.material_json),
requirement: safeParseJson(workOrder.requirement_json),
acceptance: mapAcceptanceForResponse(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,
}
}
export function mapWorkOrderForWorker(
workOrder: WorkOrderRow,
permissions: { depositFreeAmount: number },
myShare?: WorkOrderShareRow | null,
) {
const mapped = mapWorkOrderAdmin(workOrder)
const {
requiredDepositAmount,
depositThresholdAmount,
worker: _worker,
orderId: _orderId,
orderItemId: _orderItemId,
...visibleOrder
} = mapped
return {
...visibleOrder,
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
myShare: myShare ? mapWorkOrderShare(myShare) : null,
}
}
export function mapWorkOrderShare(share: WorkOrderShareRow) {
return {
shareId: Number(share.id),
workOrderId: Number(share.work_order_id),
workerId: Number(share.worker_id),
quantity: Number(share.quantity || 0),
unitReward: Number(share.unit_reward || 0),
shareReward: Number(share.share_reward || 0),
shareDeposit: Number(share.share_deposit || 0),
status: share.status,
acceptance: safeParseJson(share.acceptance_json),
submittedAt: share.submitted_at,
acceptedAt: share.accepted_at,
createdAt: share.created_at,
updatedAt: share.updated_at,
worker: share.worker_id
? {
workerId: Number(share.worker_id),
username: share.worker_username || '',
displayName: share.worker_display_name || share.worker_username || '',
}
: null,
}
}
export function mapWorkOrderPublic(workOrder: WorkOrderRow) {
return {
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
platformOrderId: workOrder.platform_order_id,
productName: workOrder.product_name,
status: workOrder.status,
}
}
export function mapCollectLookupOrder(workOrder: WorkOrderRow) {
return {
...mapWorkOrderPublic(workOrder),
fields: resolveRequirementFields(workOrder),
values: resolveCollectSubmittedFieldValues(workOrder),
}
}
export 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),
}
}
export function resolveRequirementFields(workOrder: WorkOrderRow) {
return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields)
}
export 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: '测试角色' },
]
}
export function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
const directFields = Array.isArray(payload.fields) ? payload.fields : null
const requirement =
payload.requirement &&
typeof payload.requirement === 'object' &&
!Array.isArray(payload.requirement)
? (payload.requirement as JsonObject)
: {}
if (directFields) {
return normalizeRequirementFields(directFields)
}
if (Array.isArray(requirement.fields)) {
return normalizeRequirementFields(requirement.fields)
}
const textFields = normalizeRequirementFieldsText(
payload.fieldsText || payload.requiredFieldsText,
)
return textFields.length > 0 ? textFields : normalizeRequirementFields([])
}
export function normalizeRequirementFieldsText(value: unknown) {
return String(value || '')
.split(/\r?\n/)
.map((line, index) => {
const text = line.trim()
if (!text) return null
const delimiter = text.includes('') ? '' : ':'
if (text.includes(delimiter)) {
const [rawKey, ...labelParts] = text.split(delimiter)
const key = String(rawKey || '').trim()
if (!key) return null
return {
key,
label: labelParts.join(delimiter).trim() || key,
required: true,
mockValue: '',
}
}
return {
key: `field${index + 1}`,
label: text,
required: true,
mockValue: '',
}
})
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
Boolean(item),
)
}
export function resolveMatchingProductRule(
order: OrderRow,
item: OrderItemRow,
rules: WorkProductRuleRow[],
) {
return (
rules.find((rule) => {
if (!matchesOptionalText(rule.provider, order.provider)) return false
if (!matchesOptionalText(rule.platform, order.platform)) return false
if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false
const skuCode = String(item.sku_code || '')
.trim()
.toLowerCase()
const skuName = String(item.sku_name || '')
.trim()
.toLowerCase()
const ruleSkuCode = String(rule.sku_code || '')
.trim()
.toLowerCase()
const ruleProductName = String(rule.product_name || '')
.trim()
.toLowerCase()
if (ruleSkuCode && ruleSkuCode !== skuCode) {
return false
}
if (!ruleProductName) {
return Boolean(ruleSkuCode)
}
if (rule.match_type === 'exact') {
return ruleProductName === skuName || ruleProductName === skuCode
}
return skuName.includes(ruleProductName) || skuCode.includes(ruleProductName)
}) || null
)
}
export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) {
const ruleText = String(ruleValue || '')
.trim()
.toLowerCase()
if (!ruleText) {
return true
}
return (
ruleText ===
String(sourceValue || '')
.trim()
.toLowerCase()
)
}
export 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()]),
)
}
export function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.map((item) => String(item || '').trim()).filter(Boolean)
}
export function normalizeUploadedFiles(value: unknown) {
if (!Array.isArray(value)) return []
return value
.map((item) => {
const source =
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
const objectKey = String(source.objectKey || source.object_key || '').trim()
const url = String(source.url || '').trim()
if (!objectKey && !url) return null
return {
objectKey,
url: normalizeStoredFileUrl(url, objectKey),
thumbnailUrl: normalizeStoredFileUrl(
source.thumbnailUrl || source.thumbnail_url || url,
objectKey,
),
mediumUrl: normalizeStoredFileUrl(source.mediumUrl || source.medium_url || url, objectKey),
filename: String(source.filename || '').trim(),
contentType: String(source.contentType || source.content_type || '').trim(),
size: normalizeInteger(source.size, 0),
}
})
.filter(
(
item,
): item is {
objectKey: string
url: string
thumbnailUrl: string
mediumUrl: string
filename: string
contentType: string
size: number
} => Boolean(item),
)
}
export function mapAcceptanceForResponse(value: unknown) {
const acceptance = safeParseJson(value)
const files = normalizeUploadedFiles(acceptance.files).map((file) =>
refreshUploadedFileUrls(file),
)
const imageUrls = normalizeStringArray(acceptance.imageUrls).map((url) =>
refreshStoredFileUrl(url),
)
return {
...acceptance,
files,
imageUrls,
}
}
export function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) {
const material = safeParseJson(workOrder.material_json)
const collect =
material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect)
? safeParseJson(material.collect)
: {}
return normalizeSubmittedFields(collect.fields)
}
export function normalizeReviewStatus(value: unknown) {
const status = String(value || '').trim()
if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status
return 'pending_review'
}
export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
const action = String(value || '').trim()
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
return action as ProblemWorkOrderResolutionAction
}
throw createHttpError('问题单处置方式不正确', {
statusCode: 400,
errorCode: 'work_order_problem_resolution_action_invalid',
})
}
export function throwGrabWorkOrderFailure(failureReason: GrabWorkOrderFailureReason | null): never {
if (failureReason === 'worker_active_order_limit') {
throw createHttpError('已达到当前等级最大同时接单量', {
statusCode: 409,
errorCode: 'worker_active_order_limit',
})
}
if (failureReason === 'worker_deposit_insufficient') {
throw createHttpError('余额不足,无法冻结所需押金', {
statusCode: 409,
errorCode: 'worker_deposit_insufficient',
})
}
throw createHttpError('抢单失败,订单可能已被抢走', {
statusCode: 409,
errorCode: 'work_order_grab_conflict',
})
}
export function normalizeEnabledStatus(value: unknown) {
return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active'
}
export function normalizeWalletLedgerType(value: unknown) {
const ledgerType = String(value || '').trim()
if (!ledgerType) return ''
if (
[
'manual_credit',
'withdraw_paid',
'deposit_freeze',
'deposit_release',
'deposit_deduction',
'reward_settlement',
].includes(ledgerType)
) {
return ledgerType
}
throw createHttpError('钱包流水类型不正确', {
statusCode: 400,
errorCode: 'worker_wallet_ledger_type_invalid',
})
}
export function normalizeFinanceRequestType(value: unknown) {
const requestType = String(value || '').trim()
if (!requestType) return ''
if (['recharge', 'withdraw'].includes(requestType)) {
return requestType
}
throw createHttpError('资金申请类型不正确', {
statusCode: 400,
errorCode: 'worker_finance_request_type_invalid',
})
}
export function normalizeFinanceRequestStatus(value: unknown) {
const status = String(value || '').trim()
if (!status) return ''
if (['pending', 'approved', 'rejected', 'cancelled'].includes(status)) {
return status
}
throw createHttpError('资金申请状态不正确', {
statusCode: 400,
errorCode: 'worker_finance_request_status_invalid',
})
}
export function normalizeAdminFinanceReviewStatus(value: unknown) {
const status = String(value || '').trim()
if (['approved', 'rejected', 'cancelled'].includes(status)) {
return status
}
throw createHttpError('审核状态不正确', {
statusCode: 400,
errorCode: 'worker_finance_review_status_invalid',
})
}
export function normalizeWithdrawChannel(value: unknown) {
const channel = String(value || '').trim()
if (['alipay', 'wechat', 'bank'].includes(channel)) {
return channel
}
throw createHttpError('提现方式不正确', {
statusCode: 400,
errorCode: 'worker_withdraw_channel_invalid',
})
}
export function normalizeMatchType(value: unknown) {
const matchType = String(value || '').trim()
if (matchType === 'exact') return 'exact'
return 'contains'
}
export function normalizeBoolean(value: unknown, fallback: boolean) {
if (typeof value === 'boolean') return value
const text = String(value ?? '')
.trim()
.toLowerCase()
if (!text) return fallback
if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true
if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false
return fallback
}
export function normalizeSlugKey(value: unknown, fallback: string) {
const key = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return key || fallback
}
export function normalizeUsername(value: unknown): string {
return String(value || '')
.trim()
.toLowerCase()
}
export function normalizePassword(value: unknown): string {
return String(value || '').trim()
}
export function validateWorkerUsername(username: string) {
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) {
throw createHttpError('接单账号格式无效,需为 3-32 位字母数字或 ._-', {
statusCode: 400,
errorCode: 'worker_username_invalid',
})
}
}
export function validateWorkerPassword(password: string) {
if (password.length < 6) {
throw createHttpError('接单密码至少 6 位', {
statusCode: 400,
errorCode: 'worker_password_invalid',
})
}
}
export function normalizeSessionVersion(value: unknown): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1
}
export function normalizeOptionalId(value: unknown): number | null {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
export function normalizeInteger(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
export function normalizePositiveInteger(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
export 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)
}
export function normalizeProofFiles(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return []
const files: Array<Record<string, unknown>> = []
for (const item of value) {
if (!item || typeof item !== 'object') continue
const file = item as Record<string, unknown>
const url = String(file.url || '').trim()
if (!url) continue
files.push({
url,
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(),
mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(),
objectKey: String(file.objectKey || file.object_key || '').trim(),
originalFilename: String(
file.originalFilename || file.original_filename || file.name || '',
).trim(),
contentType: String(file.contentType || file.content_type || '').trim(),
sizeBytes: Number(file.sizeBytes || file.size_bytes || 0),
})
if (files.length >= 10) break
}
return files
}
export function isIgnorableWorkerAuthError(error: unknown) {
return IGNORABLE_WORKER_AUTH_ERROR_CODES.has(
String((error as { errorCode?: string })?.errorCode || '').trim(),
)
}
export const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
'worker_auth_required',
'worker_auth_invalid',
'worker_auth_expired',
'worker_auth_user_invalid',
'worker_auth_stale',
])
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { WorkOrderRow } from '../../repositories/worker-platform-repo.js'
import { resolveCollectSubmitTargetWorkOrder } from './worker-platform-service.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
import { resolveCollectSubmitTargetWorkOrder } from './index.js'
function buildWorkOrderRow(
overrides: Partial<WorkOrderRow> = {},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AdminSession } from '../services/admin/admin-auth-service.js'
import type { WorkerSession } from '../services/worker-platform/worker-platform-service.js'
import type { WorkerSession } from '../services/worker-platform/index.js'
declare global {
namespace Express {