删除库存和旧回调管理

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
+1 -1
View File
@@ -89,7 +89,7 @@ npm test
运行日志默认保存在:
- `data/logs/app-YYYY-MM-DD.log`
- `data/logs/webhook-YYYY-MM-DD.log`
- `data/logs/integration-YYYY-MM-DD.log`
日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health``/health/live``/health/ready` 探活请求默认不写 access log。
@@ -135,42 +135,6 @@ CREATE TABLE IF NOT EXISTS order_items (
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code);
CREATE TABLE IF NOT EXISTS inventory_items (
id BIGSERIAL PRIMARY KEY,
batch_no TEXT NOT NULL DEFAULT '',
sku_code TEXT NOT NULL,
credential_type TEXT NOT NULL,
display_value TEXT NOT NULL DEFAULT '',
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
source_type TEXT NOT NULL DEFAULT 'static_import',
status TEXT NOT NULL DEFAULT 'available',
invalid_reason TEXT NOT NULL DEFAULT '',
inventory_group_code TEXT NOT NULL DEFAULT '',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_items_unique_payload
ON inventory_items(sku_code, credential_type, md5(payload_json::text));
CREATE INDEX IF NOT EXISTS idx_inventory_items_pool
ON inventory_items(sku_code, credential_type, status, id);
CREATE INDEX IF NOT EXISTS idx_inventory_items_group_lookup
ON inventory_items(sku_code, credential_type, inventory_group_code, status, id);
CREATE TABLE IF NOT EXISTS admin_user_inventory_group_bindings (
id BIGSERIAL PRIMARY KEY,
admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
inventory_group_code TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE(admin_user_id, inventory_group_code)
);
CREATE INDEX IF NOT EXISTS idx_admin_user_inventory_group_bindings_user_id
ON admin_user_inventory_group_bindings(admin_user_id, inventory_group_code);
CREATE TABLE IF NOT EXISTS fulfillment_tasks (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
@@ -185,7 +149,6 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks (
profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE RESTRICT,
executor_key TEXT NOT NULL,
task_status TEXT NOT NULL DEFAULT 'pending_payment',
inventory_status TEXT NOT NULL DEFAULT 'pending',
delivery_status TEXT NOT NULL DEFAULT 'pending',
result_code TEXT NOT NULL DEFAULT '',
result_message TEXT NOT NULL DEFAULT '',
@@ -224,24 +187,6 @@ CREATE TABLE IF NOT EXISTS claim_tokens (
CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status, expired_at);
CREATE TABLE IF NOT EXISTS task_inventory_bindings (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
inventory_item_id BIGINT NOT NULL REFERENCES inventory_items(id) ON DELETE RESTRICT,
role_key TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
binding_status TEXT NOT NULL DEFAULT 'reserved',
consumed_at TIMESTAMPTZ,
released_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE(task_id, role_key, inventory_item_id)
);
CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_task ON task_inventory_bindings(task_id);
CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_inventory ON task_inventory_bindings(inventory_item_id);
CREATE TABLE IF NOT EXISTS task_events (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
@@ -268,24 +213,3 @@ CREATE TABLE IF NOT EXISTS task_runtime_contexts (
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS webhook_events (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
shop_id TEXT NOT NULL DEFAULT '',
shop_name TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
event_key TEXT NOT NULL,
signature_valid BOOLEAN NOT NULL DEFAULT FALSE,
headers_json JSONB NOT NULL DEFAULT '{}'::jsonb,
query_json JSONB NOT NULL DEFAULT '{}'::jsonb,
body_json JSONB NOT NULL DEFAULT '{}'::jsonb,
processed BOOLEAN NOT NULL DEFAULT FALSE,
process_error TEXT NOT NULL DEFAULT '',
related_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webhook_events_lookup ON webhook_events(provider, platform, shop_id, event_key);
CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed, created_at DESC);
@@ -1,4 +1,4 @@
import { query, withTransaction } from '../db/client.js'
import { query } from '../db/client.js'
type AdminUserRow = {
id: number
@@ -8,7 +8,6 @@ type AdminUserRow = {
status: string
created_at: string
updated_at: string
inventory_group_codes: string[]
}
type AdminUserCreateInput = {
@@ -39,15 +38,8 @@ type AdminUserListResult = {
}
const ADMIN_USER_SELECT = `
SELECT
au.*,
COALESCE(bindings.inventory_group_codes, ARRAY[]::text[]) AS inventory_group_codes
SELECT au.*
FROM admin_users au
LEFT JOIN LATERAL (
SELECT ARRAY_AGG(auigb.inventory_group_code ORDER BY auigb.inventory_group_code) AS inventory_group_codes
FROM admin_user_inventory_group_bindings auigb
WHERE auigb.admin_user_id = au.id
) bindings ON TRUE
`
export async function getAdminUserById(userId: number | string): Promise<AdminUserRow | null> {
@@ -183,39 +175,3 @@ export async function countActiveAdminUsers(): Promise<number> {
)
return Number(result.rows[0]?.total || 0)
}
export async function replaceAdminUserInventoryGroupBindings(
userId: number | string,
inventoryGroupCodes: unknown[] = [],
timestamp: string,
): Promise<AdminUserRow | null> {
const normalizedUserId = Number(userId)
const normalizedCodes = Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : [])
.map((value) => String(value || '').trim())
.filter(Boolean)))
await withTransaction(async (client) => {
await client.query(
'DELETE FROM admin_user_inventory_group_bindings WHERE admin_user_id = $1',
[normalizedUserId],
)
for (const inventoryGroupCode of normalizedCodes) {
await client.query(
`
INSERT INTO admin_user_inventory_group_bindings (
admin_user_id,
inventory_group_code,
created_at,
updated_at
) VALUES ($1, $2, $3, $4)
ON CONFLICT (admin_user_id, inventory_group_code) DO UPDATE
SET updated_at = EXCLUDED.updated_at
`,
[normalizedUserId, inventoryGroupCode, timestamp, timestamp],
)
}
})
return getAdminUserById(normalizedUserId)
}
@@ -1,497 +0,0 @@
import { query, withTransaction } from '../db/client.js'
import type {
InventoryCreateItemInput,
InventoryListQueryInput,
InventorySkuSuggestionQueryInput,
} from '../types/repository-inputs.js'
import type {
InventoryItemRow,
InventoryListQueryResult,
InventorySkuSuggestionRow,
} from '../types/repository-rows.js'
type InventoryGroupCodesInput = string | string[] | null | undefined
type InventoryGroupListInput = {
keyword?: string
limit?: number | string
}
const INVENTORY_ITEM_SELECT = `
SELECT
ii.id,
ii.sku_code,
ii.batch_no,
ii.credential_type,
ii.inventory_group_code,
ii.display_value,
ii.status,
ii.invalid_reason,
ii.consumed_at AS delivered_at,
ii.created_at,
ii.updated_at,
tib.task_id AS reserved_by_task_id,
ft.task_no AS reserved_by_task_no,
ft.platform_order_id
FROM inventory_items ii
LEFT JOIN LATERAL (
SELECT *
FROM task_inventory_bindings
WHERE inventory_item_id = ii.id AND binding_status IN ('reserved', 'consumed')
ORDER BY id DESC
LIMIT 1
) tib ON TRUE
LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id
`
export async function findFirstAvailableInventoryItemBySkuCode(
skuCode: string,
credentialType = 'claim_code',
inventoryGroupCodes: InventoryGroupCodesInput = null,
): Promise<InventoryItemRow | null> {
const normalizedInventoryGroupCodes = normalizeInventoryGroupCodes(inventoryGroupCodes)
if (normalizedInventoryGroupCodes && normalizedInventoryGroupCodes.length === 0) {
return null
}
const params: unknown[] = [skuCode, credentialType]
const filters = [
'ii.sku_code = $1',
'ii.credential_type = $2',
`ii.status = 'available'`,
]
if (normalizedInventoryGroupCodes) {
params.push(normalizedInventoryGroupCodes)
filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`)
}
const result = await query<InventoryItemRow>(
`${INVENTORY_ITEM_SELECT}
WHERE ${filters.join(' AND ')}
ORDER BY ii.id ASC
LIMIT 1`,
params,
)
return result.rows[0] || null
}
export async function assignReservedInventoryItem(
inventoryItemId: number | string,
taskId: number | string,
updatedAt: string,
roleKey = 'primary_code',
): Promise<InventoryItemRow | null> {
return withTransaction(async (client) => {
const inventoryResult = await client.query(
`
UPDATE inventory_items
SET status = 'reserved', updated_at = $1
WHERE id = $2 AND status = 'available'
RETURNING id
`,
[updatedAt, Number(inventoryItemId)],
)
if (!inventoryResult.rows[0]) {
return getInventoryItemById(inventoryItemId)
}
await client.query(
`
INSERT INTO task_inventory_bindings (
task_id,
inventory_item_id,
role_key,
quantity,
binding_status,
metadata_json,
created_at,
updated_at
) VALUES ($1, $2, $3, 1, 'reserved', '{}'::jsonb, $4, $4)
ON CONFLICT (task_id, role_key, inventory_item_id) DO UPDATE
SET binding_status = 'reserved', updated_at = EXCLUDED.updated_at, released_at = NULL
`,
[Number(taskId), Number(inventoryItemId), roleKey, updatedAt],
)
return getInventoryItemById(inventoryItemId)
})
}
export async function getInventoryItemById(inventoryItemId: number | string): Promise<InventoryItemRow | null> {
const result = await query<InventoryItemRow>(
`${INVENTORY_ITEM_SELECT}
WHERE ii.id = $1
LIMIT 1`,
[Number(inventoryItemId)],
)
return result.rows[0] || null
}
export async function markInventoryItemDelivered(
inventoryItemId: number | string,
deliveredAt: string,
): Promise<InventoryItemRow | null> {
return withTransaction(async (client) => {
await client.query(
`
UPDATE inventory_items
SET status = 'consumed', consumed_at = $1, updated_at = $1
WHERE id = $2
`,
[deliveredAt, Number(inventoryItemId)],
)
await client.query(
`
UPDATE task_inventory_bindings
SET binding_status = 'consumed', consumed_at = $1, updated_at = $1
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
`,
[deliveredAt, Number(inventoryItemId)],
)
return getInventoryItemById(inventoryItemId)
})
}
export async function markInventoryItemConsumed(
inventoryItemId: number | string,
reason: string,
consumedAt: string,
): Promise<InventoryItemRow | null> {
return withTransaction(async (client) => {
await client.query(
`
UPDATE inventory_items
SET
status = 'consumed',
invalid_reason = $1,
consumed_at = $2,
updated_at = $2
WHERE id = $3
`,
[String(reason || '').trim(), consumedAt, Number(inventoryItemId)],
)
await client.query(
`
UPDATE task_inventory_bindings
SET binding_status = 'consumed', consumed_at = $1, updated_at = $1
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
`,
[consumedAt, Number(inventoryItemId)],
)
return getInventoryItemById(inventoryItemId)
})
}
export async function listInventoryItems({
page = 1,
pageSize = 20,
skuCode = '',
credentialType = '',
status = '',
batchNo = '',
inventoryGroupCode = '',
allowedInventoryGroupCodes = null,
}: InventoryListQueryInput = {}): Promise<InventoryListQueryResult> {
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
return {
items: [],
total: 0,
}
}
const offset = (page - 1) * pageSize
const filters: string[] = []
const params: unknown[] = []
if (skuCode) {
params.push(skuCode)
filters.push(`ii.sku_code = $${params.length}`)
}
if (status) {
params.push(status === 'delivered' ? 'consumed' : status)
filters.push(`ii.status = $${params.length}`)
}
if (credentialType) {
params.push(credentialType)
filters.push(`ii.credential_type = $${params.length}`)
}
if (batchNo) {
params.push(`%${batchNo}%`)
filters.push(`ii.batch_no ILIKE $${params.length}`)
}
if (inventoryGroupCode) {
params.push(inventoryGroupCode)
filters.push(`ii.inventory_group_code = $${params.length}`)
}
if (normalizedAllowedInventoryGroupCodes) {
params.push(normalizedAllowedInventoryGroupCodes)
filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const totalResult = await query<{ [column: string]: unknown, total: number }>(
`SELECT COUNT(*)::int AS total FROM inventory_items ii ${whereClause}`,
params,
)
params.push(pageSize)
params.push(offset)
const itemsResult = await query<InventoryItemRow>(
`${INVENTORY_ITEM_SELECT}
${whereClause}
ORDER BY ii.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
)
return {
items: itemsResult.rows,
total: Number(totalResult.rows[0]?.total || 0),
}
}
export async function listInventorySkuSuggestions({
credentialType = '',
keyword = '',
limit = 50,
inventoryGroupCode = '',
allowedInventoryGroupCodes = null,
}: InventorySkuSuggestionQueryInput = {}): Promise<InventorySkuSuggestionRow[]> {
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
return []
}
const filters: string[] = []
const params: unknown[] = []
if (credentialType) {
params.push(credentialType)
filters.push(`credential_type = $${params.length}`)
}
if (keyword) {
params.push(`%${keyword}%`)
filters.push(`sku_code ILIKE $${params.length}`)
}
if (inventoryGroupCode) {
params.push(inventoryGroupCode)
filters.push(`inventory_group_code = $${params.length}`)
}
if (normalizedAllowedInventoryGroupCodes) {
params.push(normalizedAllowedInventoryGroupCodes)
filters.push(`inventory_group_code = ANY($${params.length}::text[])`)
}
const normalizedLimit = Number.isFinite(Number(limit))
? Math.max(1, Math.min(Number(limit), 100))
: 50
params.push(normalizedLimit)
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const result = await query<InventorySkuSuggestionRow>(
`
SELECT
sku_code,
credential_type,
inventory_group_code,
COUNT(*)::int AS total_count,
COUNT(*) FILTER (WHERE status = 'available')::int AS available_count,
MAX(updated_at) AS latest_updated_at
FROM inventory_items
${whereClause}
GROUP BY sku_code, credential_type, inventory_group_code
ORDER BY
COUNT(*) FILTER (WHERE status = 'available') DESC,
COUNT(*) DESC,
MAX(updated_at) DESC,
sku_code ASC,
inventory_group_code ASC
LIMIT $${params.length}
`,
params,
)
return result.rows
}
export async function createInventoryItems(rows: InventoryCreateItemInput[]): Promise<number> {
let created = 0
for (const row of rows) {
const payloadJson = JSON.stringify(row.payload || { code: row.displayValue })
const displayValue = String(row.displayValue || '').trim()
const inventoryGroupCode = String(row.inventoryGroupCode || '').trim()
const result = await query<{ [column: string]: unknown, id: number }>(
`
INSERT INTO inventory_items (
batch_no,
sku_code,
credential_type,
inventory_group_code,
display_value,
payload_json,
source_type,
status,
invalid_reason,
metadata_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'static_import', 'available', '', '{}'::jsonb, $7, $8)
ON CONFLICT DO NOTHING
RETURNING id
`,
[
row.batchNo || '',
row.skuCode,
row.credentialType || 'claim_code',
inventoryGroupCode,
displayValue,
payloadJson,
row.createdAt,
row.updatedAt,
],
)
if (result.rows[0]?.id) {
created += 1
}
}
return created
}
export async function listInventoryGroupCodes({
keyword = '',
limit = 100,
}: InventoryGroupListInput = {}): Promise<string[]> {
const params: unknown[] = []
const filters = [`inventory_group_code <> ''`]
if (keyword) {
params.push(`%${String(keyword || '').trim()}%`)
filters.push(`inventory_group_code ILIKE $${params.length}`)
}
const normalizedLimit = Number.isFinite(Number(limit))
? Math.max(1, Math.min(Number(limit), 200))
: 100
params.push(normalizedLimit)
const result = await query<{ [column: string]: unknown, inventory_group_code: string }>(
`
SELECT inventory_group_code
FROM inventory_items
WHERE ${filters.join(' AND ')}
GROUP BY inventory_group_code
ORDER BY inventory_group_code ASC
LIMIT $${params.length}
`,
params,
)
return result.rows.map((row) => String(row.inventory_group_code || '').trim()).filter(Boolean)
}
function normalizeInventoryGroupCodes(inventoryGroupCodes: InventoryGroupCodesInput): string[] | null {
if (inventoryGroupCodes == null) {
return null
}
return Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : [inventoryGroupCodes])
.map((value) => String(value || '').trim())
.filter(Boolean)))
}
export async function releaseReservedInventoryItem(
inventoryItemId: number | string,
updatedAt: string,
): Promise<InventoryItemRow | null> {
return withTransaction(async (client) => {
await client.query(
`
UPDATE task_inventory_bindings
SET binding_status = 'released', released_at = $1, updated_at = $1
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
`,
[updatedAt, Number(inventoryItemId)],
)
await client.query(
`
UPDATE inventory_items
SET status = 'available', updated_at = $1
WHERE id = $2 AND status = 'reserved'
`,
[updatedAt, Number(inventoryItemId)],
)
return getInventoryItemById(inventoryItemId)
})
}
export async function invalidateInventoryItem(
inventoryItemId: number | string,
invalidReason: string,
updatedAt: string,
): Promise<InventoryItemRow | null> {
const result = await query<{ [column: string]: unknown, id: number }>(
`
UPDATE inventory_items
SET status = 'invalid', invalid_reason = $1, updated_at = $2
WHERE id = $3 AND status = 'available'
RETURNING id
`,
[invalidReason, updatedAt, Number(inventoryItemId)],
)
if (!result.rows[0]) {
return getInventoryItemById(inventoryItemId)
}
return getInventoryItemById(inventoryItemId)
}
export async function invalidateReservedInventoryItem(
inventoryItemId: number | string,
invalidReason: string,
updatedAt: string,
): Promise<InventoryItemRow | null> {
return withTransaction(async (client) => {
await client.query(
`
UPDATE task_inventory_bindings
SET binding_status = 'released', released_at = $1, updated_at = $1
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
`,
[updatedAt, Number(inventoryItemId)],
)
await client.query(
`
UPDATE inventory_items
SET status = 'invalid', invalid_reason = $1, updated_at = $2
WHERE id = $3
`,
[String(invalidReason || '').trim(), updatedAt, Number(inventoryItemId)],
)
return getInventoryItemById(inventoryItemId)
})
}
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { resolveOrderItemSyncPlan } from './order-item-repo.js'
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated webhook syncs', () => {
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => {
const existingItems = [
{
id: 15,
@@ -1,105 +0,0 @@
import { query } from '../db/client.js'
import type {
TaskInventoryBindingRow,
TaskInventoryBindingSummaryRow,
} from '../types/repository-rows.js'
export async function listTaskInventoryBindingsByTaskId(
taskId: number | string,
): Promise<TaskInventoryBindingRow[]> {
const result = await query<TaskInventoryBindingRow>(
`
SELECT
tib.id,
tib.task_id,
tib.inventory_item_id,
tib.role_key,
tib.quantity,
tib.binding_status,
tib.consumed_at,
tib.released_at,
tib.metadata_json,
tib.created_at,
tib.updated_at,
ii.sku_code,
ii.batch_no,
ii.credential_type,
ii.inventory_group_code,
ii.display_value,
ii.status AS inventory_item_status,
ii.invalid_reason
FROM task_inventory_bindings tib
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
WHERE tib.task_id = $1
ORDER BY tib.id ASC
`,
[Number(taskId)],
)
return result.rows
}
export async function getTaskInventoryBindingById(
bindingId: number | string,
): Promise<TaskInventoryBindingRow | null> {
const result = await query<TaskInventoryBindingRow>(
`
SELECT
tib.id,
tib.task_id,
tib.inventory_item_id,
tib.role_key,
tib.quantity,
tib.binding_status,
tib.consumed_at,
tib.released_at,
tib.metadata_json,
tib.created_at,
tib.updated_at,
ii.sku_code,
ii.batch_no,
ii.credential_type,
ii.inventory_group_code,
ii.display_value,
ii.status AS inventory_item_status,
ii.invalid_reason
FROM task_inventory_bindings tib
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
WHERE tib.id = $1
LIMIT 1
`,
[Number(bindingId)],
)
return result.rows[0] || null
}
export async function listTaskInventoryBindingSummariesByTaskIds(
taskIds: unknown[] = [],
): Promise<TaskInventoryBindingSummaryRow[]> {
const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : [])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)))
if (normalizedTaskIds.length === 0) {
return []
}
const result = await query<TaskInventoryBindingSummaryRow>(
`
SELECT
tib.task_id,
COUNT(*)::int AS total_binding_count,
COUNT(*) FILTER (WHERE tib.binding_status = 'reserved')::int AS reserved_binding_count,
COUNT(*) FILTER (WHERE tib.binding_status = 'consumed')::int AS consumed_binding_count,
COUNT(*) FILTER (WHERE tib.binding_status = 'released')::int AS released_binding_count,
ARRAY_AGG(DISTINCT tib.role_key ORDER BY tib.role_key) AS role_keys
FROM task_inventory_bindings tib
WHERE tib.task_id = ANY($1::bigint[])
GROUP BY tib.task_id
`,
[normalizedTaskIds],
)
return result.rows
}
+18 -44
View File
@@ -42,35 +42,13 @@ const TASK_FIELDS = `
ctx.partition_name,
ctx.screenshot_path,
ctx.artifacts_json,
ctx.state_json,
inv.inventory_item_id AS primary_inventory_item_id,
inv.display_value AS primary_inventory_display_value,
inv.credential_type AS primary_inventory_credential_type,
inv.binding_status AS primary_inventory_binding_status
ctx.state_json
`
const TASK_JOINS = `
FROM fulfillment_tasks ft
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
LEFT JOIN LATERAL (
SELECT
tib.inventory_item_id,
tib.binding_status,
ii.display_value,
ii.credential_type
FROM task_inventory_bindings tib
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
WHERE tib.task_id = ft.id AND tib.binding_status IN ('reserved', 'consumed')
ORDER BY
CASE tib.binding_status
WHEN 'reserved' THEN 0
WHEN 'consumed' THEN 1
ELSE 2
END ASC,
tib.id DESC
LIMIT 1
) inv ON TRUE
`
function buildTaskSelect(extraFields = ''): string {
@@ -106,7 +84,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
profile_id,
executor_key,
task_status,
inventory_status,
delivery_status,
result_code,
result_message,
@@ -123,7 +100,7 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24::jsonb, $25, $26
$20, $21, $22, $23::jsonb, $24, $25
)
RETURNING id
`,
@@ -140,7 +117,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
input.profileId,
input.executorKey,
input.taskStatus || 'pending_payment',
input.inventoryStatus || 'pending',
input.deliveryStatus || 'pending',
input.resultCode || '',
input.resultMessage || '',
@@ -180,27 +156,25 @@ export async function updateTask(taskId: number | string, patch: TaskUpdatePatch
UPDATE fulfillment_tasks
SET
task_status = $1,
inventory_status = $2,
delivery_status = $3,
result_code = $4,
result_message = $5,
claim_token = $6,
claim_expires_at = $7,
automation_mode = $8,
requires_claim = $9,
user_action_status = $10,
attempt_count = $11,
last_error = $12,
context_json = $13::jsonb,
claimed_at = $14,
role_confirmed_at = $15,
redeemed_at = $16,
updated_at = $17
WHERE id = $18
delivery_status = $2,
result_code = $3,
result_message = $4,
claim_token = $5,
claim_expires_at = $6,
automation_mode = $7,
requires_claim = $8,
user_action_status = $9,
attempt_count = $10,
last_error = $11,
context_json = $12::jsonb,
claimed_at = $13,
role_confirmed_at = $14,
redeemed_at = $15,
updated_at = $16
WHERE id = $17
`,
[
next.task_status,
next.inventory_status,
next.delivery_status,
next.result_code,
next.result_message,
@@ -1,181 +0,0 @@
import { query } from '../db/client.js'
import type {
WebhookEventCreateInput,
WebhookEventListQueryInput,
WebhookEventUpdatePatch,
} from '../types/repository-inputs.js'
import type {
WebhookEventListQueryResult,
WebhookEventRow,
} from '../types/repository-rows.js'
export async function createWebhookEvent(input: WebhookEventCreateInput): Promise<WebhookEventRow | null> {
const result = await query<WebhookEventRow>(
`
INSERT INTO webhook_events (
provider,
platform,
shop_id,
shop_name,
event_type,
event_key,
signature_valid,
headers_json,
query_json,
body_json,
processed,
process_error,
related_order_id,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12, $13, $14)
RETURNING *
`,
[
input.provider,
input.platform,
input.shopId || '',
input.shopName || '',
input.eventType,
input.eventKey,
Boolean(input.signatureValid),
input.headersJson || '{}',
input.queryJson || '{}',
input.bodyJson || '{}',
Boolean(input.processed),
input.processError || '',
input.relatedOrderId || null,
input.createdAt,
],
)
return result.rows[0] || null
}
export async function updateWebhookEvent(
eventId: number | string,
patch: WebhookEventUpdatePatch,
): Promise<WebhookEventRow | null> {
const current = await getWebhookEventById(eventId)
if (!current) {
return null
}
const next = { ...current, ...patch }
const result = await query<WebhookEventRow>(
`
UPDATE webhook_events
SET
processed = $1,
process_error = $2,
related_order_id = $3
WHERE id = $4
RETURNING *
`,
[Boolean(next.processed), next.process_error || '', next.related_order_id || null, Number(eventId)],
)
return result.rows[0] || null
}
export async function getWebhookEventById(eventId: number | string): Promise<WebhookEventRow | null> {
const result = await query<WebhookEventRow>('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [
Number(eventId),
])
return result.rows[0] || null
}
export async function listWebhookEvents({
page = 1,
pageSize = 20,
provider = '',
platform = '',
platformOrderId = '',
processed = '',
visibility = 'important',
relatedOrderId = '',
dateFrom = '',
dateTo = '',
}: WebhookEventListQueryInput = {}): Promise<WebhookEventListQueryResult> {
const offset = (page - 1) * pageSize
const filters: string[] = []
const params: unknown[] = []
if (provider) {
params.push(provider)
filters.push(`provider = $${params.length}`)
}
if (platform) {
params.push(platform)
filters.push(`platform = $${params.length}`)
}
if (platformOrderId) {
params.push(`%${platformOrderId}%`)
filters.push(`(event_key ILIKE $${params.length} OR body_json::text ILIKE $${params.length})`)
}
if (processed === '0' || processed === '1') {
params.push(processed === '1')
filters.push(`processed = $${params.length}`)
}
if (visibility === 'ignored') {
filters.push(`process_error LIKE 'ignored_%'`)
} else if (visibility === 'important') {
filters.push(`(process_error = '' OR process_error NOT LIKE 'ignored_%')`)
}
if (relatedOrderId) {
params.push(Number(relatedOrderId))
filters.push(`related_order_id = $${params.length}`)
}
if (dateFrom) {
params.push(dateFrom)
filters.push(`created_at >= $${params.length}`)
}
if (dateTo) {
params.push(dateTo)
filters.push(`created_at <= $${params.length}`)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const totalResult = await query<{ [column: string]: unknown, total: number }>(
`SELECT COUNT(*)::int AS total FROM webhook_events ${whereClause}`,
params,
)
params.push(pageSize)
params.push(offset)
const itemsResult = await query<WebhookEventRow>(
`
SELECT *
FROM webhook_events
${whereClause}
ORDER BY id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
)
return {
items: itemsResult.rows,
total: Number(totalResult.rows[0]?.total || 0),
}
}
export async function listWebhookEventsByOrderId(orderId: number | string): Promise<WebhookEventRow[]> {
const result = await query<WebhookEventRow>(
`
SELECT *
FROM webhook_events
WHERE related_order_id = $1
ORDER BY id DESC
`,
[Number(orderId)],
)
return result.rows
}
-4
View File
@@ -3,13 +3,11 @@ import { Router } from "express";
import authRouter from "./admin/auth.js";
import auditLogsRouter from "./admin/audit-logs.js";
import dashboardRouter from "./admin/dashboard.js";
import inventoryRouter from "./admin/inventory.js";
import ordersRouter from "./admin/orders.js";
import platformConfigRouter from "./admin/platform-config.js";
import { requireAdminSession } from "./admin/shared.js";
import tasksRouter from "./admin/tasks.js";
import usersRouter from "./admin/users.js";
import webhookEventsRouter from "./admin/webhook-events.js";
import { buildNotFoundPayload } from "../utils/http.js";
const router = Router();
@@ -22,8 +20,6 @@ router.use(auditLogsRouter);
router.use(platformConfigRouter);
router.use(ordersRouter);
router.use(tasksRouter);
router.use(inventoryRouter);
router.use(webhookEventsRouter);
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req));
-149
View File
@@ -1,149 +0,0 @@
import { Router } from "express";
import {
createAdminInventoryItem,
importAdminInventoryItems,
invalidateAdminInventoryItem,
releaseAdminInventoryItem,
} from "../../services/admin/write/inventory.js";
import {
getAdminInventoryItems,
getAdminInventorySkuSuggestions,
} from "../../services/admin/admin-read-service.js";
import { createJsonHandler, requireAdminRoles } from "./shared.js";
import type {
AdminInventoryCreateRouteBody,
AdminInventoryImportRouteBody,
AdminInventoryInvalidateRouteBody,
AdminInventoryRouteParams,
AdminInventoryRouteQuery,
AdminInventorySkuSuggestionRouteQuery,
} from "../../types/admin-route-inputs.js";
import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js";
const router = Router();
router.use("/inventory", requireAdminRoles(["admin", "operator"]));
router.get(
"/inventory",
createJsonHandler(
(req) =>
getAdminInventoryItems(
req.query as AdminInventoryRouteQuery,
req.adminSession || null
),
{
successMessage: "ok",
errorMessage: "读取库存列表失败",
scope: "[admin/inventory]",
}
)
);
router.get(
"/inventory/sku-suggestions",
createJsonHandler(
(req) =>
getAdminInventorySkuSuggestions(
req.query as AdminInventorySkuSuggestionRouteQuery,
req.adminSession || null
),
{
successMessage: "ok",
errorMessage: "读取内部 SKU 建议失败",
scope: "[admin/inventory/sku-suggestions]",
}
)
);
router.post(
"/inventory",
createJsonHandler(
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
{
successMessage: "库存项已新增",
errorMessage: "新增库存项失败",
scope: "[admin/inventory]",
}
)
);
router.post(
"/inventory/import",
createJsonHandler(
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
{
successMessage: "导入完成",
errorMessage: "导入库存凭据失败",
scope: "[admin/inventory/import]",
}
)
);
router.post(
"/inventory/:inventoryItemId/release",
requireAdminRoles(["admin"]),
createJsonHandler(
(req) =>
releaseAdminInventoryItem(
(req.params as AdminInventoryRouteParams).inventoryItemId
),
{
successMessage: "库存项已释放",
errorMessage: "释放库存项失败",
scope: "[admin/inventory/:inventoryItemId/release]",
audit: (req, data) => {
const result = data as AdminInventoryMutationResponse;
return {
action: "inventory_item_released",
targetType: "inventory_item",
targetId: String(
(req.params as AdminInventoryRouteParams).inventoryItemId
),
data: {
inventoryItemId: result.inventoryItem?.inventoryItemId,
skuCode: result.inventoryItem?.skuCode,
displayValue: result.inventoryItem?.displayValue,
},
};
},
}
)
);
router.post(
"/inventory/:inventoryItemId/invalidate",
requireAdminRoles(["admin"]),
createJsonHandler(
(req) =>
invalidateAdminInventoryItem(
(req.params as AdminInventoryRouteParams).inventoryItemId,
req.body as AdminInventoryInvalidateRouteBody
),
{
successMessage: "库存项已作废",
errorMessage: "作废库存项失败",
scope: "[admin/inventory/:inventoryItemId/invalidate]",
audit: (req, data) => {
const result = data as AdminInventoryMutationResponse;
return {
action: "inventory_item_invalidated",
targetType: "inventory_item",
targetId: String(
(req.params as AdminInventoryRouteParams).inventoryItemId
),
data: {
inventoryItemId: result.inventoryItem?.inventoryItemId,
skuCode: result.inventoryItem?.skuCode,
invalidReason: result.inventoryItem?.invalidReason,
},
};
},
}
)
);
export default router;
-23
View File
@@ -4,7 +4,6 @@ import {
createManagedAdminUser,
getAdminUserList,
resetManagedAdminUserPassword,
updateManagedAdminUserInventoryGroups,
updateManagedAdminUserRole,
updateManagedAdminUserStatus,
} from '../../services/admin/admin-auth-service.js'
@@ -16,7 +15,6 @@ type AdminUserMutationResult = {
username: string
role: string
status?: string
inventoryGroupCodes?: string[]
}
}
@@ -97,27 +95,6 @@ router.post('/users/:userId/status', createJsonHandler(
},
))
router.post('/users/:userId/inventory-groups', createJsonHandler(
(req) => updateManagedAdminUserInventoryGroups(String(req.params.userId), req.body, req.adminSession),
{
successMessage: '用户库存组已更新',
errorMessage: '更新用户库存组失败',
scope: '[admin/users/:userId/inventory-groups]',
audit: (_req, data) => {
const result = data as AdminUserMutationResult
return {
action: 'admin_user_inventory_groups_updated',
targetType: 'admin_user',
targetId: String(result.user.userId),
data: {
username: result.user.username,
inventoryGroupCodes: result.user.inventoryGroupCodes,
},
}
},
},
))
router.post('/users/:userId/reset-password', createJsonHandler(
(req) => resetManagedAdminUserPassword(String(req.params.userId), req.body),
{
@@ -1,76 +0,0 @@
import { Router } from "express";
import {
getAdminWebhookEventDetail,
getAdminWebhookEvents,
} from "../../services/admin/admin-read-service.js";
import { createHttpError } from "../../utils/http.js";
import { createJsonHandler, requireAdminRoles } from "./shared.js";
import type {
AdminWebhookEventRouteParams,
AdminWebhookEventRouteQuery,
} from "../../types/admin-route-inputs.js";
const router = Router();
router.use("/webhook-events", requireAdminRoles(["admin", "operator"]));
router.get(
"/webhook-events",
createJsonHandler(
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
{
successMessage: "ok",
errorMessage: "读取 webhook 列表失败",
scope: "[admin/webhook-events]",
}
)
);
router.get(
"/webhook-events/:eventId",
createJsonHandler(
(req) =>
getAdminWebhookEventDetail(
(req.params as AdminWebhookEventRouteParams).eventId
),
{
successMessage: "ok",
errorMessage: "读取 webhook 详情失败",
scope: "[admin/webhook-events/:eventId]",
}
)
);
router.post(
"/webhook-events/:eventId/replay",
requireAdminRoles(["admin"]),
createJsonHandler(
() => {
throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
statusCode: 409,
errorCode: "admin_webhook_replay_disabled",
});
},
{
successMessage: "Webhook 已重放",
errorMessage: "重放 webhook 失败",
scope: "[admin/webhook-events/:eventId/replay]",
audit: (req, data) => {
return {
action: "webhook_replayed",
targetType: "webhook_event",
targetId: String(
(req.params as AdminWebhookEventRouteParams).eventId
),
data: {
disabled: true,
result: data,
},
};
},
}
)
);
export default router;
+7 -7
View File
@@ -3,7 +3,7 @@ import { Router } from 'express'
import { createOpen91Order } from '../services/open-91/order-create-service.js'
import { queryOpen91Order } from '../services/open-91/order-query-service.js'
import { buildOpen91ErrorResponse } from '../services/open-91/shared.js'
import { createRequestId, logWebhook } from '../utils/logger.js'
import { createRequestId, logIntegration } from '../utils/logger.js'
const router = Router()
@@ -11,7 +11,7 @@ router.post('/orders/create', async (req, res) => {
const requestId = createRequestId('91')
const startedAt = Date.now()
logWebhook('[open-91/create]', '收到 91卡券异步下单请求', {
logIntegration('[open-91/create]', '收到 91卡券异步下单请求', {
requestId,
method: req.method,
originalUrl: req.originalUrl,
@@ -21,7 +21,7 @@ router.post('/orders/create', async (req, res) => {
try {
const result = await createOpen91Order(req.body, { requestId })
logWebhook('[open-91/create]', '91卡券异步下单处理完成', {
logIntegration('[open-91/create]', '91卡券异步下单处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result,
@@ -30,7 +30,7 @@ router.post('/orders/create', async (req, res) => {
} catch (error) {
const message = error instanceof Error ? error.message : '系统错误'
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
logWebhook('[open-91/create]', '91卡券异步下单处理失败', {
logIntegration('[open-91/create]', '91卡券异步下单处理失败', {
requestId,
durationMs: Date.now() - startedAt,
error,
@@ -43,7 +43,7 @@ router.post('/orders/query', async (req, res) => {
const requestId = createRequestId('91')
const startedAt = Date.now()
logWebhook('[open-91/query]', '收到 91卡券订单查询请求', {
logIntegration('[open-91/query]', '收到 91卡券订单查询请求', {
requestId,
method: req.method,
originalUrl: req.originalUrl,
@@ -53,7 +53,7 @@ router.post('/orders/query', async (req, res) => {
try {
const result = await queryOpen91Order(req.body, { requestId })
logWebhook('[open-91/query]', '91卡券订单查询处理完成', {
logIntegration('[open-91/query]', '91卡券订单查询处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result,
@@ -62,7 +62,7 @@ router.post('/orders/query', async (req, res) => {
} catch (error) {
const message = error instanceof Error ? error.message : '系统错误'
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
logWebhook('[open-91/query]', '91卡券订单查询处理失败', {
logIntegration('[open-91/query]', '91卡券订单查询处理失败', {
requestId,
durationMs: Date.now() - startedAt,
error,
@@ -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'
@@ -1,5 +1,4 @@
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js'
import { getOrderItemById } from '../../repositories/order-item-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
@@ -228,15 +227,10 @@ async function expireClaimContext(claimToken, task) {
let nextTask = task
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
if (task.primary_inventory_item_id) {
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
}
nextTask = await updateTask(task.id, {
task_status: 'expired',
inventory_status: 'pending',
user_action_status: 'expired',
last_error: '领取链接已过期,预占库存项已释放',
last_error: '领取链接已过期',
updated_at: now,
})
}
@@ -275,7 +275,6 @@ export async function prepareKuaishouCloudFulfillmentTask(
const updatedTask = await updateTask(task.id, {
task_status: "waiting_binding",
inventory_status: "not_required",
user_action_status: "pending_claim",
claim_token: claimLinkState.token || task.claim_token || "",
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
@@ -221,14 +221,14 @@ export function notifyClaimRedeemNeedsAttention({
const orderRecord = toRecord(order)
return notifyInternalSafely({
title: status === 'waiting_inventory' ? '库存不足,任务等待补货' : '兑换失败,任务需关注',
title: '兑换失败,任务需关注',
body: [
formatTaskLine(taskRecord),
`订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`,
`状态:${String(status || taskRecord.task_status || '').trim() || '-'}`,
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
].join('\n'),
category: status === 'waiting_inventory' ? 'task_waiting_inventory' : 'claim_redeem_failed',
category: 'claim_redeem_failed',
cooldownKey: ['claim_redeem_needs_attention', taskRecord.id || '', status].join(':'),
})
}
@@ -1,5 +1,5 @@
import { upsertOrderFromSource } from '../order/order-service.js'
import { logWebhook } from '../../utils/logger.js'
import { logIntegration } from '../../utils/logger.js'
import { parseAmountToFen } from '../../utils/money.js'
import {
buildOpen91SourceEvent,
@@ -45,7 +45,7 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
sourceLabel: 'open91',
})
logWebhook('[open-91/create]', '91卡券下单已处理', {
logIntegration('[open-91/create]', '91卡券下单已处理', {
requestId,
orderNo: normalized.orderNo,
productNo: normalized.productNo,
@@ -58,7 +58,7 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
if (result.ignored) {
const pending = await upsertOpen91PendingOrder(normalized, config)
logWebhook('[open-91/create]', '91卡券订单已进入待补全队列', {
logIntegration('[open-91/create]', '91卡券订单已进入待补全队列', {
requestId,
orderNo: normalized.orderNo,
productNo: normalized.productNo,
@@ -2,7 +2,7 @@ import { findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { buildClaimUrl } from '../claim/claim-service.js'
import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud-task-service.js'
import { logWebhook } from '../../utils/logger.js'
import { logIntegration } from '../../utils/logger.js'
import {
OPEN_91_MANUAL_FAILED_STATUS,
OPEN_91_PENDING_CONFIG_STATUS,
@@ -111,7 +111,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) {
readyTaskIds,
})
logWebhook('[open-91/query]', '91卡券订单查询已处理', {
logIntegration('[open-91/query]', '91卡券订单查询已处理', {
requestId,
orderNo: normalized.orderNo,
orderId: order.id,
@@ -154,7 +154,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) {
cards: encryptedCards,
}
logWebhook('[open-91/query]', '91卡券订单查询返回 cards 已生成', {
logIntegration('[open-91/query]', '91卡券订单查询返回 cards 已生成', {
requestId,
orderNo: normalized.orderNo,
orderId: order.id,
@@ -31,58 +31,12 @@ function createTaskFixture(patch = {}) {
requires_claim: true,
claim_token: '',
primary_claim_token_id: null,
primary_inventory_item_id: null,
last_error: '',
context_json: JSON.stringify({
inventorySkuCode: 'dnf-cdk-a',
primaryRequirement: {
roleKey: 'primary_code',
credentialType: 'claim_code',
},
}),
context_json: '{}',
...patch,
}
}
test('syncDeliveryTasksForOrderWithDeps moves paid claim task to waiting inventory when reservation fails', async () => {
const updates = []
const reserveCalls = []
const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, {
listTasksByOrderId: async () => [createTaskFixture()],
reserveInventoryForTask: async (payload) => {
reserveCalls.push(payload)
return null
},
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch })
return { id: taskId, ...patch }
},
nowIso: () => '2026-04-14T12:00:00.000Z',
})
assert.deepEqual(reserveCalls, [
{
skuCode: 'dnf-cdk-a',
taskId: 30,
credentialType: 'claim_code',
roleKey: 'primary_code',
},
])
assert.deepEqual(updates, [
{
taskId: 30,
patch: {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '库存不足,等待可用库存凭据',
updated_at: '2026-04-14T12:00:00.000Z',
},
},
])
assert.equal(result[0]?.task_status, 'waiting_inventory')
})
test('syncDeliveryTasksForOrderWithDeps sends manual dispatch tasks to manual review', async () => {
const notifications = []
const updates = []
@@ -150,7 +104,6 @@ test('syncDeliveryTasksForOrderWithDeps prepares kuaishou cloud task with claim
taskId: 30,
patch: {
task_status: 'pending_binding_prepare',
inventory_status: 'not_required',
claim_token: 'claim-token',
claim_expires_at: '2026-04-15T12:00:00.000Z',
user_action_status: 'pending_claim',
@@ -1,14 +1,12 @@
import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
import {
listFulfillmentProfileRequirements,
resolveFulfillmentBinding,
} from '../../repositories/fulfillment-profile-repo.js'
import { reserveInventoryForTask } from './inventory-service.js'
import { createTaskClaimToken } from '../claim/claim-service.js'
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import type { InventoryItemRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
type JsonObject = Record<string, unknown>
@@ -31,15 +29,6 @@ type FulfillmentBindingLike = {
[key: string]: unknown
}
type FulfillmentRequirementLike = {
role_key?: string
roleKey?: string
credential_type?: string
credentialType?: string
is_required?: boolean
[key: string]: unknown
}
type DeliveryTaskRow = TaskRow & {
skuCode?: string
skuName?: string
@@ -49,21 +38,12 @@ type DeliveryTaskDeps = {
createTask?: typeof createTask
listTasksByOrderId?: typeof listTasksByOrderId
updateTask?: typeof updateTask
listFulfillmentProfileRequirements?: (
profileId: number | string,
) => Promise<FulfillmentRequirementLike[]>
resolveFulfillmentBinding?: (input: {
skuCode: string
provider?: string
platform?: string
shopId?: string
}) => Promise<FulfillmentBindingLike | null>
reserveInventoryForTask?: (input: {
skuCode: string
taskId: number | string
credentialType?: string
roleKey?: string
}) => Promise<InventoryItemRow | null>
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
notifyTaskAutoManualReview?: (payload: {
task: unknown
@@ -77,18 +57,12 @@ type DeliveryTaskDeps = {
type RuntimeDeliveryTaskDeps = Required<Pick<
DeliveryTaskDeps,
'updateTask'
| 'reserveInventoryForTask'
| 'createTaskClaimToken'
| 'notifyTaskAutoManualReview'
| 'nowIso'
>>
type TaskContext = {
inventorySkuCode?: string
primaryRequirement?: {
roleKey?: string
credentialType?: string
} | null
[key: string]: unknown
}
@@ -108,9 +82,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
createTask: createDeliveryTask = createTask,
listTasksByOrderId: listTasks = listTasksByOrderId,
updateTask: updateDeliveryTask = updateTask,
listFulfillmentProfileRequirements: listProfileRequirements = listFulfillmentProfileRequirements,
resolveFulfillmentBinding: resolveBinding = resolveFulfillmentBinding,
reserveInventoryForTask: reserveInventory = reserveInventoryForTask,
createTaskClaimToken: createClaimToken = createTaskClaimToken,
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
nowIso: getNowIso = nowIso,
@@ -119,7 +91,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
const runtimeDeps: RuntimeDeliveryTaskDeps = {
updateTask: updateDeliveryTask,
reserveInventoryForTask: reserveInventory,
createTaskClaimToken: createClaimToken,
notifyTaskAutoManualReview: notifyManualReview,
nowIso: getNowIso,
@@ -154,8 +125,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
continue
}
const profile = binding
const requirements = await listProfileRequirements(profile.profile_id || profile.id)
const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null
const quantity = Math.max(1, Number(item.quantity || 1))
const fulfillmentConfig = parseJsonObject(profile.config_json)
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
@@ -181,7 +150,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
profileId: Number(profile.profile_id || profile.id),
executorKey: String(profile.executor_key || 'manual_dispatch'),
taskStatus: initialStatus,
inventoryStatus: profile.requires_claim ? 'pending' : 'not_required',
deliveryStatus: 'pending',
resultCode: '',
resultMessage: '',
@@ -197,13 +165,6 @@ export async function syncDeliveryTasksForOrderWithDeps(
profileName: String(profile.profile_name || profile.name || ''),
skuCode: item.sku_code,
skuName: item.sku_name,
inventorySkuCode: item.sku_code,
primaryRequirement: primaryRequirement
? {
roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'),
credentialType: String(primaryRequirement.credential_type || primaryRequirement.credentialType || 'claim_code'),
}
: null,
kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key)
? {
flowType: 'kuaishou_cloud_fulfillment',
@@ -303,16 +264,12 @@ async function preparePaidTask(
): Promise<TaskRow | DeliveryTaskRow | null> {
const {
updateTask: updateDeliveryTask = updateTask,
reserveInventoryForTask: reserveInventory = reserveInventoryForTask,
createTaskClaimToken: createClaimToken = createTaskClaimToken,
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
nowIso: getNowIso = nowIso,
} = deps
const now = getNowIso()
const taskContext = parseTaskContext(task)
const primaryRequirement = taskContext.primaryRequirement || null
const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim()
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed'].includes(task.task_status)) {
return task
@@ -344,7 +301,6 @@ async function preparePaidTask(
return updateDeliveryTask(task.id, {
task_status: 'pending_binding_prepare',
inventory_status: 'not_required',
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
@@ -357,7 +313,6 @@ async function preparePaidTask(
const lastError = task.last_error || '当前任务需要人工履约处理'
const updatedTask = await updateDeliveryTask(task.id, {
task_status: 'manual_review',
inventory_status: 'not_required',
user_action_status: 'not_required',
last_error: lastError,
updated_at: now,
@@ -370,71 +325,7 @@ async function preparePaidTask(
return updatedTask
}
if (!inventorySkuCode) {
const lastError = '未匹配到 SKU,无法为任务分配库存凭据'
const updatedTask = await updateDeliveryTask(task.id, {
task_status: 'manual_review',
last_error: lastError,
updated_at: now,
})
await notifyManualReview({
task: updatedTask || task,
reason: lastError,
source: 'missing_sku',
})
return updatedTask
}
if (task.primary_inventory_item_id && task.primary_claim_token_id) {
return updateDeliveryTask(task.id, {
task_status: 'link_generated',
last_error: '',
updated_at: now,
})
}
if (!primaryRequirement?.credentialType) {
const lastError = '履约档案未配置库存要求,无法自动分配库存'
const updatedTask = await updateDeliveryTask(task.id, {
task_status: 'manual_review',
last_error: lastError,
updated_at: now,
})
await notifyManualReview({
task: updatedTask || task,
reason: lastError,
source: 'missing_inventory_requirement',
})
return updatedTask
}
const reserved = await reserveInventory({
skuCode: inventorySkuCode,
taskId: task.id,
credentialType: primaryRequirement.credentialType,
roleKey: primaryRequirement.roleKey || 'primary_code',
})
if (!reserved) {
return updateDeliveryTask(task.id, {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '库存不足,等待可用库存凭据',
updated_at: now,
})
}
const claimToken = await createClaimToken(task.id)
return updateDeliveryTask(task.id, {
task_status: 'link_generated',
inventory_status: 'reserved',
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
last_error: '',
updated_at: now,
})
return task
}
function resolvePaidTaskStatus(profile: FulfillmentBindingLike | null | undefined): string {
@@ -1,110 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
releaseReservedInventoryItemsWithDeps,
reserveInventoryForTaskWithDeps,
} from './inventory-service.js'
test('reserveInventoryForTaskWithDeps returns null when skuCode is missing', async () => {
let findCalled = false
const result = await reserveInventoryForTaskWithDeps(
{ skuCode: '', taskId: 12 },
{
findFirstAvailableInventoryItemBySkuCode: async () => {
findCalled = true
return null
},
},
)
assert.equal(result, null)
assert.equal(findCalled, false)
})
test('reserveInventoryForTaskWithDeps reserves first available inventory item with defaults', async () => {
const calls = []
const result = await reserveInventoryForTaskWithDeps(
{ skuCode: 'dnf-cdk-a', taskId: 88 },
{
findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => {
calls.push(['find', skuCode, credentialType, inventoryGroupCodes])
return { id: 321 }
},
assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => {
calls.push(['assign', inventoryItemId, taskId, updatedAt, roleKey])
return { id: inventoryItemId, reserved_by_task_id: taskId, role_key: roleKey, updated_at: updatedAt }
},
nowIso: () => '2026-04-14T12:00:00.000Z',
},
)
assert.deepEqual(calls, [
['find', 'dnf-cdk-a', 'claim_code', null],
['assign', 321, 88, '2026-04-14T12:00:00.000Z', 'primary_code'],
])
assert.equal(result?.id, 321)
assert.equal(result?.reserved_by_task_id, 88)
})
test('reserveInventoryForTaskWithDeps forwards inventory group filters', async () => {
const calls = []
await reserveInventoryForTaskWithDeps(
{ skuCode: 'dnf-cdk-a', taskId: 88, inventoryGroupCodes: ['A组', 'B组'] },
{
findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => {
calls.push(['find', skuCode, credentialType, inventoryGroupCodes])
return { id: 321 }
},
assignReservedInventoryItem: async () => ({ id: 321 }),
nowIso: () => '2026-04-14T12:00:00.000Z',
},
)
assert.deepEqual(calls, [
['find', 'dnf-cdk-a', 'claim_code', ['A组', 'B组']],
])
})
test('reserveInventoryForTaskWithDeps returns null when restricted inventory groups are empty', async () => {
let findCalled = false
const result = await reserveInventoryForTaskWithDeps(
{ skuCode: 'dnf-cdk-a', taskId: 12, inventoryGroupCodes: [] },
{
findFirstAvailableInventoryItemBySkuCode: async () => {
findCalled = true
return null
},
},
)
assert.equal(result, null)
assert.equal(findCalled, false)
})
test('releaseReservedInventoryItemsWithDeps skips empty ids and keeps successful releases only', async () => {
const calls = []
const result = await releaseReservedInventoryItemsWithDeps(
[0, null, 11, 12, '', 13],
'2026-04-14T13:00:00.000Z',
{
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
calls.push([inventoryItemId, updatedAt])
return inventoryItemId === 12 ? null : { id: inventoryItemId }
},
},
)
assert.deepEqual(calls, [
[11, '2026-04-14T13:00:00.000Z'],
[12, '2026-04-14T13:00:00.000Z'],
[13, '2026-04-14T13:00:00.000Z'],
])
assert.deepEqual(result, [
{ id: 11 },
{ id: 13 },
])
})
@@ -1,122 +0,0 @@
import {
assignReservedInventoryItem,
findFirstAvailableInventoryItemBySkuCode,
releaseReservedInventoryItem,
} from '../../repositories/inventory-repo.js'
import type { InventoryItemRow } from '../../types/repository-rows.js'
import { nowIso } from '../../utils/time.js'
type InventoryItemId = number | string | null | undefined
type InventoryGroupCodesInput = string | string[] | null | undefined
type ReserveInventoryForTaskInput = {
skuCode?: string
taskId: number | string
credentialType?: string
roleKey?: string
inventoryGroupCodes?: InventoryGroupCodesInput
}
type ReserveInventoryForTaskDeps = {
assignReservedInventoryItem?: (
inventoryItemId: number | string,
taskId: number | string,
updatedAt: string,
roleKey?: string,
) => Promise<InventoryItemRow | null>
findFirstAvailableInventoryItemBySkuCode?: (
skuCode: string,
credentialType?: string,
inventoryGroupCodes?: InventoryGroupCodesInput,
) => Promise<InventoryItemRow | null>
nowIso?: () => string
}
type ReleaseReservedInventoryItemsDeps = {
releaseReservedInventoryItem?: (
inventoryItemId: number | string,
updatedAt: string,
) => Promise<InventoryItemRow | null>
}
export async function reserveInventoryForTask({
skuCode,
taskId,
credentialType = 'claim_code',
roleKey = 'primary_code',
inventoryGroupCodes = null,
}: ReserveInventoryForTaskInput): Promise<InventoryItemRow | null> {
return reserveInventoryForTaskWithDeps(
{ skuCode, taskId, credentialType, roleKey, inventoryGroupCodes },
{
assignReservedInventoryItem,
findFirstAvailableInventoryItemBySkuCode,
nowIso,
},
)
}
export async function reserveInventoryForTaskWithDeps(
{
skuCode,
taskId,
credentialType = 'claim_code',
roleKey = 'primary_code',
inventoryGroupCodes = null,
}: ReserveInventoryForTaskInput,
{
assignReservedInventoryItem: assignReserved = assignReservedInventoryItem,
findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode,
nowIso: getNowIso = nowIso,
}: ReserveInventoryForTaskDeps = {},
): Promise<InventoryItemRow | null> {
if (!skuCode) {
return null
}
if (Array.isArray(inventoryGroupCodes) && inventoryGroupCodes.length === 0) {
return null
}
const available = await findAvailable(skuCode, credentialType, inventoryGroupCodes)
if (!available) {
return null
}
return assignReserved(available.id, taskId, getNowIso(), roleKey)
}
export async function releaseReservedInventoryItems(
inventoryItemIds: InventoryItemId[] = [],
updatedAt = nowIso(),
): Promise<InventoryItemRow[]> {
return releaseReservedInventoryItemsWithDeps(
inventoryItemIds,
updatedAt,
{ releaseReservedInventoryItem },
)
}
export async function releaseReservedInventoryItemsWithDeps(
inventoryItemIds: InventoryItemId[] = [],
updatedAt = nowIso(),
{
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
}: ReleaseReservedInventoryItemsDeps = {},
): Promise<InventoryItemRow[]> {
const released: InventoryItemRow[] = []
for (const inventoryItemId of inventoryItemIds) {
if (!inventoryItemId) {
continue
}
const item = await releaseReserved(inventoryItemId, updatedAt)
if (item) {
released.push(item)
}
}
return released
}
@@ -1,10 +1,10 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mergeWebhookOrderState } from './order-service.js'
import { mergeSourceOrderState } from './order-service.js'
test('mergeWebhookOrderState keeps paid state when a late trade_create webhook arrives', () => {
const merged = mergeWebhookOrderState(
test('mergeSourceOrderState keeps paid state when a late created source event arrives', () => {
const merged = mergeSourceOrderState(
{
order_status: 'paid',
pay_status: 'paid',
@@ -22,8 +22,8 @@ test('mergeWebhookOrderState keeps paid state when a late trade_create webhook a
assert.equal(merged.paidAt, '2026-04-14T10:06:10.000Z')
})
test('mergeWebhookOrderState upgrades unpaid orders when payment webhook arrives later', () => {
const merged = mergeWebhookOrderState(
test('mergeSourceOrderState upgrades unpaid orders when payment source event arrives later', () => {
const merged = mergeSourceOrderState(
{
order_status: 'created',
pay_status: 'unpaid',
@@ -41,8 +41,8 @@ test('mergeWebhookOrderState upgrades unpaid orders when payment webhook arrives
assert.equal(merged.paidAt, '2026-04-14T10:06:12.000Z')
})
test('mergeWebhookOrderState preserves refunded status over older paid webhook', () => {
const merged = mergeWebhookOrderState(
test('mergeSourceOrderState preserves refunded status over older paid source event', () => {
const merged = mergeSourceOrderState(
{
order_status: 'refunded',
pay_status: 'refunded',
@@ -8,7 +8,7 @@ import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { resolveOrderItemForFulfillment } from './product-match-service.js'
import { nowIso } from '../../utils/time.js'
import { logWebhook } from '../../utils/logger.js'
import { logIntegration } from '../../utils/logger.js'
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
type SourceOrderItem = {
@@ -83,10 +83,6 @@ type OrderStateMergeResult = {
type StatusPriorityMap = Record<string, number>
export async function upsertOrderFromWebhook(event: SourceOrderEvent): Promise<UpsertOrderResult> {
return upsertOrderFromSource(event, { sourceLabel: 'webhook' })
}
export async function upsertOrderFromSource(
event: SourceOrderEvent,
{ sourceLabel = 'source' }: UpsertOrderSourceOptions = {},
@@ -105,7 +101,7 @@ export async function upsertOrderFromSource(
platformOrderId: event.platformOrderId,
})
logWebhook('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, {
logIntegration('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
@@ -126,7 +122,7 @@ export async function upsertOrderFromSource(
const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter((item) => item.isConfigured)
if (configuredItems.length === 0) {
logWebhook('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, {
logIntegration('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
@@ -161,7 +157,7 @@ export async function upsertOrderFromSource(
rawPayloadJson: JSON.stringify(event.rawPayload),
paidAt: event.paidAt,
}
const mergedPayload = mergeWebhookOrderState(existing, basePayload)
const mergedPayload = mergeSourceOrderState(existing, basePayload)
const order = existing
? await updateOrder(existing.id, {
@@ -195,7 +191,7 @@ export async function upsertOrderFromSource(
const tasks = await syncDeliveryTasksForOrder(order, orderItems)
logWebhook('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
orderId: order.id,
provider: order.provider,
platform: order.platform,
@@ -235,7 +231,7 @@ const PAY_STATUS_PRIORITY = {
refunded: 2,
}
export function mergeWebhookOrderState(
export function mergeSourceOrderState(
existing: OrderStateInput | null | undefined,
incoming: OrderStateInput | null | undefined,
): OrderStateMergeResult {
@@ -2,7 +2,6 @@ export type AdminViewerSessionInput = {
userId?: number | string
username?: string
role?: string
allowedInventoryGroups?: string[]
}
export type AdminOrderListQueryInput = {
@@ -27,34 +26,4 @@ export type AdminTaskListQueryInput = {
dateTo?: string
}
export type AdminInventoryListQueryInput = {
page?: number | string
pageSize?: number | string
skuCode?: string
credentialType?: string
status?: string
batchNo?: string
inventoryGroupCode?: string
}
export type AdminInventorySkuSuggestionQueryInput = {
credentialType?: string
keyword?: string
limit?: number | string
inventoryGroupCode?: string
}
export type AdminWebhookEventListQueryInput = {
page?: number | string
pageSize?: number | string
provider?: string
platform?: string
platformOrderId?: string
processed?: string
visibility?: string
relatedOrderId?: string
dateFrom?: string
dateTo?: string
}
export type AdminEntityIdInput = number | string
@@ -63,76 +63,10 @@ export type AdminTaskListItem = {
lastError: string
createdAt: string
updatedAt: string
inventoryDisplayMasked: string
inventoryCredentialType: string
claimToken: string
screenshotPath: string
}
export type AdminInventoryItemListItem = {
inventoryItemId: number
skuCode: string
batchNo: string
credentialType: string
inventoryGroupCode: string
displayValue: string
status: string
reservedByTaskId: number | null
reservedByTaskNo: string
platformOrderId: string
systemBindingStatus: string
userBindingStatus: string
invalidReason: string
deliveredAt: string | null
createdAt: string
updatedAt: string
}
export type AdminWebhookEventListItem = {
eventId: number
provider: string
platform: string
platformRaw: string
shopId: string
shopName: string
eventType: string
eventKey: string
signatureValid: boolean
processed: boolean
processError: string
visibilityLevel: string
relatedOrderId: number | null
platformOrderId: string
buyerId: string
buyerName: string
totalAmount: string
totalAmountFen: number
currency: string
itemCount: number
taskCount: number
messageText: string
requestTimestamp: string
requestSign: string
aopic: string
sourceHost: string
sourceIp: string
userAgent: string
createdAt: string
headers?: Record<string, unknown>
query?: Record<string, unknown>
body?: Record<string, unknown>
payload?: Record<string, unknown>
}
export type AdminInventorySkuSuggestion = {
skuCode: string
credentialType: string
inventoryGroupCode: string
totalCount: number
availableCount: number
latestUpdatedAt: string | null
}
export type AdminPagination = {
page: number
pageSize: number
@@ -148,17 +82,3 @@ export type AdminTaskListResponse = {
items: AdminTaskListItem[]
pagination: AdminPagination
}
export type AdminInventoryListResponse = {
items: AdminInventoryItemListItem[]
pagination: AdminPagination
}
export type AdminWebhookEventListResponse = {
items: AdminWebhookEventListItem[]
pagination: AdminPagination
}
export type AdminInventorySkuSuggestionResponse = {
items: AdminInventorySkuSuggestion[]
}
@@ -1,10 +1,7 @@
import type {
AdminInventoryListQueryInput,
AdminInventorySkuSuggestionQueryInput,
AdminOrderListQueryInput,
AdminTaskListQueryInput,
AdminViewerSessionInput,
AdminWebhookEventListQueryInput,
} from './admin-read-inputs.js'
import type {
AdminCloudtentaclesCatalogQueryInput,
@@ -16,9 +13,6 @@ import type {
AdminCloudtentaclesTestLoginInput,
AdminCloudtentaclesValidateSessionInput,
AdminCloudtentaclesVirtualNumberInput,
AdminInventoryCreateInput,
AdminInventoryImportInput,
AdminInventoryInvalidateInput,
AdminKuaishouCloudFulfillmentConfigInput,
AdminKuaishouEticketConsumeInput,
AdminKuaishouEticketDetailQueryInput,
@@ -29,15 +23,11 @@ import type {
AdminScheduledJobsConfigInput,
AdminTaskKuaishouCloudDispatchInput,
AdminTaskManualDispatchInput,
AdminUserInventoryGroupsInput,
} from './admin-write-inputs.js'
export type AdminInventoryRouteQuery = AdminInventoryListQueryInput
export type AdminInventorySkuSuggestionRouteQuery = AdminInventorySkuSuggestionQueryInput
export type AdminOrderRouteQuery = AdminOrderListQueryInput
export type AdminTaskRouteQuery = AdminTaskListQueryInput
export type AdminRouteAdminSession = AdminViewerSessionInput
export type AdminWebhookEventRouteQuery = AdminWebhookEventListQueryInput
export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
@@ -56,16 +46,10 @@ export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput
export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput
export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput
export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput
export type AdminInventoryCreateRouteBody = AdminInventoryCreateInput
export type AdminInventoryImportRouteBody = AdminInventoryImportInput
export type AdminInventoryInvalidateRouteBody = AdminInventoryInvalidateInput
export type AdminUserInventoryGroupsRouteBody = AdminUserInventoryGroupsInput
export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput
export type AdminTaskKuaishouCloudDispatchRouteBody = AdminTaskKuaishouCloudDispatchInput
export type AdminInventoryRouteParams = { inventoryItemId?: string }
export type AdminOrderRouteParams = { orderId?: string }
export type AdminTaskRouteParams = { taskId?: string, bindingId?: string }
export type AdminWebhookEventRouteParams = { eventId?: string }
export type AdminEntityRouteParams = { id?: string }
export type AdminCloudtentaclesSourceRouteParams = { sourceKey?: string }
@@ -1,32 +1,3 @@
export type AdminInventoryCreateInput = {
skuCode?: string
displayValue?: string
batchNo?: string
credentialType?: string
inventoryGroupCode?: string
}
export type AdminInventoryImportRowInput = {
skuCode?: string
displayValue?: string
batchNo?: string
credentialType?: string
inventoryGroupCode?: string
}
export type AdminInventoryImportInput = {
rows?: AdminInventoryImportRowInput[]
codes?: unknown[]
skuCode?: string
batchNo?: string
credentialType?: string
inventoryGroupCode?: string
}
export type AdminUserInventoryGroupsInput = {
inventoryGroupCodes?: string[]
}
export type AdminKuaishouCloudFulfillmentItemInput = {
id?: string
enabled?: boolean
@@ -249,10 +220,6 @@ export type AdminCloudtentaclesFullFlowInput = {
vnKey?: string
}
export type AdminInventoryInvalidateInput = {
reason?: string
}
export type AdminTaskManualDispatchInput = {
outcome?: string
resultMessage?: string
@@ -1,30 +1,3 @@
import type {
AdminInventoryItemListItem,
AdminInventorySkuSuggestion,
} from './admin-read-models.js'
export type { AdminInventoryItemListItem, AdminInventorySkuSuggestion }
export type AdminInventoryMutationResponse = {
inventoryItem: AdminInventoryItemListItem | null
}
export type AdminInventoryImportResponse = {
total: number
created: number
duplicated: number
}
export type AdminInventoryGroupListResponse = {
items: AdminInventorySkuSuggestion[]
}
export type AdminWebhookReplayResponse = {
eventId: number
replayed: boolean
result: unknown
}
export type AdminTaskActionPayload = {
taskId: number
taskNo: string
@@ -32,7 +5,6 @@ export type AdminTaskActionPayload = {
deliveryStatus: string
resultCode: string
resultMessage: string
inventoryItemId: number | null
primaryClaimTokenId: number | null
lastError: string
updatedAt: string
@@ -44,11 +16,6 @@ export type AdminTaskActionResponse = {
token?: string
}
export type AdminTaskBindingReleaseResponse = AdminTaskActionResponse & {
bindingId: number
inventoryItemId: number
}
export type AdminManualDispatchOutcome = 'delivered' | 'failed'
export type AdminTaskManualDispatchResponse = AdminTaskActionResponse & {
@@ -83,7 +83,6 @@ export type TaskCreateInput = {
profileId: number | string
executorKey: string
taskStatus?: string
inventoryStatus?: string
deliveryStatus?: string
resultCode?: string
resultMessage?: string
@@ -102,7 +101,6 @@ export type TaskCreateInput = {
export type TaskUpdatePatch = {
task_status?: string
inventory_status?: string
delivery_status?: string
result_code?: string
result_message?: string
@@ -129,69 +127,3 @@ export type TaskUpdatePatch = {
artifacts_json?: string | Record<string, unknown>
state_json?: string | Record<string, unknown>
}
export type InventoryListQueryInput = {
page?: number
pageSize?: number
skuCode?: string
credentialType?: string
status?: string
batchNo?: string
inventoryGroupCode?: string
allowedInventoryGroupCodes?: string[] | null
}
export type InventorySkuSuggestionQueryInput = {
credentialType?: string
keyword?: string
limit?: number
inventoryGroupCode?: string
allowedInventoryGroupCodes?: string[] | null
}
export type InventoryCreateItemInput = {
batchNo?: string
skuCode: string
credentialType?: string
inventoryGroupCode?: string
displayValue: string
payload?: Record<string, unknown>
createdAt: string
updatedAt: string
}
export type WebhookEventListQueryInput = {
page?: number
pageSize?: number
provider?: string
platform?: string
platformOrderId?: string
processed?: string
visibility?: string
relatedOrderId?: string
dateFrom?: string
dateTo?: string
}
export type WebhookEventCreateInput = {
provider: string
platform: string
shopId?: string
shopName?: string
eventType: string
eventKey: string
signatureValid?: boolean
headersJson?: string | Record<string, unknown>
queryJson?: string | Record<string, unknown>
bodyJson?: string | Record<string, unknown>
processed?: boolean
processError?: string
relatedOrderId?: number | null
createdAt: string
}
export type WebhookEventUpdatePatch = {
processed?: boolean
process_error?: string
related_order_id?: number | null
}
-91
View File
@@ -46,7 +46,6 @@ export type TaskRow = {
delivery_status: string
result_code: string
result_message: string
inventory_status: string
automation_mode: string
requires_claim: boolean
user_action_status: string
@@ -64,12 +63,6 @@ export type TaskRow = {
primary_claim_token_id: number | null
primary_claim_token_status: string
primary_claim_expires_at?: string | null
primary_inventory_item_id: number | null
primary_inventory_binding_status: string
inventory_display_value: string
primary_inventory_display_value: string
inventory_credential_type?: string
primary_inventory_credential_type?: string
artifacts_json: string | Record<string, unknown>
context_json: string | Record<string, unknown>
screenshot_path: string
@@ -84,80 +77,6 @@ export type TaskRow = {
sku_name?: string
}
export type InventoryItemRow = {
id: number
sku_code: string
batch_no: string
credential_type: string
inventory_group_code: string
display_value: string
status: string
invalid_reason: string
delivered_at: string | null
created_at: string
updated_at: string
reserved_by_task_id: number | null
reserved_by_task_no: string
platform_order_id: string
}
export type InventorySkuSuggestionRow = {
sku_code: string
credential_type: string
inventory_group_code: string
total_count: number
available_count: number
latest_updated_at: string | null
}
export type WebhookEventRow = {
id: number
provider: string
platform: string
shop_id: string
shop_name: string
event_type: string
event_key: string
signature_valid: boolean
headers_json: string | Record<string, unknown>
query_json: string | Record<string, unknown>
body_json: string | Record<string, unknown>
processed: boolean
process_error: string
related_order_id: number | null
created_at: string
}
export type TaskInventoryBindingRow = {
id: number
task_id: number
inventory_item_id: number
role_key: string
quantity: number
binding_status: string
metadata_json: string | Record<string, unknown>
invalid_reason: string
consumed_at: string | null
released_at: string | null
created_at: string
updated_at: string
sku_code: string
batch_no: string
credential_type: string
inventory_group_code: string
display_value: string
inventory_status: string
}
export type TaskInventoryBindingSummaryRow = {
task_id: number
total_binding_count: number
reserved_binding_count: number
consumed_binding_count: number
released_binding_count: number
role_keys: string[]
}
export type ClaimTokenRow = {
id: number
token: string
@@ -183,13 +102,3 @@ export type TaskListQueryResult = {
items: TaskRow[]
total: number
}
export type InventoryListQueryResult = {
items: InventoryItemRow[]
total: number
}
export type WebhookEventListQueryResult = {
items: WebhookEventRow[]
total: number
}
+5 -5
View File
@@ -28,7 +28,7 @@ test('shouldWriteLog respects configured minimum log level', () => {
test('resolveLogFilePath uses daily log file names by channel', () => {
assert.match(resolveLogFilePath('app', '2026-04-14T10:00:00.000Z'), /app-2026-04-14\.log$/)
assert.match(resolveLogFilePath('webhook', '2026-04-14T10:00:00.000Z'), /webhook-2026-04-14\.log$/)
assert.match(resolveLogFilePath('integration', '2026-04-14T10:00:00.000Z'), /integration-2026-04-14\.log$/)
})
test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown files', () => {
@@ -37,17 +37,17 @@ test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown fil
'app-2026-04-13.log',
'app-2026-04-08.log',
'app-2026-04-07.log',
'webhook-2026-04-06.log',
'integration-2026-04-06.log',
'app.log',
'webhook.log',
'integration.log',
'random.txt',
], '2026-04-14T12:00:00.000Z', 7)
assert.deepEqual(expired, [
'app-2026-04-07.log',
'webhook-2026-04-06.log',
'integration-2026-04-06.log',
'app.log',
'webhook.log',
'integration.log',
])
})
+5 -5
View File
@@ -35,7 +35,7 @@ const DATA_ROOT = resolveDataRoot()
const LOG_DIR = path.join(DATA_ROOT, 'logs')
const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level)
const LOG_RETENTION_DAYS = 7
const LEGACY_LOG_FILES = new Set(['app.log', 'webhook.log'])
const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log'])
let writeQueue = Promise.resolve()
let lastCleanupDateKey = ''
@@ -60,8 +60,8 @@ export function logError(scope, message, detail = undefined) {
return writeLog('error', scope, message, detail)
}
export function logWebhook(scope, message, detail = undefined, { level = 'info' } = {}) {
return writeLog(level, scope, message, detail, { channel: 'webhook' })
export function logIntegration(scope, message, detail = undefined, { level = 'info' } = {}) {
return writeLog(level, scope, message, detail, { channel: 'integration' })
}
function writeLog(level, scope, message, detail, { channel = 'app' } = {}) {
@@ -167,7 +167,7 @@ export function formatLogEntry(entry, { color = false } = {}) {
}
export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) {
const normalizedChannel = String(channel || '').trim() === 'webhook' ? 'webhook' : 'app'
const normalizedChannel = String(channel || '').trim() === 'integration' ? 'integration' : 'app'
return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`)
}
@@ -392,7 +392,7 @@ function shouldDeleteLogFileByDate(fileName, cutoffDateKey) {
return true
}
const matched = /^(app|webhook)-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim())
const matched = /^(app|integration)-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim())
if (!matched) {
return false