删除库存和旧回调管理

This commit is contained in:
yml
2026-05-25 23:07:19 +08:00
parent 90ad866ecc
commit 2c7464b1eb
87 changed files with 94 additions and 5794 deletions
@@ -7,7 +7,6 @@ import {
getAdminUserById,
getAdminUserByUsername,
listAdminUsers,
replaceAdminUserInventoryGroupBindings,
updateAdminUser,
} from '../../repositories/admin-user-repo.js'
import { addHours, nowIso } from '../../utils/time.js'
@@ -23,7 +22,6 @@ export type AdminSession = {
userId: number
username: string
role: AdminRole
allowedInventoryGroups: string[]
expiresAt: string
}
@@ -144,7 +142,6 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
userId: Number(user.id),
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
allowedInventoryGroups: normalizeInventoryGroupCodes(user.inventory_group_codes),
expiresAt,
}
}
@@ -159,7 +156,6 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
userId: session.userId,
username: session.username,
role: session.role,
inventoryGroupCodes: session.allowedInventoryGroups,
},
}
}
@@ -235,22 +231,6 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
}
}
export async function updateManagedAdminUserInventoryGroups(
userId: number | string,
payload: JsonObject = {},
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const inventoryGroupCodes = normalizeInventoryGroupCodes(payload.inventoryGroupCodes)
await ensureAdminUserChangeAllowed(user, {}, session)
const updated = await replaceAdminUserInventoryGroupBindings(user.id, inventoryGroupCodes, nowIso())
return {
user: mapAdminUser(updated),
}
}
export async function updateManagedAdminUserRole(
userId: number | string,
payload: JsonObject = {},
@@ -358,7 +338,6 @@ function createAdminSession(user: AdminUserRow): JsonObject {
userId: Number(user.id),
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
},
}
}
@@ -493,14 +472,7 @@ function mapAdminUser(user: AdminUserRow | null): JsonObject {
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
status: normalizeAdminUserStatus(user.status),
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
createdAt: user.created_at,
updatedAt: user.updated_at,
}
}
function normalizeInventoryGroupCodes(values: unknown): string[] {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean)))
}
@@ -12,8 +12,7 @@ export async function getAdminDashboardSummary() {
(
SELECT COUNT(*)::int
FROM fulfillment_tasks
WHERE task_status IN ('paid', 'link_generated')
OR (inventory_status = 'reserved' AND task_status NOT IN ('claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed', 'expired'))
WHERE task_status IN ('paid', 'link_generated', 'pending_binding_prepare', 'waiting_binding')
) AS paid_pending_claim,
(
SELECT COUNT(*)::int
@@ -29,13 +28,8 @@ export async function getAdminDashboardSummary() {
(
SELECT COUNT(*)::int
FROM fulfillment_tasks
WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')
) AS abnormal_tasks,
(
SELECT COUNT(DISTINCT sku_code)::int
FROM inventory_items
WHERE status = 'available'
) AS sku_with_inventory
WHERE task_status IN ('retry_pending', 'manual_review')
) AS abnormal_tasks
`,
[todayPrefix],
)
@@ -47,6 +41,5 @@ export async function getAdminDashboardSummary() {
claimingTasks: Number(summary?.claiming_tasks || 0),
redeemedToday: Number(summary?.redeemed_today || 0),
abnormalTasks: Number(summary?.abnormal_tasks || 0),
skuWithInventory: Number(summary?.sku_with_inventory || 0),
}
}
@@ -1,46 +0,0 @@
import { getInventoryItemById } from '../../repositories/inventory-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import { getTaskById } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { mapAdminTaskSummary } from './admin-task-read-helpers.js'
import type { AdminInventoryItemListItem } from '../../types/admin-read-models.js'
import type { InventoryItemRow } from '../../types/repository-rows.js'
export async function mapAdminInventoryListItem(item: InventoryItemRow): Promise<AdminInventoryItemListItem> {
const task = item.reserved_by_task_id ? await getTaskById(item.reserved_by_task_id) : null
const order = task?.order_id ? await getOrderById(task.order_id) : null
const taskSummary = task ? mapAdminTaskSummary(task) : null
return {
inventoryItemId: item.id,
skuCode: item.sku_code,
batchNo: item.batch_no,
credentialType: item.credential_type || 'claim_code',
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
displayValue: item.display_value,
status: item.status,
reservedByTaskId: item.reserved_by_task_id,
reservedByTaskNo: task?.task_no || '',
platformOrderId: order?.platform_order_id || '',
systemBindingStatus: item.status === 'consumed' ? 'system_bound' : (taskSummary?.systemBindingStatus || 'pending_binding'),
userBindingStatus: item.status === 'consumed' ? 'binding_completed' : (taskSummary?.userBindingStatus || 'not_started'),
invalidReason: item.invalid_reason || '',
deliveredAt: item.delivered_at,
createdAt: item.created_at,
updatedAt: item.updated_at,
}
}
export async function getRequiredInventoryItem(inventoryItemId: number | string): Promise<InventoryItemRow> {
const inventoryItem = await getInventoryItemById(Number(inventoryItemId))
if (!inventoryItem) {
throw createHttpError('库存项不存在', {
statusCode: 404,
errorCode: 'admin_inventory_not_found',
})
}
return inventoryItem
}
@@ -2,7 +2,7 @@ import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js'
import { buildOrderBindingSummary, getTaskBindingSummaryMap } from './admin-task-read-helpers.js'
import { buildOrderBindingSummary } from './admin-task-read-helpers.js'
import type { AdminOrderListItem } from '../../types/admin-read-models.js'
import type { OrderItemRow, OrderListRow } from '../../types/repository-rows.js'
@@ -12,8 +12,7 @@ export async function mapAdminOrderListItem(item: OrderListRow): Promise<AdminOr
listTasksByOrderId(item.id),
listOrderItemsByOrderId(item.id),
])
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap)
const bindingSummary = buildOrderBindingSummary(tasks)
const itemSummary = summarizeOrderItems(orderItems)
return {
@@ -1,16 +1,9 @@
import { buildClaimUrl } from '../claim/claim-service.js'
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
import {
getInventoryItemById,
listInventoryItems,
listInventorySkuSuggestions,
} from '../../repositories/inventory-repo.js'
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-repo.js'
import { listTaskInventoryBindingsByTaskId } from '../../repositories/task-inventory-binding-repo.js'
import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js'
import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js'
import { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
@@ -19,7 +12,6 @@ import {
canRegenerateClaimLinkForViewer,
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
isManualDispatchTask,
mapKuaishouCloudFulfillmentContext,
mapManualDispatchContext,
@@ -31,36 +23,24 @@ import {
resolveOrderItemDeliveryMode,
resolveOrderItemTitle,
} from './admin-read-shared-helpers.js'
import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
import {
buildOrderBindingSummary,
createTaskBindingSummaryFromBindings,
getRequiredTask,
getTaskBindingSummary,
getTaskBindingSummaryMap,
mapAdminTaskEvent,
mapAdminTaskInventoryBinding,
mapAdminTaskListItem,
mapAdminTaskSummary,
} from './admin-task-read-helpers.js'
import type {
AdminInventoryListResponse,
AdminInventorySkuSuggestionResponse,
AdminOrderListResponse,
AdminTaskListResponse,
AdminWebhookEventListResponse,
} from '../../types/admin-read-models.js'
import type {
AdminEntityIdInput,
AdminInventoryListQueryInput,
AdminInventorySkuSuggestionQueryInput,
AdminOrderListQueryInput,
AdminTaskListQueryInput,
AdminViewerSessionInput,
AdminWebhookEventListQueryInput,
} from '../../types/admin-read-inputs.js'
type JsonRecord = Record<string, any>
@@ -94,12 +74,10 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
})
}
const [items, tasks, webhookEvents] = await Promise.all([
const [items, tasks] = await Promise.all([
listOrderItemsByOrderId(order.id),
listTasksByOrderId(order.id),
listWebhookEventsByOrderId(order.id),
])
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
const itemSummary = summarizeOrderItems(items)
return {
@@ -125,7 +103,7 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object'
? order.raw_payload_json
: JSON.parse(String(order.raw_payload_json || '{}')),
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
bindingSummary: buildOrderBindingSummary(tasks),
},
items: items.map((item) => ({
orderItemId: item.id,
@@ -138,20 +116,7 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
? item.spec_json
: JSON.parse(String(item.spec_json || '{}')),
})),
tasks: tasks.map((task) => mapAdminTaskSummary(task, getTaskBindingSummary(taskBindingSummaryMap, task.id))),
webhookEvents: webhookEvents.map((event) => ({
eventId: event.id,
provider: event.provider || '',
platform: event.platform,
shopId: event.shop_id || '',
shopName: resolveDisplayShopName(event.provider, event.shop_id, event.shop_name),
eventType: event.event_type,
eventKey: event.event_key,
signatureValid: Boolean(event.signature_valid),
processed: Boolean(event.processed),
processError: event.process_error,
createdAt: event.created_at,
})),
tasks: tasks.map((task) => mapAdminTaskSummary(task)),
}
}
@@ -172,15 +137,10 @@ export async function getAdminTasks(
dateFrom: normalizeDateQuery(query.dateFrom),
dateTo: normalizeDateQuery(query.dateTo, true),
})
const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id))
const viewerContext = createAdminViewerContext(session)
return {
items: items.map((item) => mapAdminTaskListItem(
item,
getTaskBindingSummary(taskBindingSummaryMap, item.id),
viewerContext,
)),
items: items.map((item) => mapAdminTaskListItem(item, viewerContext)),
pagination: { page, pageSize, total },
}
}
@@ -198,20 +158,16 @@ export async function getAdminTaskDetail(
})
}
const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = await Promise.all([
const [order, claimToken, taskEvents] = await Promise.all([
getOrderById(task.order_id),
getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null),
getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null),
listTaskEventsByTaskId(task.id),
listTaskInventoryBindingsByTaskId(task.id),
])
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
const taskContext = parseTaskContext(task)
const taskState = parseTaskState(task)
const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings)
const viewerContext = createAdminViewerContext(session)
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext))
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment)
@@ -221,9 +177,8 @@ export async function getAdminTaskDetail(
...task,
sku_code: orderItem?.sku_code || '',
sku_name: orderItem?.sku_name || '',
inventory_display_value: inventoryItem?.display_value || '',
claim_token: claimToken?.token || '',
}, taskBindingSummary, viewerContext),
}, viewerContext),
order: order
? {
orderId: order.id,
@@ -253,17 +208,6 @@ export async function getAdminTaskDetail(
claimUrl,
}
: null,
inventory: inventoryItem
? {
inventoryItemId: inventoryItem.id,
skuCode: inventoryItem.sku_code,
batchNo: inventoryItem.batch_no,
credentialType: inventoryItem.credential_type || 'claim_code',
displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '',
status: inventoryItem.status,
}
: null,
inventoryBindings: mappedInventoryBindings,
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
screenshotUrl,
review: {
@@ -279,8 +223,7 @@ export async function getAdminTaskDetail(
operations: {
canRetry: viewerContext.canManageTaskLifecycle
&& !isManualDispatchTask(task)
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
&& ['retry_pending', 'manual_review'].includes(task.task_status),
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
canClose: canViewerCloseTask(task, viewerContext),
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
@@ -319,96 +262,3 @@ export async function getAdminTaskScreenshotPath(
errorCode: 'admin_task_screenshot_not_found',
})
}
export async function getAdminInventoryItems(
query: AdminInventoryListQueryInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<AdminInventoryListResponse> {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const viewerContext = createAdminViewerContext(session)
const { items, total } = await listInventoryItems({
page,
pageSize,
skuCode: String(query.skuCode || '').trim(),
credentialType: String(query.credentialType || '').trim(),
status: String(query.status || '').trim(),
batchNo: String(query.batchNo || '').trim(),
inventoryGroupCode: String(query.inventoryGroupCode || '').trim(),
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
return {
items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))),
pagination: { page, pageSize, total },
}
}
export async function getAdminInventorySkuSuggestions(
query: AdminInventorySkuSuggestionQueryInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<AdminInventorySkuSuggestionResponse> {
const viewerContext = createAdminViewerContext(session)
const credentialType = String(query.credentialType || '').trim()
const keyword = String(query.keyword || '').trim()
const inventoryGroupCode = String(query.inventoryGroupCode || '').trim()
const requestedLimit = Number(query.limit)
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.min(100, Math.floor(requestedLimit))
: 50
const items = await listInventorySkuSuggestions({
credentialType,
keyword,
limit,
inventoryGroupCode,
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
return {
items: items.map((item) => ({
skuCode: String(item.sku_code || '').trim(),
credentialType: String(item.credential_type || '').trim() || 'claim_code',
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
totalCount: Number(item.total_count || 0),
availableCount: Number(item.available_count || 0),
latestUpdatedAt: item.latest_updated_at || null,
})),
}
}
export async function getAdminWebhookEvents(
query: AdminWebhookEventListQueryInput = {},
): Promise<AdminWebhookEventListResponse> {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const { items, total } = await listWebhookEvents({
page,
pageSize,
provider: String(query.provider || '').trim(),
platform: String(query.platform || '').trim(),
platformOrderId: String(query.platformOrderId || '').trim(),
processed: String(query.processed || '').trim(),
visibility: String(query.visibility || '').trim() || 'important',
relatedOrderId: String(query.relatedOrderId || '').trim(),
dateFrom: normalizeDateQuery(query.dateFrom),
dateTo: normalizeDateQuery(query.dateTo, true),
})
return {
items: await Promise.all(items.map((item) => mapAdminWebhookEvent(item))),
pagination: { page, pageSize, total },
}
}
export async function getAdminWebhookEventDetail(eventId: AdminEntityIdInput): Promise<JsonRecord> {
const event = await getWebhookEventById(Number(eventId))
if (!event) {
throw createHttpError('Webhook 事件不存在', {
statusCode: 404,
errorCode: 'admin_webhook_not_found',
})
}
return mapAdminWebhookEvent(event, { includeRaw: true })
}
@@ -16,17 +16,13 @@ type TaskLike = Partial<TaskRow> & {
export type AdminViewerContext = {
role: string
allowedInventoryGroupCodes: string[] | null
restrictInventoryGroups: boolean
canViewSensitiveTaskData: boolean
canManageSensitiveInventory: boolean
canManageTaskLifecycle: boolean
canOperateAssistedTask: boolean
}
type RedeemResolutionAttempt = {
attempt: number
inventoryItemId: number | null
codeMasked: string
credentialType: string
outcome: string
@@ -166,11 +162,6 @@ export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
}
}
export function getTaskPrimaryInventoryItemId(task: TaskLike | null | undefined): number | null {
const value = Number(task?.primary_inventory_item_id || 0)
return Number.isFinite(value) && value > 0 ? value : null
}
export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): number | null {
const value = Number(task?.primary_claim_token_id || 0)
return Number.isFinite(value) && value > 0 ? value : null
@@ -178,16 +169,10 @@ export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): n
export function createAdminViewerContext(session: AdminViewerSessionInput | null = null): AdminViewerContext {
const role = normalizeAdminRole(session?.role)
const allowedInventoryGroupCodes = role === 'support'
? normalizeInventoryGroupCodes(session?.allowedInventoryGroups)
: null
return {
role,
allowedInventoryGroupCodes,
restrictInventoryGroups: role === 'support',
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
canManageSensitiveInventory: role === 'admin',
canManageTaskLifecycle: role === 'admin' || role === 'operator',
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
}
@@ -297,38 +282,6 @@ export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: A
return false
}
export function canViewerAccessTaskInventoryGroup(
task: TaskLike,
viewerContext: AdminViewerContext = createAdminViewerContext(),
): boolean {
if (!viewerContext.restrictInventoryGroups) {
return true
}
const allowedInventoryGroupCodes = Array.isArray(viewerContext.allowedInventoryGroupCodes)
? viewerContext.allowedInventoryGroupCodes
: []
if (allowedInventoryGroupCodes.length === 0) {
return false
}
const taskContext = parseTaskContext(task)
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
if (!inventoryGroupCode) {
return true
}
return allowedInventoryGroupCodes.includes(inventoryGroupCode)
}
export function normalizeInventoryGroupCodes(values: unknown): string[] {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean)))
}
function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | null {
if (!value || typeof value !== 'object') {
return null
@@ -337,7 +290,6 @@ function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | n
const record = value as JsonRecord
return {
attempt: Math.max(1, Number(record.attempt || 1)),
inventoryItemId: Number(record.inventoryItemId || 0) || null,
codeMasked: String(record.codeMasked || '').trim(),
credentialType: String(record.credentialType || '').trim(),
outcome: String(record.outcome || '').trim(),
@@ -1,12 +1,9 @@
import { getTaskById } from '../../repositories/task-repo.js'
import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js'
import { createHttpError } from '../../utils/http.js'
import { safeParseJson } from './admin-query-utils.js'
import {
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
parseTaskContext,
} from './admin-read-shared-helpers.js'
import type {
@@ -16,8 +13,6 @@ import type {
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
import type {
TaskEventRow,
TaskInventoryBindingRow,
TaskInventoryBindingSummaryRow,
TaskRow,
} from '../../types/repository-rows.js'
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
@@ -29,10 +24,6 @@ type TaskBindingState = {
userBindingStatus: string
}
type TaskInventoryBindingLike = TaskInventoryBindingRow & {
inventory_item_status?: string
}
type OrderBindingSummary = {
totalTaskCount: number
systemBoundTaskCount: number
@@ -72,7 +63,6 @@ export function mapAdminTaskSummary(
}
export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
return {
@@ -82,7 +72,6 @@ export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
deliveryStatus: task.delivery_status,
resultCode: task.result_code,
resultMessage: task.result_message,
inventoryItemId,
primaryClaimTokenId,
lastError: task.last_error,
updatedAt: task.updated_at,
@@ -104,10 +93,10 @@ export async function getRequiredTask(taskId: number | string): Promise<TaskRow>
export function mapAdminTaskListItem(
task: TaskRow,
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
viewerContext: AdminViewerContext = createAdminViewerContext(),
): AdminTaskListItem {
const binding = buildTaskBindingState(task)
const bindingSummary = createEmptyTaskBindingSummary()
return {
taskId: task.id,
@@ -134,8 +123,6 @@ export function mapAdminTaskListItem(
lastError: task.last_error,
createdAt: task.created_at,
updatedAt: task.updated_at,
inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '',
inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(),
claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '',
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
}
@@ -162,92 +149,18 @@ export function createEmptyTaskBindingSummary(): AdminTaskBindingSummary {
}
}
export async function getTaskBindingSummaryMap(taskIds: Array<number | string> = []): Promise<Map<number, AdminTaskBindingSummary>> {
const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds)
const output = new Map<number, AdminTaskBindingSummary>()
for (const row of rows as TaskInventoryBindingSummaryRow[]) {
output.set(Number(row.task_id), {
totalBindingCount: Number(row.total_binding_count || 0),
reservedBindingCount: Number(row.reserved_binding_count || 0),
consumedBindingCount: Number(row.consumed_binding_count || 0),
releasedBindingCount: Number(row.released_binding_count || 0),
roleKeys: Array.isArray(row.role_keys)
? row.role_keys.map((value) => String(value || '').trim()).filter(Boolean)
: [],
})
}
return output
}
export function getTaskBindingSummary(
summaryMap: Map<number, AdminTaskBindingSummary>,
taskId: number | string,
): AdminTaskBindingSummary {
return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary()
}
export function createTaskBindingSummaryFromBindings(
bindings: Array<Partial<TaskInventoryBindingRow>> | null | undefined = [],
): AdminTaskBindingSummary {
const normalizedBindings = Array.isArray(bindings) ? bindings : []
const roleKeys = Array.from(new Set(normalizedBindings
.map((binding) => String(binding?.role_key || '').trim())
.filter(Boolean)))
return {
totalBindingCount: normalizedBindings.length,
reservedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'reserved').length,
consumedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'consumed').length,
releasedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'released').length,
roleKeys,
}
}
export function mapAdminTaskInventoryBinding(
binding: TaskInventoryBindingLike,
task: TaskRow,
viewerContext: AdminViewerContext = createAdminViewerContext(),
): JsonRecord {
const metadata = safeParseJson(binding.metadata_json)
return {
bindingId: binding.id,
inventoryItemId: binding.inventory_item_id,
roleKey: String(binding.role_key || '').trim(),
quantity: Math.max(1, Number(binding.quantity || 1)),
bindingStatus: String(binding.binding_status || '').trim(),
inventoryStatus: String(binding.inventory_item_status || '').trim(),
skuCode: String(binding.sku_code || '').trim(),
batchNo: String(binding.batch_no || '').trim(),
credentialType: String(binding.credential_type || 'claim_code').trim() || 'claim_code',
displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '',
invalidReason: String(binding.invalid_reason || '').trim(),
consumedAt: binding.consumed_at || null,
releasedAt: binding.released_at || null,
createdAt: binding.created_at,
updatedAt: binding.updated_at,
metadata: normalizeRecord(metadata),
isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task),
canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding),
}
}
export function buildOrderBindingSummary(
tasks: TaskRow[] | null | undefined,
taskBindingSummaryMap: Map<number, AdminTaskBindingSummary> = new Map(),
): OrderBindingSummary {
const normalizedTasks = Array.isArray(tasks) ? tasks : []
const totalTaskCount = normalizedTasks.length
const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task))
const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length
const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length
const bindingSummaries = normalizedTasks.map((task) => getTaskBindingSummary(taskBindingSummaryMap, task.id))
const totalBindingCount = bindingSummaries.reduce((sum, item) => sum + item.totalBindingCount, 0)
const reservedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.reservedBindingCount, 0)
const consumedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.consumedBindingCount, 0)
const releasedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.releasedBindingCount, 0)
const totalBindingCount = 0
const reservedBindingCount = 0
const consumedBindingCount = 0
const releasedBindingCount = 0
let systemBindingStatus = 'pending_binding'
let userBindingStatus = 'not_started'
@@ -280,7 +193,7 @@ export function buildOrderBindingSummary(
userBindingStatus = 'binding_exception'
} else if (systemBoundTaskCount > 0) {
systemBindingStatus = 'system_bound'
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) {
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending'].includes(item.systemBindingStatus))) {
systemBindingStatus = 'binding_exception'
}
@@ -297,18 +210,6 @@ export function buildOrderBindingSummary(
}
}
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
if (!task || !binding) {
return false
}
if (String(binding.binding_status || '').trim() !== 'reserved') {
return false
}
return !['redeemed', 'expired'].includes(String(task.task_status || '').trim())
}
function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingState {
const normalizedStatus = String(task?.task_status || '').trim()
@@ -327,10 +228,6 @@ function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingSta
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
}
if (normalizedStatus === 'waiting_inventory') {
return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' }
}
if (normalizedStatus === 'pending_binding_prepare') {
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
}
@@ -391,24 +288,13 @@ function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingSta
}
function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
return Boolean(task && getTaskPrimaryClaimTokenId(task))
}
function normalizeRecord(value: unknown): JsonRecord {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
function maskCode(value: unknown): string {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (text.length <= 8) {
return `${text.slice(0, 2)}****${text.slice(-2)}`
}
return `${text.slice(0, 4)}****${text.slice(-4)}`
}
function getTaskRetryCount(task: TaskRow | null | undefined): number {
return Number(task?.attempt_count || 0)
}
@@ -1,14 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { resolveWebhookVisibilityLevel } from './admin-webhook-read-helpers.js'
test('resolveWebhookVisibilityLevel marks ignored process errors as ignored', () => {
assert.equal(resolveWebhookVisibilityLevel('ignored_unconfigured_product'), 'ignored')
assert.equal(resolveWebhookVisibilityLevel('ignored_duplicate_event'), 'ignored')
})
test('resolveWebhookVisibilityLevel keeps normal and failed events as important', () => {
assert.equal(resolveWebhookVisibilityLevel(''), 'important')
assert.equal(resolveWebhookVisibilityLevel('invalid_signature'), 'important')
})
@@ -1,235 +0,0 @@
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
import { safeParseJson } from './admin-query-utils.js'
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
import type { AdminWebhookEventListItem } from '../../types/admin-read-models.js'
import type { WebhookEventRow } from '../../types/repository-rows.js'
type JsonRecord = Record<string, any>
type MapAdminWebhookEventOptions = {
includeRaw?: boolean
}
export async function mapAdminWebhookEvent(
item: WebhookEventRow,
{ includeRaw = false }: MapAdminWebhookEventOptions = {},
): Promise<AdminWebhookEventListItem> {
const headers = normalizeRecord(safeParseJson(item.headers_json))
const query = normalizeRecord(safeParseJson(item.query_json))
const body = normalizeRecord(safeParseJson(item.body_json))
const payload = extractWebhookPayload(body)
const itemSources = extractWebhookItemSources(payload)
const rawAmount = pickFirstNonEmpty([
payload.total_fee,
payload.totalFee,
payload.TotalFee,
payload.pay_fee,
payload.payFee,
payload.Payment,
payload.payment,
payload.buy_amount,
payload.buyAmount,
])
const totalAmountFen = parseAmountToFen(rawAmount)
const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0
const shopId = item.shop_id || pickFirstNonEmpty([
payload.PlatformUserId,
payload.platformUserId,
payload.platform_user_id,
payload.seller_id,
payload.sellerId,
payload.SellerId,
payload.seller_nick,
payload.sellerNick,
payload.SellerNick,
])
const mapped = {
eventId: item.id,
provider: item.provider || '',
platform: item.platform,
platformRaw: pickFirstNonEmpty([
query.fromPlatform,
query.from_platform,
body.fromPlatform,
body.from_platform,
payload.fromPlatform,
payload.FromPlatform,
payload.platform,
payload.Platform,
]),
shopId,
shopName: resolveDisplayShopName(
item.provider,
shopId,
item.shop_name || pickFirstNonEmpty([
payload.shopName,
payload.shop_name,
payload.ShopName,
payload.seller_nick,
payload.sellerNick,
payload.SellerNick,
]),
),
eventType: item.event_type,
eventKey: item.event_key,
signatureValid: Boolean(item.signature_valid),
processed: Boolean(item.processed),
processError: item.process_error,
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
relatedOrderId: item.related_order_id,
createdAt: item.created_at,
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
buyerId: pickFirstNonEmpty([
payload.buyer_id,
payload.buyerId,
payload.BuyerId,
payload.openid,
payload.BuyerOpenUid,
payload.buyer_open_uid,
payload.buyerOpenUid,
]),
buyerName: pickFirstNonEmpty([
payload.buyer_name,
payload.buyerName,
payload.BuyerName,
payload.nick,
payload.BuyerNick,
payload.buyer_nick,
payload.buyerNick,
]),
totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '',
totalAmountFen,
currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY',
itemCount: itemSources.length,
taskCount,
messageText: pickFirstNonEmpty([
payload.MsgTypeDes,
payload.msgTypeDes,
payload.msg_type_desc,
payload.message,
payload.msg,
]),
requestTimestamp: pickFirstNonEmpty([query.timestamp]),
requestSign: pickFirstNonEmpty([query.sign]),
aopic: pickFirstNonEmpty([query.aopic]),
sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]),
sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]),
userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]),
}
if (!includeRaw) {
return mapped
}
return {
...mapped,
headers,
query,
body,
payload,
}
}
function extractWebhookPayload(body: JsonRecord): JsonRecord {
const candidates = [
body.data,
body.Data,
body.payload,
body.Payload,
body.message,
body.Message,
]
for (const candidate of candidates) {
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
return normalizeRecord(candidate)
}
}
return body
}
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
const fromPayload = pickFirstNonEmpty([
payload.biz_order_id,
payload.bizOrderId,
payload.BizOrderId,
payload.tid,
payload.Tid,
payload.order_id,
payload.orderId,
payload.OrderId,
payload.oid,
payload.Oid,
])
if (fromPayload) {
return fromPayload
}
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
}
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
const normalizedPayload = normalizeRecord(payload)
const candidates = [
normalizedPayload.orders,
normalizedPayload.Orders,
normalizedPayload.items,
normalizedPayload.Items,
normalizedPayload.order_items,
]
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.length > 0) {
return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0)
}
}
if (
pickFirstNonEmpty([
normalizedPayload.item_id,
normalizedPayload.itemId,
normalizedPayload.ItemId,
normalizedPayload.num_iid,
normalizedPayload.numIid,
normalizedPayload.NumIid,
])
) {
return [normalizedPayload]
}
return []
}
export function resolveWebhookVisibilityLevel(processError: unknown): string {
const normalized = String(processError || '').trim()
if (normalized.startsWith('ignored_')) {
return 'ignored'
}
return 'important'
}
function normalizeRecord(value: unknown): JsonRecord {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
}
return ''
}
@@ -1,213 +0,0 @@
import {
createInventoryItems,
invalidateInventoryItem,
listInventoryItems,
releaseReservedInventoryItem,
} from '../../../repositories/inventory-repo.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import {
getRequiredInventoryItem,
mapAdminInventoryListItem,
} from '../admin-inventory-read-helpers.js'
import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js'
import type {
AdminInventoryCreateInput,
AdminInventoryImportInput,
AdminInventoryImportRowInput,
AdminInventoryInvalidateInput,
} from '../../../types/admin-write-inputs.js'
import type {
AdminInventoryImportResponse,
AdminInventoryMutationResponse,
} from '../../../types/admin-write-models.js'
import type { InventoryItemRow } from '../../../types/repository-rows.js'
type NormalizedInventoryImportRow = Required<Pick<
AdminInventoryImportRowInput,
'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'
>>
export async function createAdminInventoryItem(
payload: AdminInventoryCreateInput = {},
): Promise<AdminInventoryMutationResponse> {
const skuCode = String(payload.skuCode || '').trim()
const displayValue = String(payload.displayValue || '').trim()
const batchNo = String(payload.batchNo || '').trim()
const credentialType = String(payload.credentialType || 'claim_code').trim() || 'claim_code'
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
if (!skuCode || !displayValue) {
throw createHttpError('缺少 skuCode 或 displayValue', {
statusCode: 400,
errorCode: 'admin_inventory_create_invalid',
})
}
const now = nowIso()
const created = await createInventoryItems([
{
skuCode,
displayValue,
batchNo,
credentialType,
inventoryGroupCode,
createdAt: now,
updatedAt: now,
},
])
if (created === 0) {
throw createHttpError('库存凭据已存在,不能重复新增', {
statusCode: 409,
errorCode: 'admin_inventory_duplicate',
})
}
const { items } = await listInventoryItems({
page: 1,
pageSize: 20,
skuCode,
inventoryGroupCode,
})
const createdItem = items.find((item) => item.display_value === displayValue && item.inventory_group_code === inventoryGroupCode) || null
return {
inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null,
}
}
export async function importAdminInventoryItems(
payload: AdminInventoryImportInput = {},
): Promise<AdminInventoryImportResponse> {
const rows = normalizeInventoryImportRows(payload)
if (rows.length === 0) {
throw createHttpError('没有可导入的库存凭据数据', {
statusCode: 400,
errorCode: 'admin_inventory_import_empty',
})
}
const now = nowIso()
const normalizedRows = rows.map((row) => ({
skuCode: row.skuCode,
batchNo: row.batchNo,
displayValue: row.displayValue,
credentialType: row.credentialType,
inventoryGroupCode: row.inventoryGroupCode,
createdAt: now,
updatedAt: now,
}))
const created = await createInventoryItems(normalizedRows)
return {
total: normalizedRows.length,
created,
duplicated: normalizedRows.length - created,
}
}
export async function releaseAdminInventoryItem(
inventoryItemId: AdminEntityIdInput,
): Promise<AdminInventoryMutationResponse> {
const inventoryItem = await getRequiredInventoryItem(inventoryItemId)
if (inventoryItem.status !== 'reserved') {
throw createHttpError('当前库存项不是预占状态,不能释放', {
statusCode: 409,
errorCode: 'admin_inventory_release_not_allowed',
})
}
const updated = await releaseReservedInventoryItem(inventoryItem.id, nowIso())
return {
inventoryItem: await mapAdminInventoryListItem(updated as InventoryItemRow),
}
}
export async function invalidateAdminInventoryItem(
inventoryItemId: AdminEntityIdInput,
payload: AdminInventoryInvalidateInput = {},
): Promise<AdminInventoryMutationResponse> {
const inventoryItem = await getRequiredInventoryItem(inventoryItemId)
if (inventoryItem.status !== 'available') {
throw createHttpError('只有可用库存项才能作废,请先释放预占', {
statusCode: 409,
errorCode: 'admin_inventory_invalidate_not_allowed',
})
}
const reason = String(payload.reason || '').trim() || '后台手动作废'
const updated = await invalidateInventoryItem(inventoryItem.id, reason, nowIso())
return {
inventoryItem: await mapAdminInventoryListItem(updated as InventoryItemRow),
}
}
function normalizeInventoryImportRows(payload: AdminInventoryImportInput): NormalizedInventoryImportRow[] {
const rows: NormalizedInventoryImportRow[] = []
const directRows = Array.isArray(payload.rows) ? payload.rows : []
const bulkCodes = Array.isArray(payload.codes) ? payload.codes : []
if (directRows.length > 0) {
for (const row of directRows) {
const skuCode = String(row?.skuCode || '').trim()
const displayValue = String(row?.displayValue || '').trim()
const batchNo = String(row?.batchNo || '').trim()
const credentialType = String(row?.credentialType || payload.credentialType || 'claim_code').trim() || 'claim_code'
const inventoryGroupCode = String(row?.inventoryGroupCode || payload.inventoryGroupCode || '').trim()
if (!skuCode || !displayValue) {
continue
}
rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode })
}
}
if (bulkCodes.length > 0) {
const skuCode = String(payload.skuCode || '').trim()
const batchNo = String(payload.batchNo || '').trim()
const credentialType = String(payload.credentialType || 'claim_code').trim() || 'claim_code'
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
if (!skuCode) {
throw createHttpError('批量导入时缺少 skuCode', {
statusCode: 400,
errorCode: 'admin_inventory_import_missing_sku',
})
}
for (const rawValue of bulkCodes) {
const displayValue = String(rawValue || '').trim()
if (!displayValue) {
continue
}
rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode })
}
}
return dedupeRows(rows)
}
function dedupeRows(rows: NormalizedInventoryImportRow[]): NormalizedInventoryImportRow[] {
const seen = new Set<string>()
const output: NormalizedInventoryImportRow[] = []
for (const row of rows) {
const key = `${row.skuCode}::${row.credentialType || 'claim_code'}::${row.displayValue}`
if (seen.has(key)) {
continue
}
seen.add(key)
output.push(row)
}
return output
}
@@ -198,7 +198,6 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
const updatedTask = await updateTask(task.id, {
task_status: 'waiting_binding',
inventory_status: 'not_required',
user_action_status: 'pending',
claim_token: claimLinkState.token || task.claim_token || '',
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
@@ -3,7 +3,6 @@ import {
maskCode as maskCodeValue,
maskPhone as maskPhoneValue,
} from '../../../utils/masking.js'
import { parseTaskContext } from '../admin-read-shared-helpers.js'
import type { TaskRow } from '../../../types/repository-rows.js'
@@ -26,12 +25,6 @@ export function maskCode(value: unknown): string {
return maskCodeValue(value)
}
export function resolveTaskInventoryGroupCodes(task: TaskRow): string[] | null {
const taskContext = parseTaskContext(task)
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
return inventoryGroupCode ? [inventoryGroupCode] : null
}
export function isRecoverableTaskSessionCloseError(error: ErrorLike | null | undefined): boolean {
const errorCode = String(error?.errorCode || error?.code || '').trim()
return errorCode === 'session_not_found' || errorCode === 'session_closed'