优化了一些文件 增加多店铺

This commit is contained in:
yml
2026-04-09 01:31:47 +08:00
parent c2ec3aa6d2
commit e4ab1f559e
43 changed files with 1839 additions and 91 deletions
+19
View File
@@ -54,6 +54,25 @@ npm run start
- OCR 子服务目录
- 会话调试开关
- 兑换证明模式 `full | basic | off`
- Agiso 全局配置与后台店铺文件配置
### Agiso 多店铺
当前支持同一个 Agiso 应用下配置多个店铺的消息 token。
- 全局兜底配置仍走 `.env`
- `AGISO_APP_SECRET`
- `AGISO_MESSAGING_ENABLED`
- `AGISO_APP_ID`
- `AGISO_ACCESS_TOKEN`
- `AGISO_MESSAGE_APP_SECRET`
- `AGISO_MESSAGE_TEMPLATE`
- 店铺级覆盖配置改为文件:
- `apps/backend/data/agiso-shops.json`
- 后台维护入口:
- `#/admin/platform-shops`
消息发送时会按 webhook 识别出的 `shop_id` 优先读取 `agiso-shops.json`;如果没有命中,再回退到全局 `AGISO_ACCESS_TOKEN`
## 接口
+15
View File
@@ -74,6 +74,17 @@ module.exports = {
// Agiso 开放平台应用密钥,用于 webhook 验签。
appSecret: '',
tradeDetail: {
// 咸鱼 webhook 不带金额时,用这个接口按订单号补查订单详情。
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
// Header 中的 ApiVersion。
apiVersion: '1',
// 请求超时,单位毫秒。
timeoutMs: 5000,
},
messaging: {
// 是否启用 Agiso 站内消息发送。
enabled: false,
@@ -98,6 +109,10 @@ module.exports = {
// 发送给用户的默认消息模板。
messageTemplate: '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}',
// 平台内多店铺配置,按 shopId 做覆盖。
// 常见场景:同一个 Agiso 应用下,不同店铺使用不同 AccessToken。
shops: {},
},
},
},
+10
View File
@@ -0,0 +1,10 @@
{
"693760716": {
"shopName": "大锤商行",
"accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4"
},
"2037495620": {
"shopName": "羊小胖。",
"accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu"
}
}
+56
View File
@@ -167,6 +167,21 @@ function applyEnvOverrides(baseConfig) {
nextConfig.platforms.agiso.appSecret = agisoAppSecret
}
const agisoTradeDetailEndpoint = String(process.env.AGISO_TRADE_DETAIL_ENDPOINT || '').trim()
if (agisoTradeDetailEndpoint) {
nextConfig.platforms.agiso.tradeDetail.endpoint = agisoTradeDetailEndpoint
}
const agisoTradeDetailApiVersion = String(process.env.AGISO_TRADE_DETAIL_API_VERSION || '').trim()
if (agisoTradeDetailApiVersion) {
nextConfig.platforms.agiso.tradeDetail.apiVersion = agisoTradeDetailApiVersion
}
const agisoTradeDetailTimeoutMs = parseInteger(process.env.AGISO_TRADE_DETAIL_TIMEOUT_MS)
if (agisoTradeDetailTimeoutMs !== null) {
nextConfig.platforms.agiso.tradeDetail.timeoutMs = agisoTradeDetailTimeoutMs
}
const agisoAppId = String(process.env.AGISO_APP_ID || '').trim()
if (agisoAppId) {
nextConfig.platforms.agiso.messaging.appId = agisoAppId
@@ -202,6 +217,11 @@ function applyEnvOverrides(baseConfig) {
nextConfig.platforms.agiso.messaging.messageTemplate = agisoMessageTemplate
}
const agisoShops = parseJsonObject(process.env.AGISO_SHOPS_JSON)
if (agisoShops) {
nextConfig.platforms.agiso.messaging.shops = normalizeAgisoMessagingShops(agisoShops)
}
return nextConfig
}
@@ -308,3 +328,39 @@ function parseJsonArray(rawValue) {
return null
}
}
function normalizeAgisoMessagingShops(rawValue) {
const output = {}
for (const [shopId, config] of Object.entries(rawValue || {})) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId || !isPlainObject(config)) {
continue
}
const next = {}
const enabled = normalizeBooleanLike(config.enabled)
if (enabled !== null) {
next.enabled = enabled
}
for (const key of ['shopName', 'accessToken', 'messageTemplate', 'appSecret', 'apiVersion', 'sendMessageEndpoint']) {
const value = String(config[key] || '').trim()
if (value) {
next[key] = value
}
}
output[normalizedShopId] = next
}
return output
}
function normalizeBooleanLike(value) {
if (typeof value === 'boolean') {
return value
}
return parseBoolean(value)
}
+3 -3
View File
@@ -6,9 +6,9 @@ import { runDatabaseMigrations } from './db/migrate.js'
import adminRouter from './routes/admin.js'
import claimsRouter from './routes/claims.js'
import webhooksRouter from './routes/webhooks.js'
import { ensureAdminUsersBootstrapped } from './services/admin-auth-service.js'
import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/ocr.js'
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session.js'
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js'
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js'
import { logError, logInfo, logWarn } from './utils/logger.js'
import tencentRouter from './routes/tencent.js'
+34 -3
View File
@@ -3,6 +3,7 @@ import { Router } from 'express'
import {
closeAdminTask,
createAdminCdk,
getAdminAgisoShopConfigs,
getAdminCdks,
getAdminDashboardSummary,
getAdminOrderDetail,
@@ -20,7 +21,8 @@ import {
regenerateAdminTaskClaimLink,
releaseAdminTaskCdk,
retryAdminTask,
} from '../services/admin-service.js'
updateAdminAgisoShopConfigs,
} from '../services/admin/admin-service.js'
import {
createManagedAdminUser,
getAdminUserList,
@@ -31,8 +33,8 @@ import {
updateManagedAdminUserRole,
updateManagedAdminUserStatus,
verifyAdminSessionToken,
} from '../services/admin-auth-service.js'
import { getAdminAuditLogs, writeAdminAuditLog } from '../services/admin-audit-service.js'
} from '../services/admin/admin-auth-service.js'
import { getAdminAuditLogs, writeAdminAuditLog } from '../services/admin/admin-audit-service.js'
import { buildNotFoundPayload, buildSuccessPayload, createHttpError, sendRouteError } from '../utils/http.js'
const router = Router()
@@ -173,6 +175,35 @@ router.get('/audit-logs', (req, res) => {
}
})
router.get('/platform-config/agiso-shops', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = getAdminAgisoShopConfigs()
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取 Agiso 店铺配置失败', '[admin/platform-config/agiso-shops]')
}
})
router.post('/platform-config/agiso-shops', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = updateAdminAgisoShopConfigs(req.body)
writeAdminAuditLog(req.adminSession, {
action: 'platform_shop_config_updated',
targetType: 'platform_config',
targetId: 'agiso_shops',
data: {
shopCount: data.shops.length,
filePath: data.filePath,
},
})
res.json(buildSuccessPayload(data, 'Agiso 店铺配置已保存'))
} catch (error) {
sendRouteError(res, error, '保存 Agiso 店铺配置失败', '[admin/platform-config/agiso-shops]')
}
})
router.get('/orders', (req, res) => {
try {
const data = getAdminOrders(req.query)
+1 -1
View File
@@ -7,7 +7,7 @@ import {
getClaimScreenshotPath,
getClaimSessionSummary,
redeemClaimTask,
} from '../services/claim-session-service.js'
} from '../services/claim/claim-session-service.js'
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
const router = Router()
+1 -1
View File
@@ -8,7 +8,7 @@ import {
getTencentBrowserSessionScreenshotPath,
reloadTencentBrowserSession,
redeemTencentBrowserSession,
} from '../services/session.js'
} from '../services/session/session.js'
import {
buildNotFoundPayload,
buildSuccessPayload,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { processAgisoTradeWebhook } from '../services/webhook-service.js'
import { processAgisoTradeWebhook } from '../services/order/webhook-service.js'
import { createRequestId, logWebhook } from '../utils/logger.js'
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
@@ -1,5 +1,5 @@
import { createAdminAuditLog, listAdminAuditLogs } from '../repositories/admin-audit-log-repo.js'
import { nowIso } from '../utils/time.js'
import { createAdminAuditLog, listAdminAuditLogs } from '../../repositories/admin-audit-log-repo.js'
import { nowIso } from '../../utils/time.js'
export function writeAdminAuditLog(session, payload = {}) {
if (!session?.userId) {
@@ -1,6 +1,6 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../config/runtime.js'
import { runtimeConfig } from '../../config/runtime.js'
import {
countActiveAdminUsers,
createAdminUser,
@@ -8,9 +8,9 @@ import {
getAdminUserByUsername,
listAdminUsers,
updateAdminUser,
} from '../repositories/admin-user-repo.js'
import { addHours, nowIso } from '../utils/time.js'
import { createHttpError } from '../utils/http.js'
} from '../../repositories/admin-user-repo.js'
import { addHours, nowIso } from '../../utils/time.js'
import { createHttpError } from '../../utils/http.js'
export async function ensureAdminUsersBootstrapped() {
ensureAdminAuthConfigured()
@@ -1,16 +1,23 @@
import { getDb } from '../db/client.js'
import { createTaskClaimToken } from './claim-service.js'
import { getClaimTokenById, updateClaimToken } from '../repositories/claim-token-repo.js'
import { createCdks, getCdkById, invalidateCdk, listCdks, releaseReservedCdk } from '../repositories/cdk-repo.js'
import { listOrderItemsByOrderId } from '../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../repositories/order-repo.js'
import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../repositories/task-repo.js'
import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../repositories/webhook-event-repo.js'
import { createHttpError } from '../utils/http.js'
import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../utils/money.js'
import { nowIso } from '../utils/time.js'
import { reserveCdkForTask } from './cdk-service.js'
import { replayAgisoTradeWebhookEvent } from './webhook-service.js'
import { runtimeConfig } from '../../config/runtime.js'
import { getDb } from '../../db/client.js'
import { createTaskClaimToken } from '../claim/claim-service.js'
import {
getAgisoShopConfig,
getAgisoShopConfigMap,
getAgisoShopsFilePath,
saveAgisoShopConfigMap,
} from '../platforms/agiso/shop-config-service.js'
import { getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
import { createCdks, getCdkById, invalidateCdk, listCdks, releaseReservedCdk } from '../../repositories/cdk-repo.js'
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js'
import { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/money.js'
import { nowIso } from '../../utils/time.js'
import { reserveCdkForTask } from '../order/cdk-service.js'
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
export function getAdminDashboardSummary() {
const db = getDb()
@@ -74,7 +81,7 @@ export function getAdminOrderDetail(orderId) {
provider: order.provider || 'agiso',
platform: order.platform,
shopId: order.shop_id || '',
shopName: order.shop_name || '',
shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name),
platformOrderId: order.platform_order_id,
orderStatus: order.order_status,
payStatus: order.pay_status,
@@ -104,7 +111,7 @@ export function getAdminOrderDetail(orderId) {
provider: event.provider || 'agiso',
platform: event.platform,
shopId: event.shop_id || '',
shopName: event.shop_name || '',
shopName: resolveDisplayShopName(event.provider, event.shop_id, event.shop_name),
eventType: event.event_type,
eventKey: event.event_key,
signatureValid: Boolean(event.signature_valid),
@@ -402,6 +409,113 @@ export async function replayAdminWebhookEvent(eventId) {
}
}
export function getAdminAgisoShopConfigs() {
const configMap = getAgisoShopConfigMap()
const rows = getDb().prepare(`
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(*) 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
`).all()
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 function retryAdminTask(taskId) {
const task = getRequiredTask(taskId)
const now = nowIso()
@@ -626,14 +740,28 @@ function mapAdminWebhookEvent(item, { includeRaw = false } = {}) {
payload.sellerNick,
payload.SellerNick,
]),
shopName: item.shop_name || pickFirstNonEmpty([
payload.shopName,
payload.shop_name,
payload.ShopName,
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),
@@ -803,7 +931,7 @@ function mapAdminOrderListItem(item) {
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shop_id || '',
shopName: item.shop_name || '',
shopName: resolveDisplayShopName(item.provider, item.shop_id, item.shop_name),
platformOrderId: item.platform_order_id,
orderStatus: item.order_status,
payStatus: item.pay_status,
@@ -937,6 +1065,27 @@ function isTaskSystemBound(task) {
return Boolean(task && (task.reserved_cdk_id || task.claim_token_id))
}
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
}
function normalizeCdkImportRows(payload) {
const rows = []
const directRows = Array.isArray(payload.rows) ? payload.rows : []
@@ -1030,6 +1179,19 @@ function safeParseJson(rawText) {
}
}
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 normalizeRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
@@ -1,7 +1,7 @@
import { runtimeConfig } from '../config/runtime.js'
import { createClaimToken } from '../repositories/claim-token-repo.js'
import { addHours, nowIso } from '../utils/time.js'
import { randomToken } from '../utils/random.js'
import { runtimeConfig } from '../../config/runtime.js'
import { createClaimToken } from '../../repositories/claim-token-repo.js'
import { addHours, nowIso } from '../../utils/time.js'
import { randomToken } from '../../utils/random.js'
export function createTaskClaimToken(taskId) {
const createdAt = nowIso()
@@ -3,16 +3,16 @@ import {
getTencentBrowserSession,
getTencentBrowserSessionScreenshotPath,
redeemTencentBrowserSession,
} from './session.js'
import { findClaimTokenByToken, updateClaimToken } from '../repositories/claim-token-repo.js'
import { getOrderById } from '../repositories/order-repo.js'
import { getOrderItemById } from '../repositories/order-item-repo.js'
import { findTaskByClaimTokenId, updateTask } from '../repositories/task-repo.js'
import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../repositories/cdk-repo.js'
} from '../session/session.js'
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import { getOrderItemById } from '../../repositories/order-item-repo.js'
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../../repositories/cdk-repo.js'
import { buildClaimUrl } from './claim-service.js'
import { createHttpError } from '../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../utils/money.js'
import { nowIso } from '../utils/time.js'
import { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { nowIso } from '../../utils/time.js'
const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
@@ -1,5 +1,5 @@
import { assignReservedCdk, findFirstAvailableCdkBySkuCode } from '../repositories/cdk-repo.js'
import { nowIso } from '../utils/time.js'
import { assignReservedCdk, findFirstAvailableCdkBySkuCode } from '../../repositories/cdk-repo.js'
import { nowIso } from '../../utils/time.js'
export function reserveCdkForTask(skuCode, taskId) {
if (!skuCode) {
@@ -1,8 +1,8 @@
import { createTask, listTasksByOrderId, updateTask } from '../repositories/task-repo.js'
import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
import { reserveCdkForTask } from './cdk-service.js'
import { createTaskClaimToken } from './claim-service.js'
import { nowIso } from '../utils/time.js'
import { randomId } from '../utils/random.js'
import { createTaskClaimToken } from '../claim/claim-service.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
export function syncDeliveryTasksForOrder(order, orderItems) {
const existingTasks = listTasksByOrderId(order.id)
@@ -0,0 +1,177 @@
import { getDb, runInTransaction } from '../../db/client.js'
import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js'
import { logInfo } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import { parseJsonObject } from '../../utils/json.js'
export async function repairAgisoOrderData() {
const rows = getDb().prepare(`
SELECT
o.id,
o.provider,
o.platform,
o.shop_id,
o.shop_name,
o.platform_order_id,
o.total_amount,
o.raw_payload_json,
o.paid_at,
o.buyer_id,
o.buyer_name,
o.receiver_contact,
(
SELECT we.body_json
FROM webhook_events we
WHERE we.related_order_id = o.id
ORDER BY we.id DESC
LIMIT 1
) AS latest_body_json
FROM orders o
WHERE o.provider = 'agiso' AND o.platform = 'xianyu'
ORDER BY o.id ASC
`).all()
if (rows.length === 0) {
return
}
let repairedOrderIdCount = 0
let repairedAmountCount = 0
for (const row of rows) {
const latestRequestBody = parseJsonObject(row.latest_body_json)
const rawJson = String(latestRequestBody.json || '').trim()
const webhookPayload = rawJson ? parseJsonObject(rawJson, { preserveLargeIntegers: true }) : {}
const rawPayload = parseJsonObject(row.raw_payload_json, { preserveLargeIntegers: true })
const expectedOrderId = pickFirstNonEmpty([
webhookPayload.biz_order_id,
webhookPayload.order_id,
webhookPayload.orderId,
rawPayload.biz_order_id,
rawPayload.order_id,
rawPayload.orderId,
])
const parsed = {
provider: row.provider,
platform: row.platform,
shopId: String(row.shop_id || '').trim(),
shopName: String(row.shop_name || '').trim(),
platformOrderId: expectedOrderId || String(row.platform_order_id || '').trim(),
totalAmount: Number(row.total_amount || 0),
buyerId: pickFirstNonEmpty([rawPayload.buyer_id, rawPayload.buyerId, rawPayload.BuyerId, row.buyer_id]),
buyerName: pickFirstNonEmpty([rawPayload.buyer_name, rawPayload.buyerName, rawPayload.BuyerName, row.buyer_name]),
receiverContact: pickFirstNonEmpty([
rawPayload.receiver_contact,
rawPayload.receiverContact,
rawPayload.receiver_mobile,
rawPayload.receiverMobile,
rawPayload.mobile,
rawPayload.phone,
row.receiver_contact,
]),
paidAt: row.paid_at,
rawPayload: Object.keys(webhookPayload).length > 0 ? webhookPayload : rawPayload,
items: [],
}
const detailResult = await enrichAgisoXianyuTradeOrder(parsed, {
requestId: `repair-order-${row.id}`,
})
const nextOrderId = pickFirstNonEmpty([
detailResult.parsed.platformOrderId,
expectedOrderId,
row.platform_order_id,
])
const nextAmount = Number(detailResult.parsed.totalAmount || 0)
const nextRawPayloadJson = JSON.stringify(detailResult.parsed.rawPayload || rawPayload || {})
const nextShopName = pickFirstNonEmpty([detailResult.parsed.shopName, row.shop_name])
const nextBuyerId = pickFirstNonEmpty([detailResult.parsed.buyerId, row.buyer_id])
const nextBuyerName = pickFirstNonEmpty([detailResult.parsed.buyerName, row.buyer_name])
const nextReceiverContact = pickFirstNonEmpty([detailResult.parsed.receiverContact, row.receiver_contact])
const nextPaidAt = detailResult.parsed.paidAt || row.paid_at
const orderIdChanged = nextOrderId && nextOrderId !== String(row.platform_order_id || '')
const amountChanged = nextAmount > 0 && nextAmount !== Number(row.total_amount || 0)
const payloadChanged = nextRawPayloadJson !== String(row.raw_payload_json || '{}')
const metadataChanged = nextShopName !== String(row.shop_name || '')
|| nextBuyerId !== String(row.buyer_id || '')
|| nextBuyerName !== String(row.buyer_name || '')
|| nextReceiverContact !== String(row.receiver_contact || '')
|| nextPaidAt !== row.paid_at
if (!orderIdChanged && !amountChanged && !payloadChanged && !metadataChanged) {
continue
}
runInTransaction((db) => {
db.prepare(`
UPDATE orders
SET
shop_name = ?,
platform_order_id = ?,
buyer_id = ?,
buyer_name = ?,
receiver_contact = ?,
total_amount = ?,
raw_payload_json = ?,
paid_at = ?,
updated_at = ?
WHERE id = ?
`).run(
nextShopName,
nextOrderId,
nextBuyerId,
nextBuyerName,
nextReceiverContact,
nextAmount,
nextRawPayloadJson,
nextPaidAt,
nowIso(),
row.id,
)
if (orderIdChanged) {
db.prepare('UPDATE delivery_tasks SET platform_order_id = ? WHERE order_id = ?').run(nextOrderId, row.id)
db.prepare(`
UPDATE message_deliveries
SET
platform_order_id = ?,
recipient_key = CASE
WHEN recipient_key = ? THEN ?
ELSE recipient_key
END,
updated_at = ?
WHERE order_id = ?
`).run(nextOrderId, row.platform_order_id, nextOrderId, nowIso(), row.id)
}
})
if (orderIdChanged) {
repairedOrderIdCount += 1
}
if (amountChanged) {
repairedAmountCount += 1
}
}
if (repairedOrderIdCount > 0 || repairedAmountCount > 0) {
logInfo('[startup]', '已自动修复历史 Agiso 咸鱼订单数据', {
repairedOrderIdCount,
repairedAmountCount,
})
}
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
@@ -1,11 +1,11 @@
import { createOrder, findOrderByPlatformOrderId, updateOrder } from '../repositories/order-repo.js'
import { replaceOrderItems } from '../repositories/order-item-repo.js'
import { getClaimTokenById } from '../repositories/claim-token-repo.js'
import { buildClaimUrl } from './claim-service.js'
import { createOrder, findOrderByPlatformOrderId, updateOrder } from '../../repositories/order-repo.js'
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
import { buildClaimUrl } from '../claim/claim-service.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { ensureAgisoClaimMessageDeliveredForTask } from './message-service.js'
import { nowIso } from '../utils/time.js'
import { logWebhook } from '../utils/logger.js'
import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js'
import { nowIso } from '../../utils/time.js'
import { logWebhook } from '../../utils/logger.js'
export async function upsertOrderFromWebhook(event) {
const now = nowIso()
@@ -81,7 +81,12 @@ export async function upsertOrderFromWebhook(event) {
})
for (const task of tasks) {
if (event.provider !== 'agiso' || String(task.task_status || '') !== 'link_generated' || !task.claim_token_id) {
if (
event.provider !== 'agiso'
|| event.platform !== 'xianyu'
|| String(task.task_status || '') !== 'link_generated'
|| !task.claim_token_id
) {
continue
}
@@ -90,7 +95,7 @@ export async function upsertOrderFromWebhook(event) {
continue
}
const result = await ensureAgisoClaimMessageDeliveredForTask({
const result = await ensureAgisoXianyuClaimMessageDeliveredForTask({
order,
task,
claimUrl: buildClaimUrl(claimToken.token),
@@ -1,12 +1,12 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../config/runtime.js'
import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook-event-repo.js'
import { runtimeConfig } from '../../config/runtime.js'
import { createWebhookEvent, updateWebhookEvent } from '../../repositories/webhook-event-repo.js'
import { upsertOrderFromWebhook } from './order-service.js'
import { createHttpError } from '../utils/http.js'
import { logWebhook } from '../utils/logger.js'
import { parseAmountToFen } from '../utils/money.js'
import { nowIso } from '../utils/time.js'
import { createHttpError } from '../../utils/http.js'
import { logWebhook } from '../../utils/logger.js'
import { parseAmountToFen } from '../../utils/money.js'
import { nowIso } from '../../utils/time.js'
export async function processAgisoTradeWebhook(requestLike) {
const parsed = parseAgisoTradeRequest(requestLike)
@@ -0,0 +1,9 @@
## Agiso Platform Services
Keep Agiso-wide shared helpers in this directory.
Per-platform integrations live in subdirectories:
- `xianyu/`
- `pdd/`
- `taobao/`
@@ -0,0 +1,3 @@
## PDD
Put all Agiso PDD-specific service integrations here.
@@ -0,0 +1,103 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json')
export function getAgisoShopsFilePath() {
return AGISO_SHOPS_FILE_PATH
}
export function getAgisoShopConfigMap() {
const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {})
const fileConfig = loadAgisoShopConfigMapFromFile()
return {
...envConfig,
...fileConfig,
}
}
export function getAgisoShopConfig(shopId) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return null
}
return getAgisoShopConfigMap()[normalizedShopId] || null
}
export function saveAgisoShopConfigMap(rawValue) {
const normalized = normalizeAgisoShopConfigMap(rawValue)
fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true })
fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
function loadAgisoShopConfigMapFromFile() {
if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) {
return {}
}
try {
const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8')
const parsed = JSON.parse(rawText)
return normalizeAgisoShopConfigMap(parsed)
} catch {
return {}
}
}
function normalizeAgisoShopConfigMap(rawValue) {
const output = {}
for (const [shopId, config] of Object.entries(rawValue || {})) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId || !isPlainObject(config)) {
continue
}
const next = {}
const enabled = normalizeBooleanLike(config.enabled)
if (enabled !== null) {
next.enabled = enabled
}
for (const key of ['shopName', 'accessToken', 'messageTemplate', 'appSecret', 'apiVersion', 'sendMessageEndpoint', 'tradeDetailEndpoint']) {
const value = String(config[key] || '').trim()
if (value) {
next[key] = value
}
}
output[normalizedShopId] = next
}
return output
}
function normalizeBooleanLike(value) {
if (typeof value === 'boolean') {
return value
}
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) {
return null
}
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return null
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,3 @@
## Taobao
Put all Agiso Taobao-specific service integrations here.
@@ -0,0 +1,3 @@
## Xianyu
Put all Agiso Xianyu-specific service integrations here.
@@ -1,20 +1,22 @@
import { runtimeConfig } from '../config/runtime.js'
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { getAgisoShopConfigMap } from '../shop-config-service.js'
import {
createMessageDelivery,
findLatestSuccessfulMessageDeliveryByTask,
updateMessageDelivery,
} from '../repositories/message-delivery-repo.js'
import { nowIso } from '../utils/time.js'
} from '../../../../repositories/message-delivery-repo.js'
import { nowIso } from '../../../../utils/time.js'
const AGISO_MESSAGE_CHANNEL = 'agiso_im'
const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im'
export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, claimUrl, expiredAt }) {
export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ order, task, claimUrl, expiredAt }) {
if (!order || !task || !claimUrl) {
return { sent: false, skipped: true, reason: 'missing_message_context' }
}
const config = runtimeConfig.platforms?.agiso?.messaging || {}
const config = resolveAgisoXianyuMessagingConfig(order)
if (!config.enabled) {
return { sent: false, skipped: true, reason: 'messaging_disabled' }
}
@@ -26,12 +28,19 @@ export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, cla
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
}
const successful = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_MESSAGE_CHANNEL)
const successful = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_XIANYU_MESSAGE_CHANNEL)
if (successful) {
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
}
const messageContent = renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template: config.messageTemplate })
const messageContent = renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template: config.messageTemplate,
shopName: config.shopName,
})
const url = buildRequestUrl(endpoint)
const requestBody = buildRequestBody({
tid: String(order.platform_order_id || ''),
@@ -48,7 +57,7 @@ export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, cla
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
shopName: String(order.shop_name || '').trim(),
channel: AGISO_MESSAGE_CHANNEL,
channel: AGISO_XIANYU_MESSAGE_CHANNEL,
orderId: order.id,
taskId: task.id,
platformOrderId: order.platform_order_id,
@@ -113,6 +122,18 @@ export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, cla
}
}
function resolveAgisoXianyuMessagingConfig(order) {
const baseConfig = runtimeConfig.platforms?.agiso?.messaging || {}
const shopId = String(order?.shop_id || '').trim()
const shopConfigs = getAgisoShopConfigMap()
const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {}
return {
...baseConfig,
...shopConfig,
}
}
function buildRequestUrl(endpoint) {
return new URL(endpoint).toString()
}
@@ -151,12 +172,15 @@ function generateSign(params, appSecret) {
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template }) {
function renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template, shopName = '' }) {
const source = String(template || '').trim() || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
const resolvedShopName = String(shopName || order.shop_name || order.shop_id || '').trim()
return source
.replaceAll('{platformOrderId}', String(order.platform_order_id || ''))
.replaceAll('{taskNo}', String(task.task_no || ''))
.replaceAll('{shopName}', resolvedShopName)
.replaceAll('{shopId}', String(order.shop_id || ''))
.replaceAll('{claimUrl}', claimUrl)
.replaceAll('{expiredAt}', String(expiredAt || '尽快'))
}
@@ -196,7 +220,7 @@ function resolveAgisoErrorMessage(payload, rawText, statusCode) {
}
const text = String(rawText || '').trim()
return text || `Agiso 发消息失败,HTTP ${statusCode}`
return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}`
}
function safeParseJson(rawText) {
@@ -232,3 +256,7 @@ function maskBodyForStorage(body) {
return output
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,497 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import { parseAmountToFen } from '../../../../utils/money.js'
import { parseJsonObject } from '../../../../utils/json.js'
const DEFAULT_DETAIL_TIMEOUT_MS = 5000
export async function enrichAgisoXianyuTradeOrder(parsed, { requestId = '' } = {}) {
if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) {
return { parsed, enriched: false, reason: 'not_needed' }
}
const config = resolveAgisoXianyuTradeDetailConfig(parsed.shopId)
if (!config.endpoint || !config.accessToken || !config.appSecret) {
logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'missing_config' }
}
const requestBody = buildRequestBody({
platformOrderId: parsed.platformOrderId,
appSecret: config.appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
endpoint: config.endpoint,
})
try {
const response = await fetchWithTimeout(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
}, config.timeoutMs)
const rawText = await response.text()
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true })
const detailPayload = extractAgisoDetailPayload(payload)
const detailAmountFen = resolveTotalAmountFen(detailPayload)
if (response.status < 200 || response.status >= 300) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
errorMessage: message,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'http_error', errorMessage: message }
}
if (!isAgisoDetailSuccess(payload)) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
errorMessage: message,
response: payload,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'business_error', errorMessage: message }
}
if (!detailPayload || typeof detailPayload !== 'object' || Object.keys(detailPayload).length === 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'empty_detail_payload' }
}
if (detailAmountFen <= 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
responseStatus: response.status,
detailKeys: Object.keys(detailPayload),
}, { level: 'warn' })
return {
parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }),
enriched: false,
reason: 'amount_still_missing',
}
}
const merged = mergeEnrichedTrade(parsed, detailPayload)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
totalAmountFen: merged.totalAmount,
paidAt: merged.paidAt,
})
return {
parsed: merged,
enriched: true,
totalAmountFen: merged.totalAmount,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败')
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
errorMessage: message,
}, { level: 'warn' })
return { parsed, enriched: false, reason: 'request_failed', errorMessage: message }
}
}
function shouldHydrateAgisoXianyuTradeOrder(parsed) {
return parsed?.provider === 'agiso'
&& parsed?.platform === 'xianyu'
&& String(parsed?.platformOrderId || '').trim()
&& Number(parsed?.totalAmount || 0) <= 0
}
function resolveAgisoXianyuTradeDetailConfig(shopId) {
const baseConfig = runtimeConfig.platforms?.agiso?.tradeDetail || {}
const shopConfig = getAgisoShopConfig(shopId) || {}
return {
endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(),
apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1',
accessToken: String(shopConfig.accessToken || runtimeConfig.platforms?.agiso?.messaging?.accessToken || '').trim(),
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS),
}
}
function buildRequestHeaders({ accessToken, apiVersion }) {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({ platformOrderId, appSecret }) {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload = {
tid: String(platformOrderId || '').trim(),
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params, appSecret) {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function extractAgisoDetailPayload(payload) {
if (!isPlainObject(payload)) {
return {}
}
for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) {
if (isPlainObject(payload[key])) {
return payload[key]
}
}
return payload
}
function resolveTotalAmountFen(payload) {
const fenAmount = normalizeFenInteger(pickFirstNonEmpty([
payload.payment,
payload.Payment,
payload.post_fee,
payload.postFee,
payload.item?.price,
]))
if (fenAmount > 0) {
return fenAmount
}
return parseAmountToFen(pickFirstNonEmpty([
payload.total_fee,
payload.totalFee,
payload.TotalFee,
payload.pay_fee,
payload.payFee,
payload.PayFee,
payload.actual_fee,
payload.actualFee,
payload.ActualFee,
payload.total_amount,
payload.totalAmount,
payload.Amount,
payload.amount,
]))
}
function resolvePaidAt(payload, fallbackValue) {
const providerPaidAt = normalizeProviderDateTime(
pickFirstNonEmpty([
payload.paid_at,
payload.paidAt,
payload.pay_time,
payload.payTime,
payload.PayTime,
fallbackValue,
]),
)
if (providerPaidAt) {
return providerPaidAt
}
const rawStatus = normalizeInteger(
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
)
if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) {
return nowIso()
}
return null
}
function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) {
const mergedPayload = {
...parsed.rawPayload,
_agisoTradeDetail: detailPayload,
}
const totalAmountFen = resolveTotalAmountFen(detailPayload)
return {
...parsed,
buyerId: pickFirstNonEmpty([
parsed.buyerId,
detailPayload.encryption_buyer_id,
detailPayload.buyer_id,
detailPayload.buyerId,
detailPayload.BuyerId,
detailPayload.BuyerOpenUid,
detailPayload.buyer_open_uid,
]),
buyerName: pickFirstNonEmpty([
parsed.buyerName,
detailPayload.buyer_name,
detailPayload.buyerName,
detailPayload.BuyerName,
detailPayload.buyer_nick,
detailPayload.nick,
detailPayload.BuyerNick,
]),
receiverContact: pickFirstNonEmpty([
parsed.receiverContact,
detailPayload.receiver_contact,
detailPayload.receiverContact,
detailPayload.receiver_mobile,
detailPayload.receiverMobile,
detailPayload.mobile,
detailPayload.phone,
]),
shopName: pickFirstNonEmpty([
parsed.shopName,
detailPayload.shop_name,
detailPayload.shopName,
detailPayload.ShopName,
detailPayload.seller_name,
detailPayload.sellerName,
detailPayload.seller_nick,
detailPayload.sellerNick,
detailPayload.SellerNick,
]),
totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount),
paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt),
rawPayload: mergedPayload,
items: normalizeOrderItems(mergedPayload, parsed.items),
}
}
function normalizeOrderItems(payload, fallbackItems = []) {
const candidates = [
payload.item ? [payload.item] : null,
payload.items,
payload.Items,
payload.orders,
payload.Orders,
payload.order_list,
payload.OrderList,
]
const items = candidates.find((item) => Array.isArray(item) && item.length > 0)
if (!Array.isArray(items) || items.length === 0) {
return Array.isArray(fallbackItems) ? fallbackItems : []
}
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuCode = pickFirstNonEmpty([
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
source.OuterIid,
source.outerIid,
source.outer_iid,
source.sku_code,
source.skuCode,
source.goods_sku,
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
source.NumIid,
source.num_iid,
payload.item_id,
payload.itemId,
])
return {
skuCode,
skuName: pickFirstNonEmpty([
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
]),
quantity: Math.max(
1,
normalizeInteger(
pickFirstNonEmpty([
source.quantity,
source.num,
source.Num,
source.buy_amount,
payload.quantity,
payload.num,
payload.Num,
]),
) || 1,
),
spec: source,
}
})
}
function isAgisoDetailSuccess(payload) {
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return false
}
if (payload.IsSuccess === true) {
return true
}
if (Number(payload.Error_Code) === 0) {
return true
}
if (Number(payload.code) === 0) {
return true
}
if (Number(payload.success) === 1 || payload.success === true) {
return true
}
return false
}
function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) {
if (payload && typeof payload === 'object') {
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}`
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs)
try {
return await fetch(url, {
...options,
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
}
function normalizeProviderDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return null
}
if (/^\d{10,13}$/.test(normalized)) {
const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
if (Number.isFinite(timestamp)) {
return new Date(timestamp).toISOString()
}
}
const isoLike = normalized.replace(' ', 'T')
const parsed = Date.parse(isoLike)
if (Number.isNaN(parsed)) {
return null
}
return new Date(parsed).toISOString()
}
function normalizeInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) ? Math.round(parsed) : 0
}
function normalizePositiveInteger(value, fallbackValue) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue
}
function normalizeFenInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -3,7 +3,7 @@ import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { runtimeConfig } from '../config/runtime.js'
import { runtimeConfig } from '../../config/runtime.js'
const DEFAULT_TIMEOUT_MS = 60_000
@@ -1,5 +1,5 @@
import { runtimeConfig } from '../config/runtime.js'
import { logDebug } from '../utils/logger.js'
import { runtimeConfig } from '../../config/runtime.js'
import { logDebug } from '../../utils/logger.js'
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
@@ -1,7 +1,7 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { runtimeConfig } from '../config/runtime.js'
import { runtimeConfig } from '../../config/runtime.js'
const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4'
const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { runtimeConfig } from '../config/runtime.js'
import { runtimeConfig } from '../../config/runtime.js'
import {
isRetryableQrCaptureError,
logBrowserSessionDebug,
@@ -35,7 +35,7 @@ import {
import { recognizeTencentCaptcha } from './ocr.js'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..')
const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions')
const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
+60
View File
@@ -0,0 +1,60 @@
import { createHttpError } from './http.js'
const LARGE_INTEGER_PATTERN = /(:\s*)(-?\d{16,})(\s*[,}\]])/g
export function parseJsonObject(rawText, { preserveLargeIntegers = false, throwOnError = false } = {}) {
const normalized = String(rawText || '').trim()
if (!normalized) {
return {}
}
try {
const prepared = preserveLargeIntegers ? wrapLargeIntegerLiterals(normalized) : normalized
const parsed = JSON.parse(prepared)
return isPlainObject(parsed) ? parsed : {}
} catch (error) {
if (!throwOnError) {
return {}
}
throw createHttpError('json 参数不是合法 JSON', {
statusCode: 400,
errorCode: 'invalid_json_payload',
cause: error,
})
}
}
export function wrapLargeIntegerLiterals(rawText) {
return String(rawText || '').replace(LARGE_INTEGER_PATTERN, (_match, prefix, digits, suffix) => {
if (!shouldPreserveIntegerToken(digits)) {
return `${prefix}${digits}${suffix}`
}
return `${prefix}"${digits}"${suffix}`
})
}
function shouldPreserveIntegerToken(rawDigits) {
const digits = String(rawDigits || '').trim()
const unsigned = digits.startsWith('-') ? digits.slice(1) : digits
if (!/^\d+$/.test(unsigned)) {
return false
}
if (unsigned.length > 16) {
return true
}
if (unsigned.length < 16) {
return false
}
return unsigned > '9007199254740991'
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
+4
View File
@@ -68,6 +68,10 @@ const router = createRouter({
path: 'webhook-events',
component: () => import('@/views/admin/AdminWebhookEventsView.vue'),
},
{
path: 'platform-shops',
component: () => import('@/views/admin/AdminPlatformShopsView.vue'),
},
{
path: 'webhook-events/:eventId',
component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'),
+17
View File
@@ -1,5 +1,7 @@
import { apiGet, apiGetBlob, apiPost } from '@/lib/http'
import type {
AdminAgisoObservedShopItem,
AdminAgisoShopConfigItem,
AdminAuditLogItem,
AdminTaskActionResponse,
AdminCdkListItem,
@@ -57,6 +59,21 @@ export function fetchAdminAuditLogs(params?: Record<string, unknown>) {
return apiGet<{ items: AdminAuditLogItem[]; pagination: AdminPagination }>('/api/v1/admin/audit-logs', params)
}
export function fetchAdminAgisoShopConfigs() {
return apiGet<{
filePath: string
shops: AdminAgisoShopConfigItem[]
observedShops: AdminAgisoObservedShopItem[]
}>('/api/v1/admin/platform-config/agiso-shops')
}
export function saveAdminAgisoShopConfigs(payload: { shops: Array<Record<string, unknown>> }) {
return apiPost<{
filePath: string
shops: AdminAgisoShopConfigItem[]
}>('/api/v1/admin/platform-config/agiso-shops', payload)
}
export function fetchAdminOrders(params?: Record<string, unknown>) {
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params)
}
+21
View File
@@ -48,6 +48,27 @@ export interface AdminAuditLogItem {
createdAt: string
}
export interface AdminAgisoShopConfigItem {
shopId: string
shopName: string
accessToken: string
accessTokenMasked: string
enabled: boolean | null
messageTemplate: string
appSecretConfigured: boolean
apiVersion: string
sendMessageEndpoint: string
}
export interface AdminAgisoObservedShopItem {
shopId: string
detectedShopName: string
displayShopName: string
latestSeenAt: string | null
webhookEventCount: number
configured: boolean
}
export interface AdminDashboardSummary {
todayOrders: number
paidPendingClaim: number
+2
View File
@@ -28,6 +28,7 @@ export function formatAuditAction(action: string) {
inventory_cdk_released: '释放库存 CDK',
inventory_cdk_invalidated: '作废库存 CDK',
webhook_replayed: '重放 Webhook',
platform_shop_config_updated: '更新店铺配置',
}
return formatStatusWithRaw(action, labelMap)
@@ -39,6 +40,7 @@ export function formatAuditTargetType(targetType: string) {
task: '交付任务',
cdk: 'CDK 库存',
webhook_event: 'Webhook 事件',
platform_config: '平台配置',
}
return formatStatusWithRaw(targetType, labelMap)
@@ -20,6 +20,7 @@ const navItems = computed(() => {
if (isAdmin.value) {
baseItems.splice(1, 0, { to: '/admin/users', label: '用户' })
baseItems.push({ to: '/admin/platform-shops', label: '店铺配置' })
baseItems.push({ to: '/admin/audit-logs', label: '审计' })
}
@@ -0,0 +1,416 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchAdminAgisoShopConfigs, saveAdminAgisoShopConfigs } from '@/services/admin'
import type { AdminAgisoObservedShopItem, AdminAgisoShopConfigItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
type EditableShop = {
id: string
shopId: string
shopName: string
accessToken: string
enabled: boolean
messageTemplate: string
}
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const filePath = ref('')
const shops = ref<EditableShop[]>([])
const observedShops = ref<AdminAgisoObservedShopItem[]>([])
function createEmptyShop(): EditableShop {
return {
id: crypto.randomUUID(),
shopId: '',
shopName: '',
accessToken: '',
enabled: true,
messageTemplate: '',
}
}
function mapEditableShop(item: AdminAgisoShopConfigItem): EditableShop {
return {
id: crypto.randomUUID(),
shopId: item.shopId,
shopName: item.shopName,
accessToken: item.accessToken,
enabled: item.enabled !== false,
messageTemplate: item.messageTemplate,
}
}
async function loadConfigs() {
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminAgisoShopConfigs()
filePath.value = response.data.filePath
shops.value = response.data.shops.map(mapEditableShop)
observedShops.value = response.data.observedShops
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取店铺配置失败'
} finally {
loading.value = false
}
}
function addShop() {
shops.value.unshift(createEmptyShop())
}
function removeShop(id: string) {
shops.value = shops.value.filter((item) => item.id !== id)
}
function importObservedShop(item: AdminAgisoObservedShopItem) {
shops.value.unshift({
id: crypto.randomUUID(),
shopId: item.shopId,
shopName: item.detectedShopName || item.displayShopName || '',
accessToken: '',
enabled: true,
messageTemplate: '',
})
}
async function saveConfigs() {
const payload = shops.value
.map((item) => ({
shopId: item.shopId.trim(),
shopName: item.shopName.trim(),
accessToken: item.accessToken.trim(),
enabled: item.enabled,
messageTemplate: item.messageTemplate.trim(),
}))
.filter((item) => item.shopId && item.accessToken)
saving.value = true
try {
const response = await saveAdminAgisoShopConfigs({ shops: payload })
filePath.value = response.data.filePath
shops.value = response.data.shops.map(mapEditableShop)
ElMessage.success('店铺配置已保存')
await loadConfigs()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存店铺配置失败')
} finally {
saving.value = false
}
}
onMounted(loadConfigs)
</script>
<template>
<section class="admin-panel">
<header class="panel-header">
<div>
<h1>店铺配置</h1>
<p>Agiso 多店铺配置改为文件维护保存后立即生效不需要再手改 `.env`</p>
</div>
<div class="header-actions">
<el-button round @click="loadConfigs">刷新</el-button>
<el-button round type="primary" @click="addShop">新增店铺</el-button>
</div>
</header>
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护店铺配置</div>
<template v-else>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">配置文件</span>
<code>{{ filePath || '-' }}</code>
</div>
<div class="meta-line">
<span class="meta-label">说明</span>
<span>店铺级 `accessToken` 优先于全局 `AGISO_ACCESS_TOKEN`未配置的店铺仍会继续使用全局兜底</span>
</div>
</section>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<div v-if="loading" class="empty-block">店铺配置加载中</div>
<template v-else>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>已配置店铺</h3>
<p>至少填写 `shopId` `accessToken``shopName` 为空时界面会退回显示店铺 ID</p>
</div>
<el-button :loading="saving" round type="primary" @click="saveConfigs">保存配置</el-button>
</div>
<div v-if="shops.length === 0" class="empty-inline">当前还没有店铺配置先新增一条</div>
<div v-for="shop in shops" :key="shop.id" class="shop-card">
<div class="shop-grid">
<label class="field-block">
<span>店铺 ID</span>
<input v-model="shop.shopId" class="text-input" placeholder="例如 693760716" />
</label>
<label class="field-block">
<span>店铺名称</span>
<input v-model="shop.shopName" class="text-input" placeholder="例如 大锤商行" />
</label>
<label class="field-block field-wide">
<span>AccessToken</span>
<input v-model="shop.accessToken" class="text-input" placeholder="AldsIdle..." />
</label>
<label class="field-block field-wide">
<span>消息模板</span>
<textarea
v-model="shop.messageTemplate"
class="text-area"
placeholder="留空则沿用全局 AGISO_MESSAGE_TEMPLATE"
/>
</label>
</div>
<div class="shop-actions">
<label class="checkbox-line">
<input v-model="shop.enabled" type="checkbox" />
<span>启用店铺级覆盖</span>
</label>
<el-button link type="danger" @click="removeShop(shop.id)">删除</el-button>
</div>
</div>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>近期识别到的店铺</h3>
<p> webhook 自动识别出的店铺可一键导入配置列表</p>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>店铺</th>
<th>Webhook 次数</th>
<th>最近出现时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in observedShops" :key="item.shopId">
<td>
<div class="cell-stack">
<strong>{{ item.displayShopName || item.shopId }}</strong>
<span class="cell-subtle">ID: {{ item.shopId }}</span>
</div>
</td>
<td>{{ item.webhookEventCount }}</td>
<td>{{ formatAdminDateTime(item.latestSeenAt) }}</td>
<td>{{ item.configured ? '已配置' : '未配置' }}</td>
<td>
<el-button
v-if="!item.configured"
link
type="primary"
@click="importObservedShop(item)"
>
导入到配置
</el-button>
<span v-else class="cell-subtle">已存在</span>
</td>
</tr>
<tr v-if="observedShops.length === 0">
<td colspan="5" class="empty-inline">最近还没有识别到可用店铺</td>
</tr>
</tbody>
</table>
</section>
</template>
</template>
</section>
</template>
<style scoped>
.admin-panel {
display: grid;
gap: 16px;
}
.panel-header,
.section-title-row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.panel-header h1,
.section-title-row h3 {
margin: 0;
color: #1d3555;
}
.panel-header p,
.section-title-row p {
margin: 8px 0 0;
color: #64748b;
}
.header-actions {
display: flex;
gap: 10px;
}
.meta-card,
.table-card,
.empty-block,
.error-copy {
padding: 18px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
}
.meta-card {
display: grid;
gap: 10px;
}
.meta-line {
display: grid;
gap: 6px;
}
.meta-label {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.shop-card {
margin-top: 14px;
padding: 16px;
border-radius: 18px;
background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.18);
}
.shop-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field-block {
display: grid;
gap: 8px;
}
.field-block span {
color: #475467;
font-size: 12px;
font-weight: 700;
}
.field-wide {
grid-column: 1 / -1;
}
.text-input,
.text-area {
width: 100%;
min-height: 42px;
padding: 0 14px;
border-radius: 14px;
border: 1px solid rgba(86, 108, 138, 0.18);
background: rgba(255, 255, 255, 0.96);
}
.text-area {
min-height: 90px;
padding: 12px 14px;
resize: vertical;
}
.shop-actions {
margin-top: 12px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.checkbox-line {
display: inline-flex;
gap: 8px;
align-items: center;
color: #475467;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.data-table th,
.data-table td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
color: #334155;
vertical-align: top;
}
.data-table th {
color: #64748b;
font-size: 13px;
}
.cell-stack {
display: grid;
gap: 4px;
}
.cell-subtle {
color: #64748b;
font-size: 12px;
}
.empty-block,
.empty-inline {
color: #64748b;
}
.error-copy {
color: #b42318;
}
@media (max-width: 980px) {
.panel-header,
.section-title-row,
.shop-actions {
flex-direction: column;
align-items: stretch;
}
.shop-grid {
grid-template-columns: 1fr;
}
}
</style>