重构后台管理服务并推进渐进式类型化
This commit is contained in:
@@ -40,6 +40,19 @@ npm run browser:install:linux
|
||||
npm run start
|
||||
```
|
||||
|
||||
后端渐进 TypeScript 迁移已经开始,当前可执行:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
第一阶段目前先检查:
|
||||
|
||||
- `src/config/runtime.js`
|
||||
- `src/types/**/*.js`
|
||||
|
||||
后续会逐步扩大到更多 routes / services / repositories,而不会先改成需要编译产物的运行模式。
|
||||
|
||||
## 配置
|
||||
|
||||
当前后端采用 `.env` 单一路径做环境配置:
|
||||
|
||||
Generated
+35
@@ -11,6 +11,20 @@
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "1.42.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -981,6 +995,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
"seed:dev-data": "node scripts/seed-dev-data.js",
|
||||
"db:migrate": "node src/db/migrate.js",
|
||||
"dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "1.42.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// @ts-check
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** @typedef {import('../types/runtime-config.js').AgisoMessagingShopsConfig} AgisoMessagingShopsConfig */
|
||||
/** @typedef {import('../types/runtime-config.js').RuntimeConfig} RuntimeConfig */
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
|
||||
@@ -15,9 +20,9 @@ loadEnvFiles([
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
|
||||
const defaultConfig = loadConfig(path.join(CONFIG_ROOT, 'default.cjs'))
|
||||
const defaultConfig = /** @type {RuntimeConfig} */ (loadConfig(path.join(CONFIG_ROOT, 'default.cjs')))
|
||||
|
||||
export const runtimeConfig = applyEnvOverrides(defaultConfig)
|
||||
export const runtimeConfig = /** @type {RuntimeConfig} */ (applyEnvOverrides(defaultConfig))
|
||||
|
||||
function loadConfig(configPath) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
@@ -381,6 +386,7 @@ function parseJsonArray(rawValue) {
|
||||
}
|
||||
|
||||
function normalizeAgisoMessagingShops(rawValue) {
|
||||
/** @type {AgisoMessagingShopsConfig} */
|
||||
const output = {}
|
||||
|
||||
for (const [shopId, config] of Object.entries(rawValue || {})) {
|
||||
@@ -389,6 +395,7 @@ function normalizeAgisoMessagingShops(rawValue) {
|
||||
continue
|
||||
}
|
||||
|
||||
/** @type {AgisoMessagingShopsConfig[string]} */
|
||||
const next = {}
|
||||
const enabled = normalizeBooleanLike(config.enabled)
|
||||
if (enabled !== null) {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// @ts-check
|
||||
|
||||
import { query, withTransaction } from '../db/client.js'
|
||||
|
||||
/** @typedef {import('../types/repository-inputs.js').InventoryCreateItemInput} InventoryCreateItemInput */
|
||||
/** @typedef {import('../types/repository-inputs.js').InventoryListQueryInput} InventoryListQueryInput */
|
||||
/** @typedef {import('../types/repository-inputs.js').InventorySkuSuggestionQueryInput} InventorySkuSuggestionQueryInput */
|
||||
/** @typedef {import('../types/repository-rows.js').InventoryItemRow} InventoryItemRow */
|
||||
/** @typedef {import('../types/repository-rows.js').InventoryListQueryResult} InventoryListQueryResult */
|
||||
/** @typedef {import('../types/repository-rows.js').InventorySkuSuggestionRow} InventorySkuSuggestionRow */
|
||||
|
||||
const INVENTORY_ITEM_SELECT = `
|
||||
SELECT
|
||||
ii.id,
|
||||
@@ -26,6 +35,7 @@ const INVENTORY_ITEM_SELECT = `
|
||||
LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id
|
||||
`
|
||||
|
||||
/** @returns {Promise<InventoryItemRow | null>} */
|
||||
export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType = 'tencent_code') {
|
||||
const result = await query(
|
||||
`${INVENTORY_ITEM_SELECT}
|
||||
@@ -35,9 +45,10 @@ export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credenti
|
||||
[skuCode, credentialType],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {InventoryItemRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<InventoryItemRow | null>} */
|
||||
export async function assignReservedInventoryItem(inventoryItemId, taskId, updatedAt, roleKey = 'primary_code') {
|
||||
return withTransaction(async (client) => {
|
||||
const inventoryResult = await client.query(
|
||||
@@ -76,6 +87,7 @@ export async function assignReservedInventoryItem(inventoryItemId, taskId, updat
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns {Promise<InventoryItemRow | null>} */
|
||||
export async function getInventoryItemById(inventoryItemId) {
|
||||
const result = await query(
|
||||
`${INVENTORY_ITEM_SELECT}
|
||||
@@ -84,9 +96,10 @@ export async function getInventoryItemById(inventoryItemId) {
|
||||
[Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {InventoryItemRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<InventoryItemRow | null>} */
|
||||
export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
@@ -111,6 +124,7 @@ export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) {
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns {Promise<InventoryItemRow | null>} */
|
||||
export async function markInventoryItemConsumed(inventoryItemId, reason, consumedAt) {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
@@ -139,6 +153,8 @@ export async function markInventoryItemConsumed(inventoryItemId, reason, consume
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns {Promise<InventoryListQueryResult>} */
|
||||
/** @param {InventoryListQueryInput} [queryInput] */
|
||||
export async function listInventoryItems({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
@@ -146,7 +162,7 @@ export async function listInventoryItems({
|
||||
credentialType = '',
|
||||
status = '',
|
||||
batchNo = '',
|
||||
} = {}) {
|
||||
} = /** @type {InventoryListQueryInput} */ ({})) {
|
||||
const offset = (page - 1) * pageSize
|
||||
const filters = []
|
||||
const params = []
|
||||
@@ -188,16 +204,18 @@ export async function listInventoryItems({
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
items: /** @type {InventoryItemRow[]} */ (itemsResult.rows),
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<InventorySkuSuggestionRow[]>} */
|
||||
/** @param {InventorySkuSuggestionQueryInput} [queryInput] */
|
||||
export async function listInventorySkuSuggestions({
|
||||
credentialType = '',
|
||||
keyword = '',
|
||||
limit = 50,
|
||||
} = {}) {
|
||||
} = /** @type {InventorySkuSuggestionQueryInput} */ ({})) {
|
||||
const filters = []
|
||||
const params = []
|
||||
|
||||
@@ -238,9 +256,10 @@ export async function listInventorySkuSuggestions({
|
||||
params,
|
||||
)
|
||||
|
||||
return result.rows
|
||||
return /** @type {InventorySkuSuggestionRow[]} */ (result.rows)
|
||||
}
|
||||
|
||||
/** @param {InventoryCreateItemInput[]} rows */
|
||||
export async function createInventoryItems(rows) {
|
||||
let created = 0
|
||||
|
||||
@@ -284,6 +303,7 @@ export async function createInventoryItems(rows) {
|
||||
return created
|
||||
}
|
||||
|
||||
/** @returns {Promise<InventoryItemRow | null>} */
|
||||
export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
/** @typedef {import('../types/repository-inputs.js').OrderItemReplaceInput} OrderItemReplaceInput */
|
||||
/** @typedef {import('../types/repository-rows.js').OrderItemRow} OrderItemRow */
|
||||
|
||||
/** @returns {Promise<OrderItemRow[]>} */
|
||||
export async function listOrderItemsByOrderId(orderId) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -11,9 +17,11 @@ export async function listOrderItemsByOrderId(orderId) {
|
||||
[Number(orderId)],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
return /** @type {OrderItemRow[]} */ (result.rows)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderItemRow[]>} */
|
||||
/** @param {OrderItemReplaceInput[]} items */
|
||||
export async function replaceOrderItems(orderId, items) {
|
||||
await query('DELETE FROM order_items WHERE order_id = $1', [Number(orderId)])
|
||||
|
||||
@@ -47,7 +55,8 @@ export async function replaceOrderItems(orderId, items) {
|
||||
return listOrderItemsByOrderId(orderId)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderItemRow | null>} */
|
||||
export async function getOrderItemById(orderItemId) {
|
||||
const result = await query('SELECT * FROM order_items WHERE id = $1 LIMIT 1', [Number(orderItemId)])
|
||||
return result.rows[0] || null
|
||||
return /** @type {OrderItemRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
/** @typedef {import('../types/repository-inputs.js').OrderUpsertInput} OrderUpsertInput */
|
||||
/** @typedef {import('../types/repository-inputs.js').OrderListQueryInput} OrderListQueryInput */
|
||||
/** @typedef {import('../types/repository-rows.js').OrderListQueryResult} OrderListQueryResult */
|
||||
/** @typedef {import('../types/repository-rows.js').OrderListRow} OrderListRow */
|
||||
/** @typedef {import('../types/repository-rows.js').OrderRow} OrderRow */
|
||||
|
||||
/** @returns {Promise<OrderRow | null>} */
|
||||
export async function findOrderByPlatformOrderId({ provider = 'agiso', platform, shopId = '', platformOrderId }) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -11,9 +20,11 @@ export async function findOrderByPlatformOrderId({ provider = 'agiso', platform,
|
||||
[provider, platform, shopId, platformOrderId],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {OrderRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderRow | null>} */
|
||||
/** @param {OrderUpsertInput} input */
|
||||
export async function createOrder(input) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -57,9 +68,11 @@ export async function createOrder(input) {
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {OrderRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderRow | null>} */
|
||||
/** @param {OrderUpsertInput} input */
|
||||
export async function updateOrder(orderId, input) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -101,14 +114,17 @@ export async function updateOrder(orderId, input) {
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {OrderRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderRow | null>} */
|
||||
export async function getOrderById(orderId) {
|
||||
const result = await query('SELECT * FROM orders WHERE id = $1 LIMIT 1', [Number(orderId)])
|
||||
return result.rows[0] || null
|
||||
return /** @type {OrderRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<OrderListQueryResult>} */
|
||||
/** @param {OrderListQueryInput} [queryInput] */
|
||||
export async function listOrders({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
@@ -117,7 +133,7 @@ export async function listOrders({
|
||||
skuCode = '',
|
||||
dateFrom = '',
|
||||
dateTo = '',
|
||||
} = {}) {
|
||||
} = /** @type {OrderListQueryInput} */ ({})) {
|
||||
const offset = (page - 1) * pageSize
|
||||
const filters = []
|
||||
const params = []
|
||||
@@ -166,7 +182,7 @@ export async function listOrders({
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
items: /** @type {OrderListRow[]} */ (itemsResult.rows),
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
/** @typedef {import('../types/repository-rows.js').TaskInventoryBindingRow} TaskInventoryBindingRow */
|
||||
/** @typedef {import('../types/repository-rows.js').TaskInventoryBindingSummaryRow} TaskInventoryBindingSummaryRow */
|
||||
|
||||
/** @returns {Promise<TaskInventoryBindingRow[]>} */
|
||||
export async function listTaskInventoryBindingsByTaskId(taskId) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -29,9 +35,10 @@ export async function listTaskInventoryBindingsByTaskId(taskId) {
|
||||
[Number(taskId)],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
return /** @type {TaskInventoryBindingRow[]} */ (result.rows)
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskInventoryBindingRow | null>} */
|
||||
export async function getTaskInventoryBindingById(bindingId) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -61,9 +68,10 @@ export async function getTaskInventoryBindingById(bindingId) {
|
||||
[Number(bindingId)],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {TaskInventoryBindingRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskInventoryBindingSummaryRow[]>} */
|
||||
export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) {
|
||||
const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : [])
|
||||
.map((value) => Number(value))
|
||||
@@ -89,5 +97,5 @@ export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) {
|
||||
[normalizedTaskIds],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
return /** @type {TaskInventoryBindingSummaryRow[]} */ (result.rows)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// @ts-check
|
||||
|
||||
import { query, withTransaction } from '../db/client.js'
|
||||
|
||||
/** @typedef {import('../types/repository-inputs.js').TaskCreateInput} TaskCreateInput */
|
||||
/** @typedef {import('../types/repository-inputs.js').TaskListQueryInput} TaskListQueryInput */
|
||||
/** @typedef {import('../types/repository-inputs.js').TaskTencentContextPatch} TaskTencentContextPatch */
|
||||
/** @typedef {import('../types/repository-inputs.js').TaskUpdatePatch} TaskUpdatePatch */
|
||||
/** @typedef {import('../types/repository-rows.js').TaskListQueryResult} TaskListQueryResult */
|
||||
/** @typedef {import('../types/repository-rows.js').TaskRow} TaskRow */
|
||||
|
||||
const TASK_FIELDS = `
|
||||
ft.*,
|
||||
ct.id AS primary_claim_token_id,
|
||||
@@ -52,6 +61,7 @@ function buildTaskSelect(extraFields = '') {
|
||||
return `SELECT ${fieldSql} ${TASK_JOINS}`
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskRow[]>} */
|
||||
export async function listTasksByOrderId(orderId) {
|
||||
const result = await query(
|
||||
`${buildTaskSelect()}
|
||||
@@ -59,9 +69,11 @@ export async function listTasksByOrderId(orderId) {
|
||||
ORDER BY ft.id ASC`,
|
||||
[Number(orderId)],
|
||||
)
|
||||
return result.rows
|
||||
return /** @type {TaskRow[]} */ (result.rows)
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskRow | null>} */
|
||||
/** @param {TaskCreateInput} input */
|
||||
export async function createTask(input) {
|
||||
return withTransaction(async (client) => {
|
||||
const taskResult = await client.query(
|
||||
@@ -139,6 +151,8 @@ export async function createTask(input) {
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskRow | null>} */
|
||||
/** @param {TaskUpdatePatch} patch */
|
||||
export async function updateTask(taskId, patch) {
|
||||
const current = await getTaskById(taskId)
|
||||
if (!current) {
|
||||
@@ -212,10 +226,12 @@ export async function updateTask(taskId, patch) {
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskRow | null>} */
|
||||
export async function getTaskById(taskId) {
|
||||
return getTaskByIdWithExecutor(query, taskId)
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskRow | null>} */
|
||||
export async function findTaskByClaimTokenId(claimTokenId) {
|
||||
const result = await query(
|
||||
`${buildTaskSelect()}
|
||||
@@ -224,9 +240,11 @@ export async function findTaskByClaimTokenId(claimTokenId) {
|
||||
LIMIT 1`,
|
||||
[Number(claimTokenId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
return /** @type {TaskRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskListQueryResult>} */
|
||||
/** @param {TaskListQueryInput} [queryInput] */
|
||||
export async function listTasks({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
@@ -237,7 +255,7 @@ export async function listTasks({
|
||||
roleId = '',
|
||||
dateFrom = '',
|
||||
dateTo = '',
|
||||
} = {}) {
|
||||
} = /** @type {TaskListQueryInput} */ ({})) {
|
||||
const offset = (page - 1) * pageSize
|
||||
const filters = []
|
||||
const params = []
|
||||
@@ -301,11 +319,12 @@ export async function listTasks({
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
items: /** @type {TaskRow[]} */ (itemsResult.rows),
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {TaskTencentContextPatch} patch */
|
||||
async function upsertTencentBrowserContextWithClient(client, taskId, patch = {}, timestamp) {
|
||||
const currentResult = await client.query(
|
||||
'SELECT * FROM tencent_browser_contexts WHERE task_id = $1 LIMIT 1',
|
||||
@@ -399,6 +418,7 @@ async function upsertTencentBrowserContextWithClient(client, taskId, patch = {},
|
||||
)
|
||||
}
|
||||
|
||||
/** @returns {Promise<TaskRow | null>} */
|
||||
async function getTaskByIdWithExecutor(executor, taskId) {
|
||||
const result = await executor(
|
||||
`${buildTaskSelect()}
|
||||
@@ -406,7 +426,7 @@ async function getTaskByIdWithExecutor(executor, taskId) {
|
||||
LIMIT 1`,
|
||||
[Number(taskId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
return /** @type {TaskRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
function containsTencentPatch(patch = {}) {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
/** @typedef {import('../types/repository-inputs.js').WebhookEventCreateInput} WebhookEventCreateInput */
|
||||
/** @typedef {import('../types/repository-inputs.js').WebhookEventListQueryInput} WebhookEventListQueryInput */
|
||||
/** @typedef {import('../types/repository-inputs.js').WebhookEventUpdatePatch} WebhookEventUpdatePatch */
|
||||
/** @typedef {import('../types/repository-rows.js').WebhookEventListQueryResult} WebhookEventListQueryResult */
|
||||
/** @typedef {import('../types/repository-rows.js').WebhookEventRow} WebhookEventRow */
|
||||
|
||||
/** @returns {Promise<WebhookEventRow | null>} */
|
||||
/** @param {WebhookEventCreateInput} input */
|
||||
export async function createWebhookEvent(input) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -39,9 +49,11 @@ export async function createWebhookEvent(input) {
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {WebhookEventRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<WebhookEventRow | null>} */
|
||||
/** @param {WebhookEventUpdatePatch} patch */
|
||||
export async function updateWebhookEvent(eventId, patch) {
|
||||
const current = await getWebhookEventById(eventId)
|
||||
if (!current) {
|
||||
@@ -62,14 +74,17 @@ export async function updateWebhookEvent(eventId, patch) {
|
||||
[Boolean(next.processed), next.process_error || '', next.related_order_id || null, Number(eventId)],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
return /** @type {WebhookEventRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<WebhookEventRow | null>} */
|
||||
export async function getWebhookEventById(eventId) {
|
||||
const result = await query('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [Number(eventId)])
|
||||
return result.rows[0] || null
|
||||
return /** @type {WebhookEventRow | null} */ (result.rows[0] || null)
|
||||
}
|
||||
|
||||
/** @returns {Promise<WebhookEventListQueryResult>} */
|
||||
/** @param {WebhookEventListQueryInput} [queryInput] */
|
||||
export async function listWebhookEvents({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
@@ -80,7 +95,7 @@ export async function listWebhookEvents({
|
||||
relatedOrderId = '',
|
||||
dateFrom = '',
|
||||
dateTo = '',
|
||||
} = {}) {
|
||||
} = /** @type {WebhookEventListQueryInput} */ ({})) {
|
||||
const offset = (page - 1) * pageSize
|
||||
const filters = []
|
||||
const params = []
|
||||
@@ -137,11 +152,12 @@ export async function listWebhookEvents({
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
items: /** @type {WebhookEventRow[]} */ (itemsResult.rows),
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<WebhookEventRow[]>} */
|
||||
export async function listWebhookEventsByOrderId(orderId) {
|
||||
const result = await query(
|
||||
`
|
||||
@@ -153,5 +169,5 @@ export async function listWebhookEventsByOrderId(orderId) {
|
||||
[Number(orderId)],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
return /** @type {WebhookEventRow[]} */ (result.rows)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminDashboardSummary } from '../../services/admin/admin-service.js'
|
||||
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
|
||||
import { createJsonHandler } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Router } from 'express'
|
||||
|
||||
import {
|
||||
createAdminInventoryItem,
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
releaseAdminInventoryItem,
|
||||
} from '../../services/admin/admin-service.js'
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
import {
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminMessageDeliveries } from '../../services/admin/admin-service.js'
|
||||
import { getAdminMessageDeliveries } from '../../services/admin/admin-message-delivery-service.js'
|
||||
import { createJsonHandler } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-service.js'
|
||||
import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
updateAdminAgisoShopConfigs,
|
||||
updateAdminFulfillmentBindingConfigs,
|
||||
} from '../../services/admin/admin-service.js'
|
||||
} from '../../services/admin/admin-platform-config-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -2,18 +2,20 @@ import { Router } from 'express'
|
||||
|
||||
import {
|
||||
closeAdminTask,
|
||||
confirmAdminTaskAssistedRole,
|
||||
completeAdminTaskManualDispatch,
|
||||
getAdminTaskDetail,
|
||||
getAdminTaskScreenshotPath,
|
||||
getAdminTasks,
|
||||
confirmAdminTaskAssistedRole,
|
||||
markAdminTaskManualReview,
|
||||
regenerateAdminTaskClaimLink,
|
||||
redeemAdminTaskAssisted,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
releaseAdminTaskInventory,
|
||||
retryAdminTask,
|
||||
} from '../../services/admin/admin-service.js'
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
import {
|
||||
getAdminTaskDetail,
|
||||
getAdminTaskScreenshotPath,
|
||||
getAdminTasks,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createFileHandler, createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
replayAdminWebhookEvent,
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
import {
|
||||
getAdminWebhookEventDetail,
|
||||
getAdminWebhookEvents,
|
||||
replayAdminWebhookEvent,
|
||||
} from '../../services/admin/admin-service.js'
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
export async function getAdminDashboardSummary() {
|
||||
const todayPrefix = nowIso().slice(0, 10)
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM orders WHERE to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1) AS today_orders,
|
||||
(
|
||||
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'))
|
||||
) AS paid_pending_claim,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_tasks
|
||||
WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')
|
||||
) AS claiming_tasks,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_tasks
|
||||
WHERE task_status = 'redeemed'
|
||||
AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1
|
||||
) AS redeemed_today,
|
||||
(
|
||||
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
|
||||
`,
|
||||
[todayPrefix],
|
||||
)
|
||||
const summary = result.rows[0] || {}
|
||||
|
||||
return {
|
||||
todayOrders: Number(summary?.today_orders || 0),
|
||||
paidPendingClaim: Number(summary?.paid_pending_claim || 0),
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// @ts-check
|
||||
|
||||
import { listMessageDeliveries } from '../../repositories/message-delivery-repo.js'
|
||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||
|
||||
export async function getAdminMessageDeliveries(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listMessageDeliveries({
|
||||
page,
|
||||
pageSize,
|
||||
provider: String(query.provider || '').trim(),
|
||||
platform: String(query.platform || '').trim(),
|
||||
status: String(query.status || '').trim(),
|
||||
shopId: String(query.shopId || '').trim(),
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
taskNo: String(query.taskNo || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminMessageDeliveryListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminMessageDeliveryListItem(item) {
|
||||
return {
|
||||
deliveryId: Number(item.id),
|
||||
provider: String(item.provider || '').trim(),
|
||||
platform: String(item.platform || '').trim(),
|
||||
shopId: String(item.shop_id || '').trim(),
|
||||
shopName: String(item.shop_name || '').trim(),
|
||||
channel: String(item.channel || '').trim(),
|
||||
orderId: item.order_id ? Number(item.order_id) : null,
|
||||
taskId: item.task_id ? Number(item.task_id) : null,
|
||||
taskNo: String(item.task_no || '').trim(),
|
||||
taskStatus: String(item.task_status || '').trim(),
|
||||
platformOrderId: String(item.platform_order_id || '').trim(),
|
||||
recipientKey: String(item.recipient_key || '').trim(),
|
||||
messageContent: String(item.message_content || '').trim(),
|
||||
claimUrl: String(item.claim_url || '').trim(),
|
||||
status: String(item.status || '').trim(),
|
||||
requestUrl: String(item.request_url || '').trim(),
|
||||
responseStatus: Number(item.response_status || 0),
|
||||
response: safeParseJson(item.response_json),
|
||||
errorMessage: String(item.error_message || '').trim(),
|
||||
sentAt: item.sent_at || null,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../../db/client.js'
|
||||
import {
|
||||
getAgisoShopConfig,
|
||||
getAgisoShopConfigMap,
|
||||
getAgisoShopsFilePath,
|
||||
saveAgisoShopConfigMap,
|
||||
} from '../platforms/agiso/shop-config-service.js'
|
||||
import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js'
|
||||
import {
|
||||
getOrderFulfillmentBindingConfigs,
|
||||
getOrderFulfillmentBindingsFilePath,
|
||||
saveOrderFulfillmentBindingConfigs,
|
||||
} from '../order/fulfillment-binding-config-service.js'
|
||||
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatFenToAmount } from '../../utils/money.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { resolveDisplayShopName } from './admin-read-helpers.js'
|
||||
|
||||
export async function getAdminAgisoShopConfigs() {
|
||||
const configMap = getAgisoShopConfigMap()
|
||||
const rowsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
shop_id,
|
||||
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
|
||||
MAX(created_at) AS latest_seen_at,
|
||||
COUNT(*)::int AS webhook_event_count
|
||||
FROM webhook_events
|
||||
WHERE provider = 'agiso' AND trim(shop_id) != ''
|
||||
GROUP BY shop_id
|
||||
ORDER BY latest_seen_at DESC, shop_id DESC
|
||||
`,
|
||||
)
|
||||
const rows = rowsResult.rows
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
shops: Object.entries(configMap)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => ({
|
||||
shopId,
|
||||
shopName: String(config.shopName || '').trim(),
|
||||
accessToken: String(config.accessToken || '').trim(),
|
||||
accessTokenMasked: maskSecret(config.accessToken),
|
||||
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
|
||||
messageTemplate: String(config.messageTemplate || '').trim(),
|
||||
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
|
||||
apiVersion: String(config.apiVersion || '').trim(),
|
||||
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
|
||||
})),
|
||||
observedShops: rows.map((row) => ({
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
detectedShopName: String(row.detected_shop_name || '').trim(),
|
||||
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
|
||||
latestSeenAt: row.latest_seen_at || null,
|
||||
webhookEventCount: Number(row.webhook_event_count || 0),
|
||||
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAdminAgisoShopConfigs(payload = {}) {
|
||||
const rawItems = Array.isArray(payload.shops) ? payload.shops : []
|
||||
const nextMap = {}
|
||||
|
||||
for (const item of rawItems) {
|
||||
const shopId = String(item?.shopId || '').trim()
|
||||
if (!shopId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const next = {}
|
||||
const shopName = String(item?.shopName || '').trim()
|
||||
const accessToken = String(item?.accessToken || '').trim()
|
||||
const messageTemplate = String(item?.messageTemplate || '').trim()
|
||||
const appSecret = String(item?.appSecret || '').trim()
|
||||
const apiVersion = String(item?.apiVersion || '').trim()
|
||||
const sendMessageEndpoint = String(item?.sendMessageEndpoint || '').trim()
|
||||
|
||||
if (shopName) {
|
||||
next.shopName = shopName
|
||||
}
|
||||
if (accessToken) {
|
||||
next.accessToken = accessToken
|
||||
}
|
||||
if (messageTemplate) {
|
||||
next.messageTemplate = messageTemplate
|
||||
}
|
||||
if (appSecret) {
|
||||
next.appSecret = appSecret
|
||||
}
|
||||
if (apiVersion) {
|
||||
next.apiVersion = apiVersion
|
||||
}
|
||||
if (sendMessageEndpoint) {
|
||||
next.sendMessageEndpoint = sendMessageEndpoint
|
||||
}
|
||||
if (typeof item?.enabled === 'boolean') {
|
||||
next.enabled = item.enabled
|
||||
}
|
||||
|
||||
if (!next.accessToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextMap[shopId] = next
|
||||
}
|
||||
|
||||
const saved = saveAgisoShopConfigMap(nextMap)
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
shops: Object.entries(saved)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => ({
|
||||
shopId,
|
||||
shopName: String(config.shopName || '').trim(),
|
||||
accessToken: String(config.accessToken || '').trim(),
|
||||
accessTokenMasked: maskSecret(config.accessToken),
|
||||
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
|
||||
messageTemplate: String(config.messageTemplate || '').trim(),
|
||||
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
|
||||
apiVersion: String(config.apiVersion || '').trim(),
|
||||
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminFulfillmentBindingConfigs() {
|
||||
const bindings = getOrderFulfillmentBindingConfigs()
|
||||
const rowsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.shop_id,
|
||||
MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name,
|
||||
MAX(oi.created_at) AS latest_seen_at,
|
||||
COUNT(*)::int AS order_item_count
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != ''
|
||||
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != ''
|
||||
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != ''
|
||||
GROUP BY
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.shop_id,
|
||||
external_item_id,
|
||||
external_sku_code,
|
||||
external_sku_name
|
||||
ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC
|
||||
LIMIT 200
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
|
||||
observedProducts: rowsResult.rows.map((row) => mapAdminObservedProductItem({
|
||||
provider: String(row.provider || '').trim(),
|
||||
platform: String(row.platform || '').trim(),
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
shopName: String(row.shop_name || '').trim(),
|
||||
externalItemId: String(row.external_item_id || '').trim(),
|
||||
externalSkuCode: String(row.external_sku_code || '').trim(),
|
||||
externalSkuName: String(row.external_sku_name || '').trim(),
|
||||
latestSeenAt: row.latest_seen_at || null,
|
||||
orderItemCount: Number(row.order_item_count || 0),
|
||||
}, bindings)),
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupAdminFulfillmentBindingOrder(payload = {}) {
|
||||
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
|
||||
const shopId = String(payload.shopId || '').trim()
|
||||
const platformOrderId = String(payload.platformOrderId || '').trim()
|
||||
|
||||
if (!shopId) {
|
||||
throw createHttpError('请先填写店铺 ID', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (!platformOrderId) {
|
||||
throw createHttpError('请先填写平台订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (provider !== 'agiso' || platform !== 'xianyu') {
|
||||
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
const detailResult = await enrichAgisoXianyuTradeOrder({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: '',
|
||||
platformOrderId,
|
||||
orderStatus: 'created',
|
||||
payStatus: 'unpaid',
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
currency: 'CNY',
|
||||
paidAt: null,
|
||||
rawPayload: {},
|
||||
items: [],
|
||||
}, {
|
||||
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
|
||||
})
|
||||
|
||||
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
|
||||
const items = Array.isArray(detail.items) ? detail.items : []
|
||||
|
||||
if (items.length === 0) {
|
||||
const detailReason = String(detailResult?.reason || '').trim()
|
||||
const detailMessage = String(detailResult?.errorMessage || '').trim()
|
||||
let message = detailMessage
|
||||
|
||||
if (!message && detailReason === 'missing_config') {
|
||||
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = `未查询到订单 ${platformOrderId} 的商品明细`
|
||||
}
|
||||
|
||||
throw createHttpError(message, {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const bindings = getOrderFulfillmentBindingConfigs()
|
||||
const resolvedShopName = pickFirstNonEmpty([
|
||||
detail.shopName,
|
||||
getAgisoShopConfig(shopId)?.shopName,
|
||||
])
|
||||
|
||||
return {
|
||||
order: {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
platformOrderId,
|
||||
buyerName: String(detail.buyerName || '').trim(),
|
||||
totalAmountFen: Number(detail.totalAmount || 0),
|
||||
totalAmount: formatFenToAmount(detail.totalAmount),
|
||||
paidAt: detail.paidAt || null,
|
||||
enriched: Boolean(detailResult?.enriched),
|
||||
enrichReason: String(detailResult?.reason || '').trim(),
|
||||
errorMessage: String(detailResult?.errorMessage || '').trim(),
|
||||
},
|
||||
items: items.map((item, index) => {
|
||||
const observed = {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
|
||||
externalSkuCode: pickFirstNonEmpty([item?.externalSkuCode, item?.skuCode, item?.externalItemId, item?.itemId]),
|
||||
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
|
||||
latestSeenAt: null,
|
||||
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
|
||||
}
|
||||
|
||||
return {
|
||||
lineId: [
|
||||
platformOrderId,
|
||||
index + 1,
|
||||
observed.externalSkuCode || 'na',
|
||||
observed.externalItemId || 'na',
|
||||
].join(':'),
|
||||
itemTitle: pickFirstNonEmpty([
|
||||
item?.skuName,
|
||||
item?.externalSkuName,
|
||||
item?.externalSkuCode,
|
||||
item?.externalItemId,
|
||||
]),
|
||||
quantity: observed.orderItemCount,
|
||||
...mapAdminObservedProductItem(observed, bindings),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminFulfillmentBindingConfigs(payload = {}) {
|
||||
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
|
||||
await validateAdminFulfillmentBindingConfigs(bindingsInput)
|
||||
const saved = saveOrderFulfillmentBindingConfigs(bindingsInput)
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||
if (!Array.isArray(bindings)) {
|
||||
throw createHttpError('履约配置格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_payload',
|
||||
})
|
||||
}
|
||||
|
||||
const seenKeys = new Set()
|
||||
|
||||
for (const [index, rawBinding] of bindings.entries()) {
|
||||
if (!isPlainObject(rawBinding)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则格式不正确`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_item',
|
||||
})
|
||||
}
|
||||
|
||||
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawBinding.platform || '').trim()
|
||||
const shopId = String(rawBinding.shopId || '').trim()
|
||||
const skuCode = String(rawBinding.skuCode || '').trim()
|
||||
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
|
||||
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
|
||||
})
|
||||
}
|
||||
|
||||
if (!externalItemId && !externalSkuCode && !externalSkuName) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
|
||||
})
|
||||
}
|
||||
|
||||
const profile = await getFulfillmentProfileByKey(profileKey)
|
||||
if (!profile) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${profileKey}`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
|
||||
})
|
||||
}
|
||||
|
||||
const uniqueKey = [
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
externalItemId,
|
||||
externalSkuCode,
|
||||
normalizeProductName(externalSkuName),
|
||||
skuCode,
|
||||
].join('::')
|
||||
|
||||
if (seenKeys.has(uniqueKey)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
|
||||
})
|
||||
}
|
||||
|
||||
seenKeys.add(uniqueKey)
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminFulfillmentBindingConfigItem(item) {
|
||||
const match = item?.match || {}
|
||||
return {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
skuCode: String(item?.skuCode || '').trim(),
|
||||
skuName: String(item?.skuName || '').trim(),
|
||||
profileKey: String(item?.profileKey || '').trim(),
|
||||
enabled: item?.enabled !== false,
|
||||
priority: Number(item?.priority || 100),
|
||||
config: item?.config || {},
|
||||
match: {
|
||||
externalSkuCode: String(match.externalSkuCode || '').trim(),
|
||||
externalItemId: String(match.externalItemId || '').trim(),
|
||||
externalSkuName: String(match.externalSkuName || '').trim(),
|
||||
config: match.config || {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function matchesObservedProduct(binding, observed) {
|
||||
const provider = String(binding?.provider || '').trim()
|
||||
const platform = String(binding?.platform || '').trim()
|
||||
const shopId = String(binding?.shopId || '').trim()
|
||||
const match = binding?.match || {}
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
|
||||
if (provider && provider !== String(observed?.provider || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (platform && platform !== String(observed?.platform || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (shopId && shopId !== String(observed?.shopId || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim())
|
||||
|| (externalItemId && externalItemId === String(observed?.externalItemId || '').trim())
|
||||
|| (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
function mapAdminObservedProductItem(item, bindings = []) {
|
||||
const matchedBinding = findMatchingObservedBinding(bindings, item)
|
||||
|
||||
return {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
shopName: String(item?.shopName || '').trim(),
|
||||
externalItemId: String(item?.externalItemId || '').trim(),
|
||||
externalSkuCode: String(item?.externalSkuCode || '').trim(),
|
||||
externalSkuName: String(item?.externalSkuName || '').trim(),
|
||||
latestSeenAt: item?.latestSeenAt || null,
|
||||
orderItemCount: Number(item?.orderItemCount || 0),
|
||||
configured: Boolean(matchedBinding),
|
||||
matchedBinding: matchedBinding
|
||||
? {
|
||||
skuCode: String(matchedBinding.skuCode || '').trim(),
|
||||
skuName: String(matchedBinding.skuName || '').trim(),
|
||||
profileKey: String(matchedBinding.profileKey || '').trim(),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchingObservedBinding(bindings, observed) {
|
||||
return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.length <= 10) {
|
||||
return `${normalized.slice(0, 2)}****${normalized.slice(-2)}`
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 6)}****${normalized.slice(-6)}`
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,953 @@
|
||||
// @ts-check
|
||||
|
||||
import { getAgisoShopConfig } from '../platforms/agiso/shop-config-service.js'
|
||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
|
||||
import { getInventoryItemById } from '../../repositories/inventory-repo.js'
|
||||
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { getTaskById, listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/money.js'
|
||||
import { safeParseJson } from './admin-query-utils.js'
|
||||
import { normalizeAdminRole } from './admin-auth-service.js'
|
||||
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminInventoryItemListItem} AdminInventoryItemListItem */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminOrderListItem} AdminOrderListItem */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminTaskBindingSummary} AdminTaskBindingSummary */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminTaskListItem} AdminTaskListItem */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminWebhookEventListItem} AdminWebhookEventListItem */
|
||||
/** @typedef {import('../../types/repository-rows.js').InventoryItemRow} InventoryItemRow */
|
||||
/** @typedef {import('../../types/repository-rows.js').OrderListRow} OrderListRow */
|
||||
/** @typedef {import('../../types/repository-rows.js').TaskEventRow} TaskEventRow */
|
||||
/** @typedef {import('../../types/repository-rows.js').TaskInventoryBindingRow} TaskInventoryBindingRow */
|
||||
/** @typedef {import('../../types/repository-rows.js').TaskRow} TaskRow */
|
||||
/** @typedef {import('../../types/repository-rows.js').WebhookEventRow} WebhookEventRow */
|
||||
|
||||
export function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummary()) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
deliveryStatus: task.delivery_status || '',
|
||||
status: task.task_status,
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
loginType: task.login_type,
|
||||
browserSessionId: task.browser_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
lastError: task.last_error,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WebhookEventRow} item
|
||||
* @returns {Promise<AdminWebhookEventListItem>}
|
||||
*/
|
||||
export async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) {
|
||||
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 mapped = {
|
||||
eventId: item.id,
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
platformRaw: pickFirstNonEmpty([
|
||||
query.fromPlatform,
|
||||
query.from_platform,
|
||||
body.fromPlatform,
|
||||
body.from_platform,
|
||||
payload.fromPlatform,
|
||||
payload.FromPlatform,
|
||||
payload.platform,
|
||||
payload.Platform,
|
||||
]),
|
||||
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,
|
||||
]),
|
||||
shopName: resolveDisplayShopName(
|
||||
item.provider,
|
||||
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,
|
||||
]),
|
||||
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,
|
||||
relatedOrderId: item.related_order_id,
|
||||
createdAt: item.created_at,
|
||||
platformOrderId: resolveAgisoTradePlatformOrderId(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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InventoryItemRow} item
|
||||
* @returns {Promise<AdminInventoryItemListItem>}
|
||||
*/
|
||||
export async function mapAdminInventoryListItem(item) {
|
||||
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 binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
inventoryItemId: item.id,
|
||||
skuCode: item.sku_code,
|
||||
batchNo: item.batch_no,
|
||||
credentialType: item.credential_type || 'tencent_code',
|
||||
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' : binding.systemBindingStatus,
|
||||
userBindingStatus: item.status === 'consumed' ? 'binding_completed' : binding.userBindingStatus,
|
||||
invalidReason: item.invalid_reason || '',
|
||||
deliveredAt: item.delivered_at,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTaskActionPayload(task) {
|
||||
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
deliveryStatus: task.delivery_status,
|
||||
resultCode: task.result_code,
|
||||
resultMessage: task.result_message,
|
||||
inventoryItemId,
|
||||
primaryClaimTokenId,
|
||||
lastError: task.last_error,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRequiredTask(taskId) {
|
||||
const task = await getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
export async function getRequiredInventoryItem(inventoryItemId) {
|
||||
const inventoryItem = await getInventoryItemById(Number(inventoryItemId))
|
||||
|
||||
if (!inventoryItem) {
|
||||
throw createHttpError('库存项不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_inventory_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return inventoryItem
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TaskRow} task
|
||||
* @returns {AdminTaskListItem}
|
||||
*/
|
||||
export function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary(), viewerContext = createAdminViewerContext()) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
platformOrderId: task.platform_order_id,
|
||||
skuCode: task.sku_code || '',
|
||||
skuName: task.sku_name || '',
|
||||
status: task.task_status,
|
||||
executorKey: task.executor_key || '',
|
||||
deliveryStatus: task.delivery_status || '',
|
||||
resultCode: task.result_code || '',
|
||||
resultMessage: task.result_message || '',
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
loginType: task.login_type,
|
||||
roleName: task.role_name,
|
||||
roleId: task.role_id,
|
||||
browserSessionId: task.browser_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
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 || ''),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {TaskEventRow} event */
|
||||
export function mapAdminTaskEvent(event) {
|
||||
const payload = safeParseJson(event.payload_json)
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
eventType: String(event.event_type || '').trim(),
|
||||
payload: normalizeRecord(payload),
|
||||
createdAt: event.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {AdminTaskBindingSummary} */
|
||||
export function createEmptyTaskBindingSummary() {
|
||||
return {
|
||||
totalBindingCount: 0,
|
||||
reservedBindingCount: 0,
|
||||
consumedBindingCount: 0,
|
||||
releasedBindingCount: 0,
|
||||
roleKeys: [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTaskBindingSummaryMap(taskIds = []) {
|
||||
const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds)
|
||||
const output = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
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, taskId) {
|
||||
return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary()
|
||||
}
|
||||
|
||||
export function createTaskBindingSummaryFromBindings(bindings = []) {
|
||||
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, task, viewerContext = createAdminViewerContext()) {
|
||||
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 || 'tencent_code').trim() || 'tencent_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),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OrderListRow} item
|
||||
* @returns {Promise<AdminOrderListItem>}
|
||||
*/
|
||||
export async function mapAdminOrderListItem(item) {
|
||||
const [tasks, orderItems] = await Promise.all([
|
||||
listTasksByOrderId(item.id),
|
||||
listOrderItemsByOrderId(item.id),
|
||||
])
|
||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
|
||||
const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap)
|
||||
const itemSummary = summarizeOrderItems(orderItems)
|
||||
|
||||
return {
|
||||
orderId: item.id,
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
shopId: item.shop_id || '',
|
||||
shopName: resolveDisplayShopName(item.provider, item.shop_id, item.shop_name),
|
||||
platformOrderId: item.platform_order_id,
|
||||
orderStatus: item.order_status,
|
||||
payStatus: item.pay_status,
|
||||
buyerId: item.buyer_id || '',
|
||||
buyerName: item.buyer_name,
|
||||
receiverContact: item.receiver_contact || '',
|
||||
totalAmount: formatFenToAmount(item.total_amount),
|
||||
totalAmountFen: normalizeFen(item.total_amount),
|
||||
currency: item.currency,
|
||||
paidAt: item.paid_at,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
itemCount: orderItems.length,
|
||||
totalQuantity: orderItems.reduce((sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), 0),
|
||||
itemSummary,
|
||||
taskCount: Number(item.task_count || tasks.length || 0),
|
||||
systemBindingStatus: bindingSummary.systemBindingStatus,
|
||||
userBindingStatus: bindingSummary.userBindingStatus,
|
||||
systemBoundTaskCount: bindingSummary.systemBoundTaskCount,
|
||||
completedBindingTaskCount: bindingSummary.completedBindingTaskCount,
|
||||
totalBindingCount: bindingSummary.totalBindingCount,
|
||||
reservedBindingCount: bindingSummary.reservedBindingCount,
|
||||
consumedBindingCount: bindingSummary.consumedBindingCount,
|
||||
releasedBindingCount: bindingSummary.releasedBindingCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeOrderItems(items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
|
||||
if (normalizedItems.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const [firstItem] = normalizedItems
|
||||
const firstLabel = resolveOrderItemTitle(firstItem)
|
||||
|| String(firstItem?.sku_name || firstItem?.sku_code || '').trim()
|
||||
|
||||
if (normalizedItems.length === 1) {
|
||||
return firstLabel
|
||||
}
|
||||
|
||||
return `${firstLabel} 等 ${normalizedItems.length} 项`
|
||||
}
|
||||
|
||||
export function buildOrderBindingSummary(tasks, taskBindingSummaryMap = new Map()) {
|
||||
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)
|
||||
|
||||
let systemBindingStatus = 'pending_binding'
|
||||
let userBindingStatus = 'not_started'
|
||||
|
||||
if (totalTaskCount === 0) {
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
totalBindingCount,
|
||||
reservedBindingCount,
|
||||
consumedBindingCount,
|
||||
releasedBindingCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
userBindingStatus = 'binding_completed'
|
||||
} else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'user_binding'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'waiting_user_claim'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'binding_exception'
|
||||
} else if (systemBoundTaskCount > 0) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) {
|
||||
systemBindingStatus = 'binding_exception'
|
||||
}
|
||||
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
totalBindingCount,
|
||||
reservedBindingCount,
|
||||
consumedBindingCount,
|
||||
releasedBindingCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrderAgisoAutoDeliverySummary(order, tasks = []) {
|
||||
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
|
||||
const latest = normalizedTasks.reduce((best, task) => {
|
||||
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
|
||||
|
||||
if (!autoDelivery) {
|
||||
return best
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
...autoDelivery,
|
||||
sourceTaskId: Number(task.id || 0) || null,
|
||||
sourceTaskNo: String(task.task_no || '').trim(),
|
||||
}
|
||||
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
|
||||
const bestTime = Date.parse(String(best?.updatedAt || ''))
|
||||
|
||||
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return best
|
||||
}, null)
|
||||
|
||||
if (latest) {
|
||||
return {
|
||||
...latest,
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
|
||||
trigger: '',
|
||||
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
|
||||
platformOrderId: String(order?.platform_order_id || '').trim(),
|
||||
responseStatus: 0,
|
||||
errorMessage: '',
|
||||
requestId: '',
|
||||
aldsType: null,
|
||||
updatedAt: null,
|
||||
sourceTaskId: null,
|
||||
sourceTaskNo: '',
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapManualDispatchContext(value, viewerContext = createAdminViewerContext()) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: String(value.outcome || '').trim(),
|
||||
deliveryReference: String(value.deliveryReference || '').trim(),
|
||||
deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(value.deliveredCredential || '').trim() : '',
|
||||
resultMessage: String(value.resultMessage || '').trim(),
|
||||
completedAt: value.completedAt || null,
|
||||
completedBy: value.completedBy && typeof value.completedBy === 'object'
|
||||
? {
|
||||
userId: Number(value.completedBy.userId || 0) || 0,
|
||||
username: String(value.completedBy.username || '').trim(),
|
||||
role: String(value.completedBy.role || '').trim(),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRedeemResolutionContext(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const attempts = Array.isArray(value.attempts)
|
||||
? value.attempts
|
||||
.map((attempt) => mapRedeemResolutionAttempt(attempt))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return {
|
||||
status: String(value.status || '').trim(),
|
||||
taskStatus: String(value.taskStatus || '').trim(),
|
||||
replacementCount: Math.max(0, Number(value.replacementCount || 0)),
|
||||
finishedAt: value.finishedAt || null,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskPrimaryInventoryItemId(task) {
|
||||
const value = Number(task?.primary_inventory_item_id || 0)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
export function getTaskPrimaryClaimTokenId(task) {
|
||||
const value = Number(task?.primary_claim_token_id || 0)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
export function createAdminViewerContext(session = null) {
|
||||
const role = normalizeAdminRole(session?.role)
|
||||
|
||||
return {
|
||||
role,
|
||||
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
|
||||
canManageSensitiveInventory: role === 'admin',
|
||||
canManageTaskLifecycle: role === 'admin' || role === 'operator',
|
||||
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAdminTaskScreenshotUrl(task, viewerContext) {
|
||||
if (viewerContext.role === 'support') {
|
||||
return task.browser_session_id ? `/api/v1/admin/tasks/${task.id}/screenshot` : ''
|
||||
}
|
||||
|
||||
if (task.screenshot_path || task.browser_session_id) {
|
||||
return `/api/v1/admin/tasks/${task.id}/screenshot`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveDisplayShopName(provider, shopId, shopName) {
|
||||
const normalizedShopName = String(shopName || '').trim()
|
||||
if (normalizedShopName) {
|
||||
return normalizedShopName
|
||||
}
|
||||
|
||||
const normalizedShopId = String(shopId || '').trim()
|
||||
if (!normalizedShopId) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (String(provider || '').trim().toLowerCase() === 'agiso') {
|
||||
const configuredName = String(getAgisoShopConfig(normalizedShopId)?.shopName || '').trim()
|
||||
if (configuredName) {
|
||||
return configuredName
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedShopId
|
||||
}
|
||||
|
||||
export function parseTaskContext(task) {
|
||||
const value = task?.context_json
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTaskState(task) {
|
||||
const value = task?.state_json
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrderItemTitle(item) {
|
||||
if (!item) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const spec = safeParseJson(item.spec_json)
|
||||
|
||||
return pickFirstNonEmpty([
|
||||
spec.title,
|
||||
spec.Title,
|
||||
spec.itemTitle,
|
||||
spec.item_title,
|
||||
spec.goods_name,
|
||||
spec.goodsName,
|
||||
item.sku_name,
|
||||
item.sku_code,
|
||||
])
|
||||
}
|
||||
|
||||
export function resolveOrderItemDeliveryMode(tasks, orderItemId) {
|
||||
const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId)
|
||||
|
||||
if (!task) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (String(task.executor_key || '').trim() === 'manual_dispatch') {
|
||||
return 'manual_dispatch'
|
||||
}
|
||||
|
||||
if (task.requires_claim || getTaskPrimaryClaimTokenId(task) || task.primary_claim_token || task.claim_token) {
|
||||
return 'claim_link'
|
||||
}
|
||||
|
||||
return String(task.executor_key || '').trim()
|
||||
}
|
||||
|
||||
export function isManualDispatchTask(task) {
|
||||
return String(task?.executor_key || '').trim() === 'manual_dispatch'
|
||||
}
|
||||
|
||||
export function isAssistedClaimTask(task) {
|
||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
||||
}
|
||||
|
||||
export function canRegenerateClaimLinkForViewer(task, viewerContext) {
|
||||
if (isManualDispatchTask(task)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseAllowed = ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status)
|
||||
|
||||
if (!baseAllowed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (viewerContext.canManageTaskLifecycle) {
|
||||
return true
|
||||
}
|
||||
|
||||
return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task)
|
||||
}
|
||||
|
||||
export function canViewerConfirmAssistedRole(task, viewerContext) {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return String(task?.task_status || '').trim() === 'claimed'
|
||||
}
|
||||
|
||||
export function canViewerRedeemAssistedTask(task, viewerContext) {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim())
|
||||
}
|
||||
|
||||
function canReleaseTaskInventoryBinding(task, binding) {
|
||||
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) {
|
||||
const normalizedStatus = String(task?.task_status || '').trim()
|
||||
|
||||
if (!normalizedStatus) {
|
||||
return {
|
||||
systemBindingStatus: 'pending_binding',
|
||||
userBindingStatus: 'not_started',
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'pending_payment') {
|
||||
return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'paid') {
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'waiting_inventory') {
|
||||
return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'manual_review') {
|
||||
return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'retry_pending') {
|
||||
return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'closed') {
|
||||
return { systemBindingStatus: 'closed', userBindingStatus: 'closed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'expired') {
|
||||
return { systemBindingStatus: 'expired', userBindingStatus: 'expired' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'link_generated') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'claimed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'role_confirmed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeeming') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeemed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
||||
}
|
||||
|
||||
if (isTaskSystemBound(task)) {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
}
|
||||
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
function isTaskSystemBound(task) {
|
||||
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
|
||||
}
|
||||
|
||||
function mapAgisoAutoDeliveryContext(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
status: String(value.status || '').trim(),
|
||||
trigger: String(value.trigger || '').trim(),
|
||||
reason: String(value.reason || '').trim(),
|
||||
platformOrderId: String(value.platformOrderId || '').trim(),
|
||||
responseStatus: Number(value.responseStatus || 0),
|
||||
errorMessage: String(value.errorMessage || '').trim(),
|
||||
requestId: String(value.requestId || '').trim(),
|
||||
aldsType: Number(value.aldsType || 0) || null,
|
||||
updatedAt: value.updatedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapRedeemResolutionAttempt(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
attempt: Math.max(1, Number(value.attempt || 1)),
|
||||
inventoryItemId: Number(value.inventoryItemId || 0) || null,
|
||||
codeMasked: String(value.codeMasked || '').trim(),
|
||||
credentialType: String(value.credentialType || '').trim(),
|
||||
outcome: String(value.outcome || '').trim(),
|
||||
resultCode: String(value.resultCode || '').trim(),
|
||||
resultMessage: String(value.resultMessage || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecord(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function extractWebhookPayload(body) {
|
||||
return extractAgisoTradePayload(body)
|
||||
}
|
||||
|
||||
function extractWebhookItemSources(payload) {
|
||||
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 []
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(values) {
|
||||
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 ''
|
||||
}
|
||||
|
||||
function maskCode(value) {
|
||||
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) {
|
||||
return Number(task?.attempt_count || 0)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// @ts-check
|
||||
|
||||
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 { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||
import {
|
||||
buildOrderAgisoAutoDeliverySummary,
|
||||
buildOrderBindingSummary,
|
||||
canRegenerateClaimLinkForViewer,
|
||||
canViewerConfirmAssistedRole,
|
||||
canViewerRedeemAssistedTask,
|
||||
createAdminViewerContext,
|
||||
createTaskBindingSummaryFromBindings,
|
||||
getRequiredTask,
|
||||
getTaskBindingSummary,
|
||||
getTaskBindingSummaryMap,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
isAssistedClaimTask,
|
||||
isManualDispatchTask,
|
||||
mapAdminInventoryListItem,
|
||||
mapAdminOrderListItem,
|
||||
mapAdminTaskEvent,
|
||||
mapAdminTaskInventoryBinding,
|
||||
mapAdminTaskListItem,
|
||||
mapAdminTaskSummary,
|
||||
mapAdminWebhookEvent,
|
||||
mapManualDispatchContext,
|
||||
parseTaskContext,
|
||||
parseTaskState,
|
||||
mapRedeemResolutionContext,
|
||||
resolveOrderItemDeliveryMode,
|
||||
resolveOrderItemTitle,
|
||||
resolveAdminTaskScreenshotUrl,
|
||||
resolveDisplayShopName,
|
||||
summarizeOrderItems,
|
||||
} from './admin-read-helpers.js'
|
||||
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminInventoryListResponse} AdminInventoryListResponse */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminInventorySkuSuggestionResponse} AdminInventorySkuSuggestionResponse */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminOrderListResponse} AdminOrderListResponse */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminTaskListResponse} AdminTaskListResponse */
|
||||
/** @typedef {import('../../types/admin-read-models.js').AdminWebhookEventListResponse} AdminWebhookEventListResponse */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminInventoryListQueryInput} AdminInventoryListQueryInput */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminInventorySkuSuggestionQueryInput} AdminInventorySkuSuggestionQueryInput */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminOrderListQueryInput} AdminOrderListQueryInput */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminTaskListQueryInput} AdminTaskListQueryInput */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminWebhookEventListQueryInput} AdminWebhookEventListQueryInput */
|
||||
|
||||
/** @returns {Promise<AdminOrderListResponse>} */
|
||||
/** @param {AdminOrderListQueryInput} [query] */
|
||||
export async function getAdminOrders(query = /** @type {AdminOrderListQueryInput} */ ({})) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listOrders({
|
||||
page,
|
||||
pageSize,
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
payStatus: String(query.payStatus || '').trim(),
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: await Promise.all(items.map((item) => mapAdminOrderListItem(item))),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminEntityIdInput} orderId */
|
||||
export async function getAdminOrderDetail(orderId) {
|
||||
const order = await getOrderById(Number(orderId))
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const [items, tasks, webhookEvents] = 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 {
|
||||
order: {
|
||||
orderId: order.id,
|
||||
provider: order.provider || 'agiso',
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id || '',
|
||||
shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name),
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderStatus: order.order_status,
|
||||
payStatus: order.pay_status,
|
||||
buyerId: order.buyer_id,
|
||||
buyerName: order.buyer_name,
|
||||
receiverContact: order.receiver_contact,
|
||||
totalAmount: formatFenToAmount(order.total_amount),
|
||||
totalAmountFen: normalizeFen(order.total_amount),
|
||||
currency: order.currency,
|
||||
paidAt: order.paid_at,
|
||||
createdAt: order.created_at,
|
||||
updatedAt: order.updated_at,
|
||||
itemSummary,
|
||||
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),
|
||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
itemTitle: resolveOrderItemTitle(item),
|
||||
quantity: item.quantity,
|
||||
deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id),
|
||||
spec: item.spec_json && typeof item.spec_json === 'object'
|
||||
? 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 || 'agiso',
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskListResponse>} */
|
||||
/** @param {AdminTaskListQueryInput} [query] */
|
||||
/** @param {AdminViewerSessionInput | null} [session] */
|
||||
export async function getAdminTasks(
|
||||
query = /** @type {AdminTaskListQueryInput} */ ({}),
|
||||
session = null,
|
||||
) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listTasks({
|
||||
page,
|
||||
pageSize,
|
||||
status: String(query.status || '').trim(),
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
taskNo: String(query.taskNo || '').trim(),
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
roleId: String(query.roleId || '').trim(),
|
||||
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,
|
||||
)),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
/** @param {AdminViewerSessionInput | null} [session] */
|
||||
export async function getAdminTaskDetail(taskId, session = null) {
|
||||
const task = await getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = 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)
|
||||
|
||||
return {
|
||||
task: mapAdminTaskListItem({
|
||||
...task,
|
||||
sku_code: orderItem?.sku_code || '',
|
||||
sku_name: orderItem?.sku_name || '',
|
||||
inventory_display_value: inventoryItem?.display_value || '',
|
||||
claim_token: claimToken?.token || '',
|
||||
}, taskBindingSummary, viewerContext),
|
||||
order: order
|
||||
? {
|
||||
orderId: order.id,
|
||||
provider: order.provider || 'agiso',
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id || '',
|
||||
shopName: order.shop_name || '',
|
||||
platformOrderId: order.platform_order_id,
|
||||
payStatus: order.pay_status,
|
||||
orderStatus: order.order_status,
|
||||
}
|
||||
: null,
|
||||
orderItem: orderItem
|
||||
? {
|
||||
orderItemId: orderItem.id,
|
||||
skuCode: orderItem.sku_code,
|
||||
skuName: orderItem.sku_name,
|
||||
quantity: orderItem.quantity,
|
||||
}
|
||||
: null,
|
||||
claimToken: claimToken
|
||||
? {
|
||||
primaryClaimTokenId: claimToken.id,
|
||||
token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '',
|
||||
status: claimToken.status,
|
||||
expiredAt: claimToken.expired_at,
|
||||
claimUrl,
|
||||
}
|
||||
: null,
|
||||
inventory: inventoryItem
|
||||
? {
|
||||
inventoryItemId: inventoryItem.id,
|
||||
skuCode: inventoryItem.sku_code,
|
||||
batchNo: inventoryItem.batch_no,
|
||||
credentialType: inventoryItem.credential_type || 'tencent_code',
|
||||
displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '',
|
||||
status: inventoryItem.status,
|
||||
}
|
||||
: null,
|
||||
inventoryBindings: mappedInventoryBindings,
|
||||
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
|
||||
screenshotUrl,
|
||||
review: {
|
||||
required: isAssistedClaimTask(task),
|
||||
screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null,
|
||||
roleId: String(taskState.reviewRoleId || '').trim() || '',
|
||||
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
||||
},
|
||||
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
|
||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
||||
events: taskEvents.map(mapAdminTaskEvent),
|
||||
operations: {
|
||||
canRetry: viewerContext.canManageTaskLifecycle
|
||||
&& !isManualDispatchTask(task)
|
||||
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
||||
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
|
||||
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
||||
canClose: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed'].includes(task.task_status),
|
||||
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
||||
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
|
||||
canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext),
|
||||
canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext),
|
||||
canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
/** @param {AdminViewerSessionInput | null} [session] */
|
||||
export async function getAdminTaskScreenshotPath(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
if (viewerContext.role === 'support') {
|
||||
if (!task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有客服复核截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_review_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||
}
|
||||
|
||||
if (task.screenshot_path) {
|
||||
return task.screenshot_path
|
||||
}
|
||||
|
||||
if (task.browser_session_id) {
|
||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||
}
|
||||
|
||||
throw createHttpError('当前任务还没有截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminInventoryListResponse>} */
|
||||
/** @param {AdminInventoryListQueryInput} [query] */
|
||||
export async function getAdminInventoryItems(query = /** @type {AdminInventoryListQueryInput} */ ({})) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
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(),
|
||||
})
|
||||
|
||||
return {
|
||||
items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminInventorySkuSuggestionResponse>} */
|
||||
/** @param {AdminInventorySkuSuggestionQueryInput} [query] */
|
||||
export async function getAdminInventorySkuSuggestions(
|
||||
query = /** @type {AdminInventorySkuSuggestionQueryInput} */ ({}),
|
||||
) {
|
||||
const credentialType = String(query.credentialType || '').trim()
|
||||
const keyword = String(query.keyword || '').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,
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
skuCode: String(item.sku_code || '').trim(),
|
||||
credentialType: String(item.credential_type || '').trim() || 'tencent_code',
|
||||
totalCount: Number(item.total_count || 0),
|
||||
availableCount: Number(item.available_count || 0),
|
||||
latestUpdatedAt: item.latest_updated_at || null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminWebhookEventListResponse>} */
|
||||
/** @param {AdminWebhookEventListQueryInput} [query] */
|
||||
export async function getAdminWebhookEvents(query = /** @type {AdminWebhookEventListQueryInput} */ ({})) {
|
||||
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(),
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminEntityIdInput} eventId */
|
||||
export async function getAdminWebhookEventDetail(eventId) {
|
||||
const event = await getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return mapAdminWebhookEvent(event, { includeRaw: true })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,719 @@
|
||||
// @ts-check
|
||||
|
||||
import {
|
||||
createInventoryItems,
|
||||
invalidateInventoryItem,
|
||||
listInventoryItems,
|
||||
markInventoryItemDelivered,
|
||||
releaseReservedInventoryItem,
|
||||
} from '../../repositories/inventory-repo.js'
|
||||
import { updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { updateTask } from '../../repositories/task-repo.js'
|
||||
import {
|
||||
getTaskInventoryBindingById,
|
||||
listTaskInventoryBindingsByTaskId,
|
||||
} from '../../repositories/task-inventory-binding-repo.js'
|
||||
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||
import { getWebhookEventById } from '../../repositories/webhook-event-repo.js'
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js'
|
||||
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
|
||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
canViewerConfirmAssistedRole,
|
||||
canViewerRedeemAssistedTask,
|
||||
createAdminViewerContext,
|
||||
getRequiredInventoryItem,
|
||||
getRequiredTask,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
isAssistedClaimTask,
|
||||
isManualDispatchTask,
|
||||
mapAdminInventoryListItem,
|
||||
mapTaskActionPayload,
|
||||
parseTaskContext,
|
||||
} from './admin-read-helpers.js'
|
||||
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */
|
||||
/** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */
|
||||
/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryCreateInput} AdminInventoryCreateInput */
|
||||
/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryImportInput} AdminInventoryImportInput */
|
||||
/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryImportRowInput} AdminInventoryImportRowInput */
|
||||
/** @typedef {import('../../types/admin-write-inputs.js').AdminInventoryInvalidateInput} AdminInventoryInvalidateInput */
|
||||
/** @typedef {import('../../types/admin-write-models.js').AdminInventoryImportResponse} AdminInventoryImportResponse */
|
||||
/** @typedef {import('../../types/admin-write-models.js').AdminInventoryMutationResponse} AdminInventoryMutationResponse */
|
||||
/** @typedef {import('../../types/admin-write-models.js').AdminTaskActionResponse} AdminTaskActionResponse */
|
||||
/** @typedef {import('../../types/admin-write-models.js').AdminTaskBindingReleaseResponse} AdminTaskBindingReleaseResponse */
|
||||
/** @typedef {import('../../types/admin-write-models.js').AdminTaskManualDispatchResponse} AdminTaskManualDispatchResponse */
|
||||
/** @typedef {import('../../types/admin-write-models.js').AdminWebhookReplayResponse} AdminWebhookReplayResponse */
|
||||
|
||||
/** @returns {Promise<AdminInventoryMutationResponse>} */
|
||||
/** @param {AdminInventoryCreateInput} [payload] */
|
||||
export async function createAdminInventoryItem(payload = /** @type {AdminInventoryCreateInput} */ ({})) {
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const displayValue = String(payload.displayValue || '').trim()
|
||||
const batchNo = String(payload.batchNo || '').trim()
|
||||
const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code'
|
||||
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
])
|
||||
|
||||
if (created === 0) {
|
||||
throw createHttpError('库存凭据已存在,不能重复新增', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_inventory_duplicate',
|
||||
})
|
||||
}
|
||||
|
||||
const { items } = await listInventoryItems({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
skuCode,
|
||||
})
|
||||
const createdItem = items.find((item) => item.display_value === displayValue) || null
|
||||
|
||||
return {
|
||||
inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminInventoryImportResponse>} */
|
||||
/** @param {AdminInventoryImportInput} [payload] */
|
||||
export async function importAdminInventoryItems(payload = /** @type {AdminInventoryImportInput} */ ({})) {
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
|
||||
const created = await createInventoryItems(normalizedRows)
|
||||
|
||||
return {
|
||||
total: normalizedRows.length,
|
||||
created,
|
||||
duplicated: normalizedRows.length - created,
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminInventoryMutationResponse>} */
|
||||
/** @param {AdminEntityIdInput} inventoryItemId */
|
||||
export async function releaseAdminInventoryItem(inventoryItemId) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminInventoryMutationResponse>} */
|
||||
/** @param {AdminEntityIdInput} inventoryItemId */
|
||||
/** @param {AdminInventoryInvalidateInput} [payload] */
|
||||
export async function invalidateAdminInventoryItem(
|
||||
inventoryItemId,
|
||||
payload = /** @type {AdminInventoryInvalidateInput} */ ({}),
|
||||
) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminWebhookReplayResponse>} */
|
||||
/** @param {AdminEntityIdInput} eventId */
|
||||
export async function replayAdminWebhookEvent(eventId) {
|
||||
const event = await getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (String(event.provider || event.platform || '').trim() !== 'agiso') {
|
||||
throw createHttpError('当前只支持重放 agiso webhook', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_webhook_replay_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await replayAgisoTradeWebhookEvent(event)
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
replayed: true,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskActionResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
export async function releaseAdminTaskInventory(taskId) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
|
||||
if (!primaryInventoryItemId) {
|
||||
throw createHttpError('当前任务没有预占库存项', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_no_reserved_inventory',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放库存项', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await releaseReservedInventoryItem(primaryInventoryItemId, now)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
inventory_status: 'pending',
|
||||
last_error: '已手动释放预占库存项',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskBindingReleaseResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
/** @param {AdminEntityIdInput} bindingId */
|
||||
export async function releaseAdminTaskInventoryBinding(taskId, bindingId) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const binding = await getTaskInventoryBindingById(Number(bindingId))
|
||||
|
||||
if (!binding || Number(binding.task_id) !== Number(task.id)) {
|
||||
throw createHttpError('任务库存绑定不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_inventory_binding_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (String(binding.binding_status || '').trim() !== 'reserved') {
|
||||
throw createHttpError('当前库存绑定不是预占状态,不能释放', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_inventory_binding_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放库存绑定', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await releaseReservedInventoryItem(binding.inventory_item_id, now)
|
||||
|
||||
const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id)
|
||||
const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim()))
|
||||
const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved')
|
||||
const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed')
|
||||
const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending')
|
||||
const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim())
|
||||
? 'waiting_inventory'
|
||||
: task.task_status
|
||||
|
||||
if (!hasReservedBindings) {
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
if (primaryClaimTokenId) {
|
||||
await updateClaimToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
inventory_status: nextInventoryStatus,
|
||||
last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(task.id, 'inventory_binding_released', {
|
||||
bindingId: Number(binding.id),
|
||||
inventoryItemId: Number(binding.inventory_item_id),
|
||||
roleKey: String(binding.role_key || '').trim(),
|
||||
}, now)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
bindingId: Number(binding.id),
|
||||
inventoryItemId: Number(binding.inventory_item_id),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskActionResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
/** @param {AdminViewerSessionInput | null} [session] */
|
||||
export async function regenerateAdminTaskClaimLink(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
if (isManualDispatchTask(task)) {
|
||||
throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_claim_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重新生成领取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_regenerate_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) {
|
||||
throw createHttpError('当前账号只能重发半自动客服任务的领取链接', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_task_regenerate_permission_denied',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
if (primaryClaimTokenId) {
|
||||
await updateClaimToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
claimUrl: claimToken.claimUrl,
|
||||
token: claimToken.token,
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskActionResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
/** @param {AdminViewerSessionInput | null} [session] */
|
||||
export async function confirmAdminTaskAssistedRole(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm')
|
||||
await confirmClaimRoleForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskActionResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
/** @param {AdminViewerSessionInput | null} [session] */
|
||||
export async function redeemAdminTaskAssisted(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem')
|
||||
await redeemClaimTaskForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskActionResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
export async function closeAdminTask(taskId) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能关闭', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_close_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'closed',
|
||||
delivery_status: 'closed',
|
||||
last_error: task.last_error || '已手动关闭任务',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskActionResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
export async function markAdminTaskManualReview(taskId) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
delivery_status: task.delivery_status || 'pending',
|
||||
last_error: task.last_error || '已转人工处理',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskActionResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
export async function retryAdminTask(taskId) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const now = nowIso()
|
||||
|
||||
if (isManualDispatchTask(task)) {
|
||||
throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const taskContext = parseTaskContext(task)
|
||||
const primaryRequirement = taskContext.primaryRequirement || null
|
||||
const orderItems = await listOrderItemsByOrderId(task.order_id)
|
||||
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
||||
let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
let claimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
let nextStatus = 'link_generated'
|
||||
let lastError = ''
|
||||
let claimExpiresAt = getTaskClaimExpiresAt(task)
|
||||
let claimUrl = ''
|
||||
let token = ''
|
||||
|
||||
if (!reservedInventoryItemId) {
|
||||
const reserved = await reserveInventoryForTask({
|
||||
skuCode: orderItem?.sku_code || '',
|
||||
taskId: task.id,
|
||||
credentialType: primaryRequirement?.credentialType || 'tencent_code',
|
||||
roleKey: primaryRequirement?.roleKey || 'primary_code',
|
||||
})
|
||||
|
||||
if (!reserved) {
|
||||
nextStatus = 'waiting_inventory'
|
||||
lastError = '库存不足,等待可用库存凭据'
|
||||
} else {
|
||||
reservedInventoryItemId = reserved.id
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus === 'link_generated' && !claimTokenId) {
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
claimTokenId = claimToken.id
|
||||
claimExpiresAt = claimToken.expired_at
|
||||
claimUrl = claimToken.claimUrl
|
||||
token = claimToken.token
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextStatus,
|
||||
inventory_status: reservedInventoryItemId ? 'reserved' : 'pending',
|
||||
claim_token: token || task.claim_token || '',
|
||||
claim_expires_at: claimExpiresAt,
|
||||
last_error: lastError,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
const response = {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
|
||||
if (claimUrl) {
|
||||
response.claimUrl = claimUrl
|
||||
response.token = token
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/** @returns {Promise<AdminTaskManualDispatchResponse>} */
|
||||
/** @param {AdminEntityIdInput} taskId */
|
||||
/** @param {{ outcome?: string, resultMessage?: string, deliveryReference?: string, deliveredCredential?: string }} [payload] */
|
||||
/** @param {AdminViewerSessionInput | null} [session] */
|
||||
export async function completeAdminTaskManualDispatch(taskId, payload = {}, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
|
||||
if (!isManualDispatchTask(task)) {
|
||||
throw createHttpError('当前任务不是人工履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_not_manual_dispatch',
|
||||
})
|
||||
}
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_already_completed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const outcome = normalizeManualDispatchOutcome(payload.outcome)
|
||||
const resultMessage = String(payload.resultMessage || '').trim()
|
||||
const deliveryReference = String(payload.deliveryReference || '').trim()
|
||||
const deliveredCredential = String(payload.deliveredCredential || '').trim()
|
||||
const context = parseTaskContext(task)
|
||||
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered'
|
||||
const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成'
|
||||
const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed'
|
||||
const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered'
|
||||
const nextInventoryStatus = outcome === 'delivered' && inventoryItemId
|
||||
? 'consumed'
|
||||
: task.inventory_status || 'not_required'
|
||||
const manualDispatch = {
|
||||
outcome,
|
||||
deliveryReference,
|
||||
deliveredCredential,
|
||||
resultMessage: resultMessage || fallbackMessage,
|
||||
completedAt: now,
|
||||
completedBy: session
|
||||
? {
|
||||
userId: Number(session.userId || 0),
|
||||
username: String(session.username || ''),
|
||||
role: String(session.role || ''),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
||||
if (outcome === 'delivered' && inventoryItemId) {
|
||||
await markInventoryItemDelivered(inventoryItemId, now)
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
inventory_status: nextInventoryStatus,
|
||||
delivery_status: nextDeliveryStatus,
|
||||
result_code: resultCode,
|
||||
result_message: resultMessage || fallbackMessage,
|
||||
user_action_status: 'not_required',
|
||||
last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '',
|
||||
redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null,
|
||||
context_json: JSON.stringify({
|
||||
...context,
|
||||
manualDispatch,
|
||||
}),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(task.id, 'manual_dispatch_completed', {
|
||||
outcome,
|
||||
resultCode,
|
||||
resultMessage: resultMessage || fallbackMessage,
|
||||
deliveryReference,
|
||||
deliveredCredentialMasked: maskCode(deliveredCredential),
|
||||
completedBy: manualDispatch.completedBy,
|
||||
}, now)
|
||||
|
||||
const taskAfterAutoDelivery = outcome === 'delivered'
|
||||
? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order: await getOrderById(task.order_id),
|
||||
task: updatedTask,
|
||||
trigger: 'manual_dispatch_completed',
|
||||
})).task || updatedTask
|
||||
: updatedTask
|
||||
|
||||
return {
|
||||
outcome,
|
||||
task: mapTaskActionPayload(taskAfterAutoDelivery),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AdminInventoryImportInput} payload
|
||||
* @returns {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType'>>>}
|
||||
*/
|
||||
function normalizeInventoryImportRows(payload) {
|
||||
const rows = []
|
||||
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 || 'tencent_code').trim() || 'tencent_code'
|
||||
|
||||
if (!skuCode || !displayValue) {
|
||||
continue
|
||||
}
|
||||
|
||||
rows.push({ skuCode, displayValue, batchNo, credentialType })
|
||||
}
|
||||
}
|
||||
|
||||
if (bulkCodes.length > 0) {
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const batchNo = String(payload.batchNo || '').trim()
|
||||
const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code'
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeRows(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType'>>>} rows
|
||||
*/
|
||||
function dedupeRows(rows) {
|
||||
const seen = new Set()
|
||||
const output = []
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.skuCode}::${row.credentialType || 'tencent_code'}::${row.displayValue}`
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
output.push(row)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {Awaited<ReturnType<typeof getRequiredTask>>} task
|
||||
* @param {ReturnType<typeof createAdminViewerContext>} viewerContext
|
||||
* @param {'confirm' | 'redeem'} action
|
||||
*/
|
||||
function ensureViewerCanOperateAssistedTask(task, viewerContext, action) {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
|
||||
throw createHttpError('当前账号没有此操作权限', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_task_assisted_permission_denied',
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'confirm' && !canViewerConfirmAssistedRole(task, viewerContext)) {
|
||||
throw createHttpError('当前任务状态还不能确认角色', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_assisted_confirm_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'redeem' && !canViewerRedeemAssistedTask(task, viewerContext)) {
|
||||
throw createHttpError('当前任务状态还不能开始兑换', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_assisted_redeem_not_allowed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManualDispatchOutcome(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
|
||||
if (normalized === 'failed') {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
return 'delivered'
|
||||
}
|
||||
|
||||
function getTaskClaimExpiresAt(task) {
|
||||
return task?.claim_expires_at || task?.primary_claim_expires_at || null
|
||||
}
|
||||
|
||||
function maskCode(value) {
|
||||
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)}`
|
||||
}
|
||||
@@ -8,6 +8,15 @@ import { parseJsonObject } from '../../../../utils/json.js'
|
||||
import { logWebhook } from '../../../../utils/logger.js'
|
||||
import { nowIso } from '../../../../utils/time.js'
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* order?: Record<string, any> | null
|
||||
* task?: Record<string, any> | null
|
||||
* trigger?: string
|
||||
* }} EnsureAgisoXianyuAutoDeliveryInput
|
||||
*/
|
||||
|
||||
/** @param {EnsureAgisoXianyuAutoDeliveryInput} input */
|
||||
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order,
|
||||
task,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @ts-check
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* userId?: number | string
|
||||
* username?: string
|
||||
* role?: string
|
||||
* }} AdminViewerSessionInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number | string
|
||||
* pageSize?: number | string
|
||||
* platformOrderId?: string
|
||||
* payStatus?: string
|
||||
* skuCode?: string
|
||||
* dateFrom?: string
|
||||
* dateTo?: string
|
||||
* }} AdminOrderListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number | string
|
||||
* pageSize?: number | string
|
||||
* status?: string
|
||||
* platformOrderId?: string
|
||||
* taskNo?: string
|
||||
* skuCode?: string
|
||||
* roleId?: string
|
||||
* dateFrom?: string
|
||||
* dateTo?: string
|
||||
* }} AdminTaskListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number | string
|
||||
* pageSize?: number | string
|
||||
* skuCode?: string
|
||||
* credentialType?: string
|
||||
* status?: string
|
||||
* batchNo?: string
|
||||
* }} AdminInventoryListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* credentialType?: string
|
||||
* keyword?: string
|
||||
* limit?: number | string
|
||||
* }} AdminInventorySkuSuggestionQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number | string
|
||||
* pageSize?: number | string
|
||||
* provider?: string
|
||||
* platform?: string
|
||||
* platformOrderId?: string
|
||||
* processed?: string
|
||||
* relatedOrderId?: string
|
||||
* dateFrom?: string
|
||||
* dateTo?: string
|
||||
* }} AdminWebhookEventListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {number | string} AdminEntityIdInput
|
||||
*/
|
||||
@@ -0,0 +1,204 @@
|
||||
// @ts-check
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* totalBindingCount: number
|
||||
* reservedBindingCount: number
|
||||
* consumedBindingCount: number
|
||||
* releasedBindingCount: number
|
||||
* roleKeys: string[]
|
||||
* }} AdminTaskBindingSummary
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* status: string
|
||||
* trigger: string
|
||||
* reason: string
|
||||
* platformOrderId: string
|
||||
* responseStatus: number
|
||||
* errorMessage: string
|
||||
* requestId: string
|
||||
* aldsType: number | null
|
||||
* updatedAt: string | null
|
||||
* }} AdminAgisoAutoDeliveryStatus
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* orderId: number
|
||||
* provider: string
|
||||
* platform: string
|
||||
* shopId: string
|
||||
* shopName: string
|
||||
* platformOrderId: string
|
||||
* orderStatus: string
|
||||
* payStatus: string
|
||||
* buyerId: string
|
||||
* buyerName: string
|
||||
* receiverContact: string
|
||||
* totalAmount: string
|
||||
* totalAmountFen: number
|
||||
* currency: string
|
||||
* paidAt: string | null
|
||||
* createdAt: string
|
||||
* updatedAt: string
|
||||
* itemCount: number
|
||||
* totalQuantity: number
|
||||
* itemSummary: string
|
||||
* taskCount: number
|
||||
* systemBindingStatus: string
|
||||
* userBindingStatus: string
|
||||
* systemBoundTaskCount: number
|
||||
* completedBindingTaskCount: number
|
||||
* totalBindingCount: number
|
||||
* reservedBindingCount: number
|
||||
* consumedBindingCount: number
|
||||
* releasedBindingCount: number
|
||||
* }} AdminOrderListItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* taskId: number
|
||||
* taskNo: string
|
||||
* platformOrderId: string
|
||||
* skuCode: string
|
||||
* skuName: string
|
||||
* status: string
|
||||
* executorKey: string
|
||||
* deliveryStatus: string
|
||||
* resultCode: string
|
||||
* resultMessage: string
|
||||
* systemBindingStatus: string
|
||||
* userBindingStatus: string
|
||||
* loginType: string
|
||||
* roleName: string
|
||||
* roleId: string
|
||||
* browserSessionId: string
|
||||
* claimedAt: string | null
|
||||
* roleConfirmedAt: string | null
|
||||
* redeemedAt: string | null
|
||||
* retryCount: number
|
||||
* bindingSummary: AdminTaskBindingSummary
|
||||
* lastError: string
|
||||
* createdAt: string
|
||||
* updatedAt: string
|
||||
* inventoryDisplayMasked: string
|
||||
* inventoryCredentialType: string
|
||||
* claimToken: string
|
||||
* screenshotPath: string
|
||||
* agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null
|
||||
* }} AdminTaskListItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* inventoryItemId: number
|
||||
* skuCode: string
|
||||
* batchNo: string
|
||||
* credentialType: 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
|
||||
* }} AdminInventoryItemListItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* eventId: number
|
||||
* provider: string
|
||||
* platform: string
|
||||
* platformRaw: string
|
||||
* shopId: string
|
||||
* shopName: string
|
||||
* eventType: string
|
||||
* eventKey: string
|
||||
* signatureValid: boolean
|
||||
* processed: boolean
|
||||
* processError: 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>
|
||||
* }} AdminWebhookEventListItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* skuCode: string
|
||||
* credentialType: string
|
||||
* totalCount: number
|
||||
* availableCount: number
|
||||
* latestUpdatedAt: string | null
|
||||
* }} AdminInventorySkuSuggestion
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page: number
|
||||
* pageSize: number
|
||||
* total: number
|
||||
* }} AdminPagination
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: AdminOrderListItem[]
|
||||
* pagination: AdminPagination
|
||||
* }} AdminOrderListResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: AdminTaskListItem[]
|
||||
* pagination: AdminPagination
|
||||
* }} AdminTaskListResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: AdminInventoryItemListItem[]
|
||||
* pagination: AdminPagination
|
||||
* }} AdminInventoryListResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: AdminWebhookEventListItem[]
|
||||
* pagination: AdminPagination
|
||||
* }} AdminWebhookEventListResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: AdminInventorySkuSuggestion[]
|
||||
* }} AdminInventorySkuSuggestionResponse
|
||||
*/
|
||||
@@ -0,0 +1,56 @@
|
||||
// @ts-check
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* skuCode?: string
|
||||
* displayValue?: string
|
||||
* batchNo?: string
|
||||
* credentialType?: string
|
||||
* }} AdminInventoryCreateInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* skuCode?: string
|
||||
* displayValue?: string
|
||||
* batchNo?: string
|
||||
* credentialType?: string
|
||||
* }} AdminInventoryImportRowInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* rows?: AdminInventoryImportRowInput[]
|
||||
* codes?: unknown[]
|
||||
* skuCode?: string
|
||||
* batchNo?: string
|
||||
* credentialType?: string
|
||||
* }} AdminInventoryImportInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* shopId?: string
|
||||
* shopName?: string
|
||||
* accessToken?: string
|
||||
* enabled?: boolean | null
|
||||
* messageTemplate?: string
|
||||
* appSecret?: string
|
||||
* apiVersion?: string
|
||||
* sendMessageEndpoint?: string
|
||||
* }} AdminAgisoShopConfigWriteItemInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* shops?: AdminAgisoShopConfigWriteItemInput[]
|
||||
* }} AdminAgisoShopConfigSaveInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* reason?: string
|
||||
* }} AdminInventoryInvalidateInput
|
||||
*/
|
||||
@@ -0,0 +1,90 @@
|
||||
// @ts-check
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* @typedef {import('./admin-read-models.js').AdminInventoryItemListItem} AdminInventoryItemListItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* inventoryItem: AdminInventoryItemListItem | null
|
||||
* }} AdminInventoryMutationResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* total: number
|
||||
* created: number
|
||||
* duplicated: number
|
||||
* }} AdminInventoryImportResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* shopId: string
|
||||
* shopName: string
|
||||
* accessToken: string
|
||||
* accessTokenMasked: string
|
||||
* enabled: boolean | null
|
||||
* messageTemplate: string
|
||||
* appSecretConfigured: boolean
|
||||
* apiVersion: string
|
||||
* sendMessageEndpoint: string
|
||||
* }} AdminAgisoShopConfigItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* filePath: string
|
||||
* shops: AdminAgisoShopConfigItem[]
|
||||
* }} AdminAgisoShopConfigSaveResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* eventId: number
|
||||
* replayed: boolean
|
||||
* result: unknown
|
||||
* }} AdminWebhookReplayResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* taskId: number
|
||||
* taskNo: string
|
||||
* status: string
|
||||
* deliveryStatus: string
|
||||
* resultCode: string
|
||||
* resultMessage: string
|
||||
* inventoryItemId: number | null
|
||||
* primaryClaimTokenId: number | null
|
||||
* lastError: string
|
||||
* updatedAt: string
|
||||
* }} AdminTaskActionPayload
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* task: AdminTaskActionPayload
|
||||
* claimUrl?: string
|
||||
* token?: string
|
||||
* }} AdminTaskActionResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AdminTaskActionResponse & {
|
||||
* bindingId: number
|
||||
* inventoryItemId: number
|
||||
* }} AdminTaskBindingReleaseResponse
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {'delivered' | 'failed'} AdminManualDispatchOutcome
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AdminTaskActionResponse & {
|
||||
* outcome: AdminManualDispatchOutcome
|
||||
* }} AdminTaskManualDispatchResponse
|
||||
*/
|
||||
@@ -0,0 +1,213 @@
|
||||
// @ts-check
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number
|
||||
* pageSize?: number
|
||||
* platformOrderId?: string
|
||||
* payStatus?: string
|
||||
* skuCode?: string
|
||||
* dateFrom?: string
|
||||
* dateTo?: string
|
||||
* }} OrderListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* provider: string
|
||||
* platform: string
|
||||
* shopId?: string
|
||||
* shopName?: string
|
||||
* platformOrderId: string
|
||||
* orderStatus: string
|
||||
* payStatus: string
|
||||
* buyerId: string
|
||||
* buyerName: string
|
||||
* receiverContact: string
|
||||
* totalAmount: number | string
|
||||
* currency: string
|
||||
* rawPayloadJson?: string | Record<string, unknown>
|
||||
* paidAt?: string | null
|
||||
* createdAt: string
|
||||
* updatedAt: string
|
||||
* }} OrderUpsertInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* skuCode: string
|
||||
* skuName: string
|
||||
* quantity: number
|
||||
* specJson?: string | Record<string, unknown>
|
||||
* itemSnapshotJson?: string | Record<string, unknown>
|
||||
* createdAt: string
|
||||
* updatedAt: string
|
||||
* }} OrderItemReplaceInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number
|
||||
* pageSize?: number
|
||||
* status?: string
|
||||
* platformOrderId?: string
|
||||
* taskNo?: string
|
||||
* skuCode?: string
|
||||
* roleId?: string
|
||||
* dateFrom?: string
|
||||
* dateTo?: string
|
||||
* }} TaskListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* browserSessionId?: string
|
||||
* loginType?: string
|
||||
* nickname?: string
|
||||
* roleId?: string
|
||||
* roleName?: string
|
||||
* area?: string
|
||||
* partitionName?: string
|
||||
* screenshotPath?: string
|
||||
* artifactsJson?: string | Record<string, unknown>
|
||||
* stateJson?: string | Record<string, unknown>
|
||||
* }} TaskTencentContextPatch
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* orderId: number
|
||||
* orderItemId: number
|
||||
* unitIndex: number
|
||||
* taskNo: string
|
||||
* provider: string
|
||||
* platform: string
|
||||
* shopId?: string
|
||||
* shopName?: string
|
||||
* platformOrderId: string
|
||||
* profileId: string
|
||||
* executorKey: string
|
||||
* taskStatus?: string
|
||||
* inventoryStatus?: string
|
||||
* deliveryStatus?: string
|
||||
* resultCode?: string
|
||||
* resultMessage?: string
|
||||
* claimToken?: string
|
||||
* claimExpiresAt?: string | null
|
||||
* automationMode?: string
|
||||
* requiresClaim?: boolean
|
||||
* userActionStatus?: string
|
||||
* attemptCount?: number
|
||||
* lastError?: string
|
||||
* contextJson?: string | Record<string, unknown>
|
||||
* createdAt: string
|
||||
* updatedAt: string
|
||||
* tencentContext?: TaskTencentContextPatch
|
||||
* }} TaskCreateInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* task_status?: string
|
||||
* inventory_status?: string
|
||||
* delivery_status?: string
|
||||
* result_code?: string
|
||||
* result_message?: string
|
||||
* claim_token?: string
|
||||
* claim_expires_at?: string | null
|
||||
* automation_mode?: string
|
||||
* requires_claim?: boolean
|
||||
* user_action_status?: string
|
||||
* attempt_count?: number
|
||||
* last_error?: string
|
||||
* context_json?: string | Record<string, unknown>
|
||||
* claimed_at?: string | null
|
||||
* role_confirmed_at?: string | null
|
||||
* redeemed_at?: string | null
|
||||
* updated_at?: string
|
||||
* browser_session_id?: string
|
||||
* login_type?: string
|
||||
* nickname?: string
|
||||
* role_id?: string
|
||||
* role_name?: string
|
||||
* area?: string
|
||||
* partition_name?: string
|
||||
* screenshot_path?: string
|
||||
* artifacts_json?: string | Record<string, unknown>
|
||||
* state_json?: string | Record<string, unknown>
|
||||
* }} TaskUpdatePatch
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number
|
||||
* pageSize?: number
|
||||
* skuCode?: string
|
||||
* credentialType?: string
|
||||
* status?: string
|
||||
* batchNo?: string
|
||||
* }} InventoryListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* credentialType?: string
|
||||
* keyword?: string
|
||||
* limit?: number
|
||||
* }} InventorySkuSuggestionQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* batchNo?: string
|
||||
* skuCode: string
|
||||
* credentialType?: string
|
||||
* displayValue: string
|
||||
* payload?: Record<string, unknown>
|
||||
* createdAt: string
|
||||
* updatedAt: string
|
||||
* }} InventoryCreateItemInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* page?: number
|
||||
* pageSize?: number
|
||||
* provider?: string
|
||||
* platform?: string
|
||||
* platformOrderId?: string
|
||||
* processed?: string
|
||||
* relatedOrderId?: string
|
||||
* dateFrom?: string
|
||||
* dateTo?: string
|
||||
* }} WebhookEventListQueryInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* 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
|
||||
* }} WebhookEventCreateInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* processed?: boolean
|
||||
* process_error?: string
|
||||
* related_order_id?: number | null
|
||||
* }} WebhookEventUpdatePatch
|
||||
*/
|
||||
@@ -0,0 +1,215 @@
|
||||
// @ts-check
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* id: number
|
||||
* provider: string
|
||||
* platform: string
|
||||
* shop_id: string
|
||||
* shop_name: string
|
||||
* platform_order_id: string
|
||||
* order_status: string
|
||||
* pay_status: string
|
||||
* buyer_id: string
|
||||
* buyer_name: string
|
||||
* receiver_contact: string
|
||||
* total_amount: number | string
|
||||
* currency: string
|
||||
* raw_payload_json: string | Record<string, unknown>
|
||||
* paid_at: string | null
|
||||
* created_at: string
|
||||
* updated_at: string
|
||||
* }} OrderRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {OrderRow & {
|
||||
* task_count?: number
|
||||
* }} OrderListRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* id: number
|
||||
* order_id: number
|
||||
* sku_code: string
|
||||
* sku_name: string
|
||||
* quantity: number
|
||||
* spec_json: string | Record<string, unknown>
|
||||
* item_snapshot_json?: string | Record<string, unknown>
|
||||
* created_at?: string
|
||||
* updated_at?: string
|
||||
* }} OrderItemRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* id: number
|
||||
* order_id: number
|
||||
* order_item_id: number
|
||||
* platform_order_id: string
|
||||
* task_no: string
|
||||
* executor_key: string
|
||||
* task_status: string
|
||||
* delivery_status: string
|
||||
* result_code: string
|
||||
* result_message: string
|
||||
* inventory_status: string
|
||||
* user_action_status: string
|
||||
* browser_session_id: string
|
||||
* login_type: string
|
||||
* role_name: string
|
||||
* role_id: string
|
||||
* claim_token: string
|
||||
* primary_claim_token: string
|
||||
* primary_claim_token_id: number | null
|
||||
* primary_inventory_item_id: number | null
|
||||
* 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
|
||||
* last_error: string
|
||||
* retry_count: number
|
||||
* created_at: string
|
||||
* updated_at: string
|
||||
* claimed_at: string | null
|
||||
* role_confirmed_at: string | null
|
||||
* redeemed_at: string | null
|
||||
* sku_code?: string
|
||||
* sku_name?: string
|
||||
* }} TaskRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* id: number
|
||||
* sku_code: string
|
||||
* batch_no: string
|
||||
* credential_type: 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
|
||||
* }} InventoryItemRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* sku_code: string
|
||||
* credential_type: string
|
||||
* total_count: number
|
||||
* available_count: number
|
||||
* latest_updated_at: string | null
|
||||
* }} InventorySkuSuggestionRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* 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
|
||||
* }} WebhookEventRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* 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
|
||||
* display_value: string
|
||||
* inventory_status: string
|
||||
* }} TaskInventoryBindingRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* task_id: number
|
||||
* total_binding_count: number
|
||||
* reserved_binding_count: number
|
||||
* consumed_binding_count: number
|
||||
* released_binding_count: number
|
||||
* role_keys: string[]
|
||||
* }} TaskInventoryBindingSummaryRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* id: number
|
||||
* token: string
|
||||
* status: string
|
||||
* expired_at: string
|
||||
* created_at?: string
|
||||
* updated_at?: string
|
||||
* }} ClaimTokenRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* id: number
|
||||
* event_type: string
|
||||
* payload_json: string | Record<string, unknown>
|
||||
* created_at: string
|
||||
* }} TaskEventRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: OrderListRow[]
|
||||
* total: number
|
||||
* }} OrderListQueryResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: TaskRow[]
|
||||
* total: number
|
||||
* }} TaskListQueryResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: InventoryItemRow[]
|
||||
* total: number
|
||||
* }} InventoryListQueryResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* items: WebhookEventRow[]
|
||||
* total: number
|
||||
* }} WebhookEventListQueryResult
|
||||
*/
|
||||
@@ -0,0 +1,102 @@
|
||||
// @ts-check
|
||||
|
||||
export {}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* username: string
|
||||
* password: string
|
||||
* role: string
|
||||
* status?: string
|
||||
* }} AdminDefaultUser
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* enabled?: boolean
|
||||
* shopName?: string
|
||||
* accessToken?: string
|
||||
* messageTemplate?: string
|
||||
* appSecret?: string
|
||||
* apiVersion?: string
|
||||
* sendMessageEndpoint?: string
|
||||
* }} AgisoMessagingShopConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, AgisoMessagingShopConfig>} AgisoMessagingShopsConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* server: {
|
||||
* port: number
|
||||
* }
|
||||
* browser: {
|
||||
* chromePath: string
|
||||
* headless: boolean | null
|
||||
* devtools: boolean | null
|
||||
* keepAlive: boolean | null
|
||||
* prewarm: boolean | null
|
||||
* slowMoMs: number | null
|
||||
* }
|
||||
* session: {
|
||||
* debug: boolean
|
||||
* }
|
||||
* ocr: {
|
||||
* projectRoot: string
|
||||
* }
|
||||
* data: {
|
||||
* root: string
|
||||
* }
|
||||
* database: {
|
||||
* url: string
|
||||
* ssl: boolean
|
||||
* maxConnections: number
|
||||
* }
|
||||
* orders: {
|
||||
* claimBaseUrl: string
|
||||
* tokenTtlHours: number
|
||||
* }
|
||||
* admin: {
|
||||
* sessionSecret: string
|
||||
* sessionTtlHours: number
|
||||
* defaultUsers: AdminDefaultUser[]
|
||||
* }
|
||||
* platforms: {
|
||||
* agiso: {
|
||||
* appSecret: string
|
||||
* tradeDetail: {
|
||||
* endpoint: string
|
||||
* apiVersion: string
|
||||
* timeoutMs: number
|
||||
* }
|
||||
* autoDelivery: {
|
||||
* enabled: boolean
|
||||
* endpoint: string
|
||||
* apiVersion: string
|
||||
* aldsType: number
|
||||
* ignoreAldsLog: boolean
|
||||
* ignoreBlackList: boolean
|
||||
* ignoreOnOff: boolean
|
||||
* ignoreRefundCheck: boolean
|
||||
* ignoreRestricted: boolean
|
||||
* ignoreTradeStatusCheck: boolean
|
||||
* }
|
||||
* messaging: {
|
||||
* enabled: boolean
|
||||
* appId: string
|
||||
* accessToken: string
|
||||
* appSecret: string
|
||||
* apiVersion: string
|
||||
* sendMessageEndpoint: string
|
||||
* messageTemplate: string
|
||||
* shops: AgisoMessagingShopsConfig
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* redeem: {
|
||||
* proofMode: string
|
||||
* }
|
||||
* }} RuntimeConfig
|
||||
*/
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": [
|
||||
"src/config/runtime.js",
|
||||
"src/repositories/inventory-repo.js",
|
||||
"src/repositories/order-item-repo.js",
|
||||
"src/repositories/order-repo.js",
|
||||
"src/repositories/task-inventory-binding-repo.js",
|
||||
"src/repositories/task-repo.js",
|
||||
"src/repositories/webhook-event-repo.js",
|
||||
"src/services/admin/admin-read-helpers.js",
|
||||
"src/services/admin/admin-dashboard-service.js",
|
||||
"src/services/admin/admin-message-delivery-service.js",
|
||||
"src/services/admin/admin-platform-config-service.js",
|
||||
"src/services/admin/admin-read-service.js",
|
||||
"src/services/admin/admin-service.js",
|
||||
"src/services/admin/admin-write-service.js",
|
||||
"src/types/**/*.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"data"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user