重构后台管理服务并推进渐进式类型化
This commit is contained in:
@@ -40,6 +40,19 @@ npm run browser:install:linux
|
|||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后端渐进 TypeScript 迁移已经开始,当前可执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
第一阶段目前先检查:
|
||||||
|
|
||||||
|
- `src/config/runtime.js`
|
||||||
|
- `src/types/**/*.js`
|
||||||
|
|
||||||
|
后续会逐步扩大到更多 routes / services / repositories,而不会先改成需要编译产物的运行模式。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
当前后端采用 `.env` 单一路径做环境配置:
|
当前后端采用 `.env` 单一路径做环境配置:
|
||||||
|
|||||||
Generated
+35
@@ -11,6 +11,20 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"playwright": "1.42.1"
|
"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": {
|
"node_modules/accepts": {
|
||||||
@@ -981,6 +995,27 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -10,11 +10,16 @@
|
|||||||
"seed:dev-data": "node scripts/seed-dev-data.js",
|
"seed:dev-data": "node scripts/seed-dev-data.js",
|
||||||
"db:migrate": "node src/db/migrate.js",
|
"db:migrate": "node src/db/migrate.js",
|
||||||
"dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.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"
|
"start": "node src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"playwright": "1.42.1"
|
"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 fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { fileURLToPath } from 'node:url'
|
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 require = createRequire(import.meta.url)
|
||||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||||
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
|
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
|
||||||
@@ -15,9 +20,9 @@ loadEnvFiles([
|
|||||||
path.join(PROJECT_ROOT, '.env'),
|
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) {
|
function loadConfig(configPath) {
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
@@ -381,6 +386,7 @@ function parseJsonArray(rawValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAgisoMessagingShops(rawValue) {
|
function normalizeAgisoMessagingShops(rawValue) {
|
||||||
|
/** @type {AgisoMessagingShopsConfig} */
|
||||||
const output = {}
|
const output = {}
|
||||||
|
|
||||||
for (const [shopId, config] of Object.entries(rawValue || {})) {
|
for (const [shopId, config] of Object.entries(rawValue || {})) {
|
||||||
@@ -389,6 +395,7 @@ function normalizeAgisoMessagingShops(rawValue) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {AgisoMessagingShopsConfig[string]} */
|
||||||
const next = {}
|
const next = {}
|
||||||
const enabled = normalizeBooleanLike(config.enabled)
|
const enabled = normalizeBooleanLike(config.enabled)
|
||||||
if (enabled !== null) {
|
if (enabled !== null) {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
import { query, withTransaction } from '../db/client.js'
|
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 = `
|
const INVENTORY_ITEM_SELECT = `
|
||||||
SELECT
|
SELECT
|
||||||
ii.id,
|
ii.id,
|
||||||
@@ -26,6 +35,7 @@ const INVENTORY_ITEM_SELECT = `
|
|||||||
LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id
|
LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id
|
||||||
`
|
`
|
||||||
|
|
||||||
|
/** @returns {Promise<InventoryItemRow | null>} */
|
||||||
export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType = 'tencent_code') {
|
export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType = 'tencent_code') {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`${INVENTORY_ITEM_SELECT}
|
`${INVENTORY_ITEM_SELECT}
|
||||||
@@ -35,9 +45,10 @@ export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credenti
|
|||||||
[skuCode, credentialType],
|
[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') {
|
export async function assignReservedInventoryItem(inventoryItemId, taskId, updatedAt, roleKey = 'primary_code') {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
const inventoryResult = await client.query(
|
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) {
|
export async function getInventoryItemById(inventoryItemId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`${INVENTORY_ITEM_SELECT}
|
`${INVENTORY_ITEM_SELECT}
|
||||||
@@ -84,9 +96,10 @@ export async function getInventoryItemById(inventoryItemId) {
|
|||||||
[Number(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) {
|
export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -111,6 +124,7 @@ export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<InventoryItemRow | null>} */
|
||||||
export async function markInventoryItemConsumed(inventoryItemId, reason, consumedAt) {
|
export async function markInventoryItemConsumed(inventoryItemId, reason, consumedAt) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -139,6 +153,8 @@ export async function markInventoryItemConsumed(inventoryItemId, reason, consume
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<InventoryListQueryResult>} */
|
||||||
|
/** @param {InventoryListQueryInput} [queryInput] */
|
||||||
export async function listInventoryItems({
|
export async function listInventoryItems({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
@@ -146,7 +162,7 @@ export async function listInventoryItems({
|
|||||||
credentialType = '',
|
credentialType = '',
|
||||||
status = '',
|
status = '',
|
||||||
batchNo = '',
|
batchNo = '',
|
||||||
} = {}) {
|
} = /** @type {InventoryListQueryInput} */ ({})) {
|
||||||
const offset = (page - 1) * pageSize
|
const offset = (page - 1) * pageSize
|
||||||
const filters = []
|
const filters = []
|
||||||
const params = []
|
const params = []
|
||||||
@@ -188,16 +204,18 @@ export async function listInventoryItems({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: itemsResult.rows,
|
items: /** @type {InventoryItemRow[]} */ (itemsResult.rows),
|
||||||
total: Number(totalResult.rows[0]?.total || 0),
|
total: Number(totalResult.rows[0]?.total || 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<InventorySkuSuggestionRow[]>} */
|
||||||
|
/** @param {InventorySkuSuggestionQueryInput} [queryInput] */
|
||||||
export async function listInventorySkuSuggestions({
|
export async function listInventorySkuSuggestions({
|
||||||
credentialType = '',
|
credentialType = '',
|
||||||
keyword = '',
|
keyword = '',
|
||||||
limit = 50,
|
limit = 50,
|
||||||
} = {}) {
|
} = /** @type {InventorySkuSuggestionQueryInput} */ ({})) {
|
||||||
const filters = []
|
const filters = []
|
||||||
const params = []
|
const params = []
|
||||||
|
|
||||||
@@ -238,9 +256,10 @@ export async function listInventorySkuSuggestions({
|
|||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows
|
return /** @type {InventorySkuSuggestionRow[]} */ (result.rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {InventoryCreateItemInput[]} rows */
|
||||||
export async function createInventoryItems(rows) {
|
export async function createInventoryItems(rows) {
|
||||||
let created = 0
|
let created = 0
|
||||||
|
|
||||||
@@ -284,6 +303,7 @@ export async function createInventoryItems(rows) {
|
|||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<InventoryItemRow | null>} */
|
||||||
export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) {
|
export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
import { query } from '../db/client.js'
|
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) {
|
export async function listOrderItemsByOrderId(orderId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
@@ -11,9 +17,11 @@ export async function listOrderItemsByOrderId(orderId) {
|
|||||||
[Number(orderId)],
|
[Number(orderId)],
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows
|
return /** @type {OrderItemRow[]} */ (result.rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<OrderItemRow[]>} */
|
||||||
|
/** @param {OrderItemReplaceInput[]} items */
|
||||||
export async function replaceOrderItems(orderId, items) {
|
export async function replaceOrderItems(orderId, items) {
|
||||||
await query('DELETE FROM order_items WHERE order_id = $1', [Number(orderId)])
|
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)
|
return listOrderItemsByOrderId(orderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<OrderItemRow | null>} */
|
||||||
export async function getOrderItemById(orderItemId) {
|
export async function getOrderItemById(orderItemId) {
|
||||||
const result = await query('SELECT * FROM order_items WHERE id = $1 LIMIT 1', [Number(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'
|
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 }) {
|
export async function findOrderByPlatformOrderId({ provider = 'agiso', platform, shopId = '', platformOrderId }) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
@@ -11,9 +20,11 @@ export async function findOrderByPlatformOrderId({ provider = 'agiso', platform,
|
|||||||
[provider, platform, shopId, platformOrderId],
|
[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) {
|
export async function createOrder(input) {
|
||||||
const result = await query(
|
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) {
|
export async function updateOrder(orderId, input) {
|
||||||
const result = await query(
|
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) {
|
export async function getOrderById(orderId) {
|
||||||
const result = await query('SELECT * FROM orders WHERE id = $1 LIMIT 1', [Number(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({
|
export async function listOrders({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
@@ -117,7 +133,7 @@ export async function listOrders({
|
|||||||
skuCode = '',
|
skuCode = '',
|
||||||
dateFrom = '',
|
dateFrom = '',
|
||||||
dateTo = '',
|
dateTo = '',
|
||||||
} = {}) {
|
} = /** @type {OrderListQueryInput} */ ({})) {
|
||||||
const offset = (page - 1) * pageSize
|
const offset = (page - 1) * pageSize
|
||||||
const filters = []
|
const filters = []
|
||||||
const params = []
|
const params = []
|
||||||
@@ -166,7 +182,7 @@ export async function listOrders({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: itemsResult.rows,
|
items: /** @type {OrderListRow[]} */ (itemsResult.rows),
|
||||||
total: Number(totalResult.rows[0]?.total || 0),
|
total: Number(totalResult.rows[0]?.total || 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
import { query } from '../db/client.js'
|
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) {
|
export async function listTaskInventoryBindingsByTaskId(taskId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
@@ -29,9 +35,10 @@ export async function listTaskInventoryBindingsByTaskId(taskId) {
|
|||||||
[Number(taskId)],
|
[Number(taskId)],
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows
|
return /** @type {TaskInventoryBindingRow[]} */ (result.rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<TaskInventoryBindingRow | null>} */
|
||||||
export async function getTaskInventoryBindingById(bindingId) {
|
export async function getTaskInventoryBindingById(bindingId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
@@ -61,9 +68,10 @@ export async function getTaskInventoryBindingById(bindingId) {
|
|||||||
[Number(bindingId)],
|
[Number(bindingId)],
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows[0] || null
|
return /** @type {TaskInventoryBindingRow | null} */ (result.rows[0] || null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<TaskInventoryBindingSummaryRow[]>} */
|
||||||
export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) {
|
export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) {
|
||||||
const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : [])
|
const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : [])
|
||||||
.map((value) => Number(value))
|
.map((value) => Number(value))
|
||||||
@@ -89,5 +97,5 @@ export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) {
|
|||||||
[normalizedTaskIds],
|
[normalizedTaskIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows
|
return /** @type {TaskInventoryBindingSummaryRow[]} */ (result.rows)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
import { query, withTransaction } from '../db/client.js'
|
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 = `
|
const TASK_FIELDS = `
|
||||||
ft.*,
|
ft.*,
|
||||||
ct.id AS primary_claim_token_id,
|
ct.id AS primary_claim_token_id,
|
||||||
@@ -52,6 +61,7 @@ function buildTaskSelect(extraFields = '') {
|
|||||||
return `SELECT ${fieldSql} ${TASK_JOINS}`
|
return `SELECT ${fieldSql} ${TASK_JOINS}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<TaskRow[]>} */
|
||||||
export async function listTasksByOrderId(orderId) {
|
export async function listTasksByOrderId(orderId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`${buildTaskSelect()}
|
`${buildTaskSelect()}
|
||||||
@@ -59,9 +69,11 @@ export async function listTasksByOrderId(orderId) {
|
|||||||
ORDER BY ft.id ASC`,
|
ORDER BY ft.id ASC`,
|
||||||
[Number(orderId)],
|
[Number(orderId)],
|
||||||
)
|
)
|
||||||
return result.rows
|
return /** @type {TaskRow[]} */ (result.rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<TaskRow | null>} */
|
||||||
|
/** @param {TaskCreateInput} input */
|
||||||
export async function createTask(input) {
|
export async function createTask(input) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
const taskResult = await client.query(
|
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) {
|
export async function updateTask(taskId, patch) {
|
||||||
const current = await getTaskById(taskId)
|
const current = await getTaskById(taskId)
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -212,10 +226,12 @@ export async function updateTask(taskId, patch) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<TaskRow | null>} */
|
||||||
export async function getTaskById(taskId) {
|
export async function getTaskById(taskId) {
|
||||||
return getTaskByIdWithExecutor(query, taskId)
|
return getTaskByIdWithExecutor(query, taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<TaskRow | null>} */
|
||||||
export async function findTaskByClaimTokenId(claimTokenId) {
|
export async function findTaskByClaimTokenId(claimTokenId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`${buildTaskSelect()}
|
`${buildTaskSelect()}
|
||||||
@@ -224,9 +240,11 @@ export async function findTaskByClaimTokenId(claimTokenId) {
|
|||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[Number(claimTokenId)],
|
[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({
|
export async function listTasks({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
@@ -237,7 +255,7 @@ export async function listTasks({
|
|||||||
roleId = '',
|
roleId = '',
|
||||||
dateFrom = '',
|
dateFrom = '',
|
||||||
dateTo = '',
|
dateTo = '',
|
||||||
} = {}) {
|
} = /** @type {TaskListQueryInput} */ ({})) {
|
||||||
const offset = (page - 1) * pageSize
|
const offset = (page - 1) * pageSize
|
||||||
const filters = []
|
const filters = []
|
||||||
const params = []
|
const params = []
|
||||||
@@ -301,11 +319,12 @@ export async function listTasks({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: itemsResult.rows,
|
items: /** @type {TaskRow[]} */ (itemsResult.rows),
|
||||||
total: Number(totalResult.rows[0]?.total || 0),
|
total: Number(totalResult.rows[0]?.total || 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {TaskTencentContextPatch} patch */
|
||||||
async function upsertTencentBrowserContextWithClient(client, taskId, patch = {}, timestamp) {
|
async function upsertTencentBrowserContextWithClient(client, taskId, patch = {}, timestamp) {
|
||||||
const currentResult = await client.query(
|
const currentResult = await client.query(
|
||||||
'SELECT * FROM tencent_browser_contexts WHERE task_id = $1 LIMIT 1',
|
'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) {
|
async function getTaskByIdWithExecutor(executor, taskId) {
|
||||||
const result = await executor(
|
const result = await executor(
|
||||||
`${buildTaskSelect()}
|
`${buildTaskSelect()}
|
||||||
@@ -406,7 +426,7 @@ async function getTaskByIdWithExecutor(executor, taskId) {
|
|||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[Number(taskId)],
|
[Number(taskId)],
|
||||||
)
|
)
|
||||||
return result.rows[0] || null
|
return /** @type {TaskRow | null} */ (result.rows[0] || null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function containsTencentPatch(patch = {}) {
|
function containsTencentPatch(patch = {}) {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
import { query } from '../db/client.js'
|
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) {
|
export async function createWebhookEvent(input) {
|
||||||
const result = await query(
|
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) {
|
export async function updateWebhookEvent(eventId, patch) {
|
||||||
const current = await getWebhookEventById(eventId)
|
const current = await getWebhookEventById(eventId)
|
||||||
if (!current) {
|
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)],
|
[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) {
|
export async function getWebhookEventById(eventId) {
|
||||||
const result = await query('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [Number(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({
|
export async function listWebhookEvents({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
@@ -80,7 +95,7 @@ export async function listWebhookEvents({
|
|||||||
relatedOrderId = '',
|
relatedOrderId = '',
|
||||||
dateFrom = '',
|
dateFrom = '',
|
||||||
dateTo = '',
|
dateTo = '',
|
||||||
} = {}) {
|
} = /** @type {WebhookEventListQueryInput} */ ({})) {
|
||||||
const offset = (page - 1) * pageSize
|
const offset = (page - 1) * pageSize
|
||||||
const filters = []
|
const filters = []
|
||||||
const params = []
|
const params = []
|
||||||
@@ -137,11 +152,12 @@ export async function listWebhookEvents({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: itemsResult.rows,
|
items: /** @type {WebhookEventRow[]} */ (itemsResult.rows),
|
||||||
total: Number(totalResult.rows[0]?.total || 0),
|
total: Number(totalResult.rows[0]?.total || 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<WebhookEventRow[]>} */
|
||||||
export async function listWebhookEventsByOrderId(orderId) {
|
export async function listWebhookEventsByOrderId(orderId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`
|
`
|
||||||
@@ -153,5 +169,5 @@ export async function listWebhookEventsByOrderId(orderId) {
|
|||||||
[Number(orderId)],
|
[Number(orderId)],
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows
|
return /** @type {WebhookEventRow[]} */ (result.rows)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express'
|
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'
|
import { createJsonHandler } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Router } from 'express'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createAdminInventoryItem,
|
createAdminInventoryItem,
|
||||||
getAdminInventoryItems,
|
|
||||||
getAdminInventorySkuSuggestions,
|
|
||||||
importAdminInventoryItems,
|
importAdminInventoryItems,
|
||||||
invalidateAdminInventoryItem,
|
invalidateAdminInventoryItem,
|
||||||
releaseAdminInventoryItem,
|
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'
|
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express'
|
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'
|
import { createJsonHandler } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express'
|
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'
|
import { createJsonHandler } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
lookupAdminFulfillmentBindingOrder,
|
lookupAdminFulfillmentBindingOrder,
|
||||||
updateAdminAgisoShopConfigs,
|
updateAdminAgisoShopConfigs,
|
||||||
updateAdminFulfillmentBindingConfigs,
|
updateAdminFulfillmentBindingConfigs,
|
||||||
} from '../../services/admin/admin-service.js'
|
} from '../../services/admin/admin-platform-config-service.js'
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import { Router } from 'express'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
closeAdminTask,
|
closeAdminTask,
|
||||||
confirmAdminTaskAssistedRole,
|
|
||||||
completeAdminTaskManualDispatch,
|
completeAdminTaskManualDispatch,
|
||||||
getAdminTaskDetail,
|
confirmAdminTaskAssistedRole,
|
||||||
getAdminTaskScreenshotPath,
|
|
||||||
getAdminTasks,
|
|
||||||
markAdminTaskManualReview,
|
markAdminTaskManualReview,
|
||||||
regenerateAdminTaskClaimLink,
|
regenerateAdminTaskClaimLink,
|
||||||
redeemAdminTaskAssisted,
|
redeemAdminTaskAssisted,
|
||||||
releaseAdminTaskInventoryBinding,
|
releaseAdminTaskInventoryBinding,
|
||||||
releaseAdminTaskInventory,
|
releaseAdminTaskInventory,
|
||||||
retryAdminTask,
|
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'
|
import { createFileHandler, createJsonHandler, requireAdminRoles } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import {
|
||||||
|
replayAdminWebhookEvent,
|
||||||
|
} from '../../services/admin/admin-write-service.js'
|
||||||
import {
|
import {
|
||||||
getAdminWebhookEventDetail,
|
getAdminWebhookEventDetail,
|
||||||
getAdminWebhookEvents,
|
getAdminWebhookEvents,
|
||||||
replayAdminWebhookEvent,
|
} from '../../services/admin/admin-read-service.js'
|
||||||
} from '../../services/admin/admin-service.js'
|
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
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 { logWebhook } from '../../../../utils/logger.js'
|
||||||
import { nowIso } from '../../../../utils/time.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({
|
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||||
order,
|
order,
|
||||||
task,
|
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
# Backend Refactor Status
|
|
||||||
|
|
||||||
最后更新:2026-04-09(已切换到“测试数据可丢弃,不做历史迁移兼容”前提)
|
|
||||||
|
|
||||||
## 当前结论
|
|
||||||
|
|
||||||
后端正在从“单一腾讯领取兑换工具”迁移到“通用订单履约系统”。
|
|
||||||
|
|
||||||
当前代码已经完成了下面两件大事:
|
|
||||||
|
|
||||||
1. 数据层已经切到 PostgreSQL,并引入了新的通用履约模型。
|
|
||||||
2. 仓库结构已经分出 `db / repositories / services / routes / frontend / deploy` 等清晰边界。
|
|
||||||
|
|
||||||
当前仍处于新旧架构并存阶段,但已经明确采用下面的推进原则:
|
|
||||||
|
|
||||||
- 现有数据库数据视为测试数据,可直接清空
|
|
||||||
- 不再为了旧测试数据保留历史迁移修复逻辑
|
|
||||||
- 新配置只认当前履约模型,不再从旧映射自动推导
|
|
||||||
|
|
||||||
当前主要表现为:
|
|
||||||
|
|
||||||
- 新 schema 已经使用 `fulfillment_* / inventory_items / task_inventory_bindings`
|
|
||||||
- 核心 service 主链路已经开始直接使用“主 token / 主库存绑定”语义
|
|
||||||
- admin/public 资源名已经切到 `inventory`
|
|
||||||
- 剩余工作主要集中在“多库存绑定能力上浮”和“测试/种子数据体系化”
|
|
||||||
|
|
||||||
## 当前真实架构
|
|
||||||
|
|
||||||
从工程实现视角,当前后端可以按 5 层理解:
|
|
||||||
|
|
||||||
1. 接口层
|
|
||||||
- `src/routes/`
|
|
||||||
- 负责接 HTTP 请求、鉴权、组装响应
|
|
||||||
2. 应用编排层
|
|
||||||
- `src/services/order/`
|
|
||||||
- `src/services/claim/`
|
|
||||||
- `src/services/admin/`
|
|
||||||
- 负责订单、任务、库存、领取、后台操作编排
|
|
||||||
3. 执行能力层
|
|
||||||
- `src/services/session/`
|
|
||||||
- `src/services/platforms/`
|
|
||||||
- 负责浏览器自动化、平台消息发送、OCR 调用
|
|
||||||
4. 持久化层
|
|
||||||
- `src/repositories/`
|
|
||||||
- 负责 orders、tasks、inventory、claim_tokens、webhook_events 的读写
|
|
||||||
5. 基础设施层
|
|
||||||
- `src/db/`
|
|
||||||
- `src/config/`
|
|
||||||
- 负责数据库连接、迁移、运行时配置、bootstrap
|
|
||||||
|
|
||||||
## 已完成部分
|
|
||||||
|
|
||||||
### 1. 通用履约模型已经落库
|
|
||||||
|
|
||||||
当前迁移文件已经不再围绕旧式单库存码模型设计,而是引入了:
|
|
||||||
|
|
||||||
- `fulfillment_profiles`
|
|
||||||
- `fulfillment_profile_requirements`
|
|
||||||
- `sku_fulfillment_bindings`
|
|
||||||
- `inventory_items`
|
|
||||||
- `fulfillment_tasks`
|
|
||||||
- `task_inventory_bindings`
|
|
||||||
- `tencent_browser_contexts`
|
|
||||||
- `message_deliveries`
|
|
||||||
|
|
||||||
这说明系统底层已经支持:
|
|
||||||
|
|
||||||
- 同一个 SKU 绑定不同履约方式
|
|
||||||
- 不同 provider / platform / shop 的差异化履约策略
|
|
||||||
- 一个任务绑定多个库存项
|
|
||||||
- 不同 `credential_type` 的库存凭据
|
|
||||||
- 手动发货与腾讯领取兑换并存
|
|
||||||
|
|
||||||
### 2. 履约目录 bootstrap 已经具备基础抽象
|
|
||||||
|
|
||||||
当前 bootstrap 中已经定义了两个核心 profile:
|
|
||||||
|
|
||||||
- `manual_review`
|
|
||||||
- `tencent_claim_redeem`
|
|
||||||
|
|
||||||
说明“手动发货”和“腾讯领取兑换”已经从业务概念上进入统一履约目录,而不是散落在业务代码里。
|
|
||||||
|
|
||||||
### 3. Admin 路由已经完成按领域拆分
|
|
||||||
|
|
||||||
后台路由不再全部堆在一个文件里,而是按:
|
|
||||||
|
|
||||||
- auth
|
|
||||||
- dashboard
|
|
||||||
- users
|
|
||||||
- orders
|
|
||||||
- tasks
|
|
||||||
- inventory
|
|
||||||
- webhook-events
|
|
||||||
- platform-config
|
|
||||||
|
|
||||||
拆成独立模块,便于继续演进。
|
|
||||||
|
|
||||||
### 4. 核心任务读写已经开始切回新 schema 语义
|
|
||||||
|
|
||||||
当前仓库层已经明确暴露:
|
|
||||||
|
|
||||||
- `primary_claim_token_*`
|
|
||||||
- `primary_inventory_item_*`
|
|
||||||
|
|
||||||
应用层也开始改为基于这些字段处理“主领取 token / 主库存绑定”,而不是继续把它们伪装成旧 `claim_token_id / reserved_cdk_id`。
|
|
||||||
|
|
||||||
另外,`claimed_at / role_confirmed_at / redeemed_at` 这些任务时间字段现在也已真正通过 repository 落库,不再只是 service 层表面传值。
|
|
||||||
|
|
||||||
### 5. 本地 Docker 开发栈已经重新打通
|
|
||||||
|
|
||||||
当前开发 Compose 已改为:
|
|
||||||
|
|
||||||
- `postgres` 提供开发数据库
|
|
||||||
- backend 通过 `DATABASE_URL` 连接 PostgreSQL
|
|
||||||
- 启动时自动执行 migration 与 bootstrap
|
|
||||||
|
|
||||||
这轮还顺手修掉了一个启动顺序问题:
|
|
||||||
|
|
||||||
- 之前 backend 启动时没有等待 migration 完成,就先执行 fulfillment catalog bootstrap
|
|
||||||
- 现已改为先 `await runDatabaseMigrations()`,再继续后续初始化
|
|
||||||
|
|
||||||
本地验证结果:
|
|
||||||
|
|
||||||
- `docker compose -f docker-compose.dev.yml` 可拉起 `postgres / backend / frontend / web`
|
|
||||||
- `/health` 可正常返回
|
|
||||||
- admin 登录可用
|
|
||||||
- `GET /api/v1/admin/tasks` 已能正常鉴权并返回结果
|
|
||||||
|
|
||||||
### 6. `manual_dispatch` 已具备完整人工履约闭环
|
|
||||||
|
|
||||||
当前 `manual_dispatch` 不再只是把任务停在 `manual_review`,而是已经补齐成新的正式履约路径:
|
|
||||||
|
|
||||||
- paid 后的人工履约任务会进入 `manual_review`
|
|
||||||
- admin 新增 `POST /api/v1/admin/tasks/:taskId/complete-manual-dispatch`
|
|
||||||
- 支持把人工履约结果明确回写为 `delivered` 或 `failed`
|
|
||||||
- 结果会写入 `task_status / delivery_status / result_code / result_message / context_json`
|
|
||||||
- 如果任务绑定了库存凭据,人工完成时也会同步把库存置为 consumed
|
|
||||||
- `task_events` 已开始记录人工履约完成事件
|
|
||||||
|
|
||||||
同时,人工履约任务已经不再允许误走旧自动链路:
|
|
||||||
|
|
||||||
- 不再允许对 `manual_dispatch` 执行自动重试
|
|
||||||
- 不再允许为 `manual_dispatch` 重新生成领取链接
|
|
||||||
- admin 详情页已补上人工履约回写表单与结果展示
|
|
||||||
|
|
||||||
### 7. admin 任务接口开始去除旧 `CDK` 过渡命名
|
|
||||||
|
|
||||||
这一轮继续把任务侧接口往新模型对齐:
|
|
||||||
|
|
||||||
- 任务释放入口已改为 `release-inventory`
|
|
||||||
- 任务 action payload 不再返回 `reservedCdkId`
|
|
||||||
- 主领取 token 字段统一为 `primaryClaimTokenId`
|
|
||||||
- 前端任务详情/列表不再依赖 `reservedCdkCodeMasked` 和 `cdk` 兼容对象
|
|
||||||
|
|
||||||
当前任务管理界面已经优先使用:
|
|
||||||
|
|
||||||
- `inventoryItemId`
|
|
||||||
- `inventory.displayValue`
|
|
||||||
- `inventory.credentialType`
|
|
||||||
- `primaryClaimTokenId`
|
|
||||||
|
|
||||||
### 8. admin 库存管理域已经切到 `inventory` 资源名
|
|
||||||
|
|
||||||
这一轮把库存管理域的外层入口也继续切到新模型:
|
|
||||||
|
|
||||||
- 后端 admin API 已从 `/api/v1/admin/cdks` 切到 `/api/v1/admin/inventory`
|
|
||||||
- 前端后台路由已从 `/admin/cdks` 切到 `/admin/inventory`
|
|
||||||
- 库存管理页面、导航、审计 action、targetType 已统一改为库存项语义
|
|
||||||
- 库存列表返回结构已改为 `inventoryItemId / displayValue / credentialType`
|
|
||||||
|
|
||||||
现在从 URL、页面和 admin payload 角度,库存域已经不再把资源本身暴露成旧 `cdk` 名称。
|
|
||||||
|
|
||||||
### 9. 后端内部 `cdk` 命名主链路已经收口到 `inventory`
|
|
||||||
|
|
||||||
这轮已经把后端主链路里的历史命名继续清理掉:
|
|
||||||
|
|
||||||
- `repositories/cdk-repo.js` 已收口为 `repositories/inventory-repo.js`
|
|
||||||
- `services/order/cdk-service.js` 已收口为 `services/order/inventory-service.js`
|
|
||||||
- admin / claim / delivery 主链路已改用 `getInventoryItemById / reserveInventoryForTask / releaseReservedInventoryItem` 等命名
|
|
||||||
- 任务错误文案已统一改成“库存项 / 库存凭据”语义
|
|
||||||
|
|
||||||
现在 `apps/backend/src` 主干里已经不再保留旧 `cdk-*` 仓储与服务文件名。
|
|
||||||
|
|
||||||
### 10. 开发种子数据入口已经补上
|
|
||||||
|
|
||||||
这轮已经补上新的开发数据入口,不再只能靠手工 webhook 或临时 SQL 验证:
|
|
||||||
|
|
||||||
- 新增 `apps/backend/scripts/seed-dev-data.js`
|
|
||||||
- 新增 `npm run seed:dev-data`
|
|
||||||
- 支持 `--apply --reset` 先清库再重建固定场景
|
|
||||||
- 当前已覆盖:
|
|
||||||
- `manual_pending`
|
|
||||||
- `manual_completed`
|
|
||||||
- `claim_ready`
|
|
||||||
- `claim_claimed`
|
|
||||||
- `claim_role_confirmed`
|
|
||||||
- `claim_retry_pending`
|
|
||||||
- `claim_expired`
|
|
||||||
- `claim_waiting_inventory`
|
|
||||||
|
|
||||||
这意味着后续继续改任务列表、订单详情、履约编排时,已经有一套可重复验证的最小场景。
|
|
||||||
|
|
||||||
## 当前未完成部分
|
|
||||||
|
|
||||||
### 1. 多凭据任务能力还没有完全上浮到应用层
|
|
||||||
|
|
||||||
底层已经支持一个任务绑定多个库存项,但上层展示和操作仍偏向:
|
|
||||||
|
|
||||||
- 虽然 admin 任务详情已经开始返回并展示 `inventoryBindings`
|
|
||||||
- 任务列表和订单详情已经开始展示绑定摘要计数
|
|
||||||
- 但列表操作入口和大部分应用编排仍主要围绕一个主库存项
|
|
||||||
- 一个任务只暴露一个领取 token
|
|
||||||
- 后台操作还没有按“绑定集合”粒度设计
|
|
||||||
|
|
||||||
### 2. 测试数据初始化还不够体系化
|
|
||||||
|
|
||||||
虽然已经明确“测试数据可丢弃”,也已经有 PostgreSQL 清库脚本和基础 seed 脚本,但当前场景库仍然偏薄。
|
|
||||||
|
|
||||||
还缺:
|
|
||||||
|
|
||||||
- `redeeming / redeemed with screenshot / claim token revoked` 等更靠近真实自动化结果的场景
|
|
||||||
- 更接近真实订单组合的多任务、多数量、多库存绑定样本
|
|
||||||
|
|
||||||
### 3. 任务状态与库存绑定状态仍可进一步解耦
|
|
||||||
|
|
||||||
当前虽然已经有 `task_status / delivery_status / inventory_status / task_inventory_bindings` 四套状态,但 admin 视角仍以“任务主状态”为中心:
|
|
||||||
|
|
||||||
- 详情页已经开始展示库存绑定集合
|
|
||||||
- 任务列表和订单详情已经开始展示绑定摘要
|
|
||||||
- 但订单列表、任务操作入口还没有把多库存绑定事件完整展开
|
|
||||||
- `manual_dispatch` 与自动领取任务共用的列表状态摘要还比较粗
|
|
||||||
- 订单编排层还没有把“多库存、多步骤”履约任务展开成更清晰的应用层对象
|
|
||||||
|
|
||||||
## 现在最该继续做的事
|
|
||||||
|
|
||||||
1. 把“单个主库存项”心智继续升级为“任务库存绑定集合”
|
|
||||||
2. 让 admin 层逐步暴露多库存绑定、多履约步骤的真实状态
|
|
||||||
3. 在现有 seed 基础上继续扩充更多任务状态和编排场景
|
|
||||||
4. 继续把订单列表和任务操作入口也切到绑定集合视角
|
|
||||||
|
|
||||||
当前这件事已经继续往前走了一步:
|
|
||||||
|
|
||||||
- 旧 SQLite 思路的 `scripts/cleanup-dev-data.js` 已经替换为基于 PostgreSQL 新 schema 的清库脚本
|
|
||||||
- admin 任务详情已开始暴露并展示 `task_inventory_bindings` 集合
|
|
||||||
- admin 任务列表和订单详情已开始消费绑定摘要计数
|
|
||||||
- `scripts/seed-dev-data.js` 已扩展到 claim 主流程多状态样本,bootstrap 仍然只负责 profile/catalog,不负责完整业务测试数据生成
|
|
||||||
|
|
||||||
## 文档说明
|
|
||||||
|
|
||||||
以下旧文档已删除,不再作为事实来源:
|
|
||||||
|
|
||||||
- `apps/backend/方案A-订单编排与自动兑换设计.md`
|
|
||||||
- `apps/backend/阶段1-4实施规格说明.md`
|
|
||||||
|
|
||||||
后续请以代码、迁移文件和本状态文档为准。
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Backend TypeScript 迁移计划
|
||||||
|
|
||||||
|
最后更新:2026-04-14
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前后端不建议整体重写为 Go。
|
||||||
|
|
||||||
|
更合适的路线是:
|
||||||
|
|
||||||
|
1. 保留 Node.js + Express + Playwright + PostgreSQL 主栈
|
||||||
|
2. 先把后端渐进迁移到 TypeScript
|
||||||
|
3. 同时拆分超大 service 文件
|
||||||
|
4. 补足核心业务链路测试
|
||||||
|
|
||||||
|
这样可以先解决“可维护性”和“改动风险”问题,而不会额外引入一次高成本的跨语言重写。
|
||||||
|
|
||||||
|
## 为什么现在不直接重写 Go
|
||||||
|
|
||||||
|
当前后端的主要复杂度并不来自语言本身,而是来自:
|
||||||
|
|
||||||
|
- 订单、任务、库存、领取、自动发货、webhook 多条链路同时演进
|
||||||
|
- Playwright 浏览器自动化本身就天然偏 Node 生态
|
||||||
|
- 若整体换成 Go,浏览器自动化大概率仍需保留 Node worker
|
||||||
|
- 这样会把系统变成 Go + Node + Python 三段式,边界更多,联调更难
|
||||||
|
|
||||||
|
所以现阶段最有效的动作,不是换语言,而是先把现有 Node 后端“类型化、分层化、可测试化”。
|
||||||
|
|
||||||
|
## 当前主要维护痛点
|
||||||
|
|
||||||
|
从当前仓库状态看,风险主要集中在:
|
||||||
|
|
||||||
|
- 少数超大编排文件已经承担过多职责
|
||||||
|
- `apps/backend/src/services/admin/admin-service.js`
|
||||||
|
- `apps/backend/src/services/claim/claim-session-service.js`
|
||||||
|
- `apps/backend/src/services/order/webhook-service.js`
|
||||||
|
- `apps/backend/src/services/session/session.js`
|
||||||
|
- 领域对象靠运行时约定在 routes / services / repositories 之间传递
|
||||||
|
- 当前自动化测试覆盖还偏薄,跨链路改动缺少回归保护
|
||||||
|
|
||||||
|
## 迁移目标
|
||||||
|
|
||||||
|
本次迁移不是为了“全部改成 .ts 才算完成”,而是为了达到下面几个目标:
|
||||||
|
|
||||||
|
1. 让核心领域对象具备稳定的静态类型
|
||||||
|
2. 让新增改动优先进入类型系统,而不是继续扩大纯 JS 面积
|
||||||
|
3. 让超大 service 文件拆分时有类型边界可依赖
|
||||||
|
4. 让 typecheck 和测试一起成为后端的标准校验步骤
|
||||||
|
|
||||||
|
## 分阶段计划
|
||||||
|
|
||||||
|
### 阶段 1:建立类型基础设施
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 引入后端 `tsconfig`
|
||||||
|
- 增加 `npm run typecheck`
|
||||||
|
- 先从最核心的共享配置和基础领域对象开始建类型
|
||||||
|
- 暂不改变现有运行方式,不引入编译产物,不影响 Docker 启动
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
- `runtimeConfig`
|
||||||
|
- 公共配置对象
|
||||||
|
- 后续会继续补:
|
||||||
|
- admin DTO
|
||||||
|
- order / task / inventory 基础类型
|
||||||
|
- webhook 解析结果类型
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 后端可以执行 `npm run typecheck`
|
||||||
|
- 现有 `npm run dev` / `npm run start` / Docker 启动流程不受影响
|
||||||
|
|
||||||
|
### 阶段 2:按领域拆分超大 service
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 保持运行逻辑不变
|
||||||
|
- 先把“巨石编排文件”拆成更小的用例编排模块
|
||||||
|
|
||||||
|
建议拆分顺序:
|
||||||
|
|
||||||
|
1. `admin-service.js`
|
||||||
|
- `admin-order-service`
|
||||||
|
- `admin-task-service`
|
||||||
|
- `admin-inventory-service`
|
||||||
|
- `admin-webhook-service`
|
||||||
|
2. `claim-session-service.js`
|
||||||
|
- `claim-session-lifecycle`
|
||||||
|
- `claim-role-service`
|
||||||
|
- `claim-redeem-orchestrator`
|
||||||
|
3. `webhook-service.js`
|
||||||
|
- `webhook-parse`
|
||||||
|
- `webhook-validate`
|
||||||
|
- `webhook-process`
|
||||||
|
|
||||||
|
### 阶段 3:扩大类型覆盖
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 新增模块优先使用 `.ts`
|
||||||
|
- 旧模块逐步迁移,而不是一次性全改
|
||||||
|
- 让 routes / services / repositories 之间共享同一套领域类型
|
||||||
|
|
||||||
|
建议优先顺序:
|
||||||
|
|
||||||
|
1. `src/types/` 下沉淀共享领域模型
|
||||||
|
2. repository 返回值显式类型化
|
||||||
|
3. service 入参 / 出参类型化
|
||||||
|
4. 再迁移最稳定的新模块到 `.ts`
|
||||||
|
|
||||||
|
### 阶段 4:补核心回归测试
|
||||||
|
|
||||||
|
优先补这些主链路:
|
||||||
|
|
||||||
|
1. webhook 入单与重放
|
||||||
|
2. 库存预占 / 释放 / 换码
|
||||||
|
3. claim 会话创建 / 关闭 / 切换
|
||||||
|
4. 腾讯兑换结果分类
|
||||||
|
5. 自动发货触发条件
|
||||||
|
|
||||||
|
## 已开始执行的第一步
|
||||||
|
|
||||||
|
本轮已经落地:
|
||||||
|
|
||||||
|
1. backend 新增 `tsconfig.json`
|
||||||
|
2. backend 新增 `npm run typecheck`
|
||||||
|
3. 新增 `src/types/runtime-config.js`
|
||||||
|
4. `src/config/runtime.js` 已接入第一批类型检查
|
||||||
|
5. 新增 `src/types/admin-read-models.js`
|
||||||
|
6. `src/services/admin/admin-service.js` 的订单 / 任务 / 库存 / webhook 读取返回模型已接入第二批类型检查
|
||||||
|
7. 新增 `src/types/repository-rows.js`
|
||||||
|
8. `admin-service.js` 的核心读取映射函数参数,已开始明确依赖 repository 行模型
|
||||||
|
9. `order / task / inventory / webhook / order-item / task-inventory-binding` repository 已补第一批返回类型
|
||||||
|
10. backend `tsconfig.json` 已将上述 repository 纳入显式 `typecheck` 范围
|
||||||
|
11. `npm --prefix apps/backend run typecheck` 已在 repository 扩围后通过
|
||||||
|
12. 新增 `src/types/repository-inputs.js`
|
||||||
|
13. `order / task / inventory / webhook / order-item` repository 已开始显式使用共享输入类型
|
||||||
|
14. `npm --prefix apps/backend run typecheck` 已在输入类型接入后继续通过
|
||||||
|
15. 新增 `src/services/admin/admin-read-service.js`,开始承接后台查询型接口
|
||||||
|
16. `orders / tasks / inventory / webhook-events` 管理路由已优先切到 `admin-read-service`
|
||||||
|
17. 读服务首轮拆分后,backend `typecheck` 继续通过
|
||||||
|
18. 新增 `src/services/admin/admin-read-helpers.js`,开始承接读侧共享 helper
|
||||||
|
19. `admin-read-service.js` 已改为优先依赖 `admin-read-helpers.js`,不再直接依赖 `admin-service.js`
|
||||||
|
20. helper 下沉后,backend `typecheck` 继续通过
|
||||||
|
21. `admin-service.js` 已开始复用 `admin-read-helpers.js`,并删除首批重复的读侧 helper
|
||||||
|
22. `order / task / inventory / webhook` repository 已补共享查询参数类型
|
||||||
|
23. `npm --prefix apps/backend run typecheck` 已在查询参数类型接入后继续通过
|
||||||
|
24. 新增 `src/types/admin-read-inputs.js`,开始承接后台读接口的 service 入参边界
|
||||||
|
25. `admin-read-service.js` 的订单 / 任务 / 库存 / webhook 查询与详情入口已接入共享输入类型
|
||||||
|
26. `npm --prefix apps/backend run typecheck` 已在 service 输入类型接入后继续通过
|
||||||
|
27. 新增 `src/services/admin/admin-write-service.js`,开始承接后台库存写接口
|
||||||
|
28. `inventory` 管理路由已切到 `admin-write-service`,`admin-service.js` 删除首批库存写侧实现
|
||||||
|
29. 新增 `src/types/admin-write-inputs.js`,库存写接口已开始复用共享输入类型
|
||||||
|
30. backend `typecheck` 已覆盖 `admin-write-service.js`
|
||||||
|
31. 新增 `src/types/admin-write-models.js`,库存写接口返回结构已接入共享响应类型
|
||||||
|
32. `admin-write-service.js` 的 create / import / release / invalidate 已补返回值 JSDoc
|
||||||
|
33. `webhook replay` 与 `Agiso 店铺配置保存` 已下沉到 `admin-write-service.js`
|
||||||
|
34. `webhook-events / platform-config` 写路由已切到 `admin-write-service`
|
||||||
|
35. `admin-service.js` 删除第二批可独立的写侧实现,并保留兼容导出
|
||||||
|
36. `task` 生命周期写操作第一批已下沉到 `admin-write-service.js`
|
||||||
|
37. `tasks` 管理路由已切换首批 task 写接口到 `admin-write-service`
|
||||||
|
38. 新增 task 写侧共享响应类型,task action / binding release 已接入 JSDoc
|
||||||
|
39. `retryAdminTask` 与 `completeAdminTaskManualDispatch` 已下沉到 `admin-write-service.js`
|
||||||
|
40. `tasks` 管理路由已全部切到 `admin-write-service` 承接 task 写接口
|
||||||
|
41. `admin-service.js` 已删除 task 写侧主体实现,仅保留兼容导出
|
||||||
|
42. 新增 `admin-platform-config-service.js`,开始承接 platform-config 整块能力
|
||||||
|
43. `platform-config` 路由已切换到独立 service,不再经过 `admin-service.js`
|
||||||
|
44. `admin-service.js` 已删除平台配置相关读写与 helper,实现进一步收敛
|
||||||
|
45. 新增 `admin-dashboard-service.js`,后台概览已独立
|
||||||
|
46. 新增 `admin-message-delivery-service.js`,消息发送记录查询已独立
|
||||||
|
47. `dashboard / message-deliveries` 路由已直接依赖独立 service
|
||||||
|
48. `admin-service.js` 已收敛为兼容导出层,不再直接承载后台业务实现
|
||||||
|
49. 后台读 / 写 / 平台配置 / 概览 / 消息记录已分散到独立 service 模块
|
||||||
|
|
||||||
|
这一步的设计原则是“只加静态约束,不动运行链路”,所以不会影响:
|
||||||
|
|
||||||
|
- `npm run dev`
|
||||||
|
- `npm run start`
|
||||||
|
- Docker 开发 / 部署
|
||||||
|
|
||||||
|
## 下一步建议
|
||||||
|
|
||||||
|
第一批继续推进时,建议按这个顺序:
|
||||||
|
|
||||||
|
1. 为 `admin` 路由层补请求体 / 查询参数的共享输入类型
|
||||||
|
2. 评估是否把部分 `admin-read-helpers.js` 再细拆成按领域 helper 模块
|
||||||
|
3. 继续补 repository / service 的边界约束,但不急着改 `.ts` 扩展名
|
||||||
|
4. 为 webhook、库存换码、自动发货补测试
|
||||||
|
|
||||||
|
## 执行原则
|
||||||
|
|
||||||
|
- 不为迁移而迁移,优先迁移最常改、最容易改坏的链路
|
||||||
|
- 不追求一口气全量改 `.ts`
|
||||||
|
- 优先建立“新代码进入类型系统”的机制
|
||||||
|
- 每一步都要求可回滚、可验证、不中断现有开发流
|
||||||
Reference in New Issue
Block a user