diff --git a/apps/backend/README.md b/apps/backend/README.md index 01ecba6d..a305ae30 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -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`。 ## 接口 diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index 5bab49e4..7d393132 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -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: {}, }, }, }, diff --git a/apps/backend/data/agiso-shops.json b/apps/backend/data/agiso-shops.json new file mode 100644 index 00000000..4a695c5c --- /dev/null +++ b/apps/backend/data/agiso-shops.json @@ -0,0 +1,10 @@ +{ + "693760716": { + "shopName": "大锤商行", + "accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4" + }, + "2037495620": { + "shopName": "羊小胖。", + "accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu" + } +} diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 55395065..2f9c8889 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -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) +} diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index caa66214..1feb1570 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -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' diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js index 6997c88e..139a0caa 100644 --- a/apps/backend/src/routes/admin.js +++ b/apps/backend/src/routes/admin.js @@ -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) diff --git a/apps/backend/src/routes/claims.js b/apps/backend/src/routes/claims.js index f549abe9..d7dbd627 100644 --- a/apps/backend/src/routes/claims.js +++ b/apps/backend/src/routes/claims.js @@ -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() diff --git a/apps/backend/src/routes/tencent.js b/apps/backend/src/routes/tencent.js index 2d0ee1de..ccea0a9c 100644 --- a/apps/backend/src/routes/tencent.js +++ b/apps/backend/src/routes/tencent.js @@ -8,7 +8,7 @@ import { getTencentBrowserSessionScreenshotPath, reloadTencentBrowserSession, redeemTencentBrowserSession, -} from '../services/session.js' +} from '../services/session/session.js' import { buildNotFoundPayload, buildSuccessPayload, diff --git a/apps/backend/src/routes/webhooks.js b/apps/backend/src/routes/webhooks.js index 820f4447..9c2c3aee 100644 --- a/apps/backend/src/routes/webhooks.js +++ b/apps/backend/src/routes/webhooks.js @@ -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' diff --git a/apps/backend/src/services/admin-audit-service.js b/apps/backend/src/services/admin/admin-audit-service.js similarity index 93% rename from apps/backend/src/services/admin-audit-service.js rename to apps/backend/src/services/admin/admin-audit-service.js index c6d0fc21..71371f2f 100644 --- a/apps/backend/src/services/admin-audit-service.js +++ b/apps/backend/src/services/admin/admin-audit-service.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) { diff --git a/apps/backend/src/services/admin-auth-service.js b/apps/backend/src/services/admin/admin-auth-service.js similarity index 98% rename from apps/backend/src/services/admin-auth-service.js rename to apps/backend/src/services/admin/admin-auth-service.js index b537935f..43ef74e4 100644 --- a/apps/backend/src/services/admin-auth-service.js +++ b/apps/backend/src/services/admin/admin-auth-service.js @@ -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() diff --git a/apps/backend/src/services/admin-service.js b/apps/backend/src/services/admin/admin-service.js similarity index 83% rename from apps/backend/src/services/admin-service.js rename to apps/backend/src/services/admin/admin-service.js index d3b2e707..8c30597e 100644 --- a/apps/backend/src/services/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -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 : {} } diff --git a/apps/backend/src/services/claim-service.js b/apps/backend/src/services/claim/claim-service.js similarity index 72% rename from apps/backend/src/services/claim-service.js rename to apps/backend/src/services/claim/claim-service.js index 7834233b..517e02fb 100644 --- a/apps/backend/src/services/claim-service.js +++ b/apps/backend/src/services/claim/claim-service.js @@ -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() diff --git a/apps/backend/src/services/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js similarity index 95% rename from apps/backend/src/services/claim-session-service.js rename to apps/backend/src/services/claim/claim-session-service.js index c33d85e9..72edf7ee 100644 --- a/apps/backend/src/services/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -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']) diff --git a/apps/backend/src/services/cdk-service.js b/apps/backend/src/services/order/cdk-service.js similarity index 80% rename from apps/backend/src/services/cdk-service.js rename to apps/backend/src/services/order/cdk-service.js index 209cd270..05ae33a9 100644 --- a/apps/backend/src/services/cdk-service.js +++ b/apps/backend/src/services/order/cdk-service.js @@ -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) { diff --git a/apps/backend/src/services/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js similarity index 92% rename from apps/backend/src/services/delivery-task-service.js rename to apps/backend/src/services/order/delivery-task-service.js index bda2842b..9b600c77 100644 --- a/apps/backend/src/services/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -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) diff --git a/apps/backend/src/services/order/order-repair-service.js b/apps/backend/src/services/order/order-repair-service.js new file mode 100644 index 00000000..97c77629 --- /dev/null +++ b/apps/backend/src/services/order/order-repair-service.js @@ -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 '' +} diff --git a/apps/backend/src/services/order-service.js b/apps/backend/src/services/order/order-service.js similarity index 78% rename from apps/backend/src/services/order-service.js rename to apps/backend/src/services/order/order-service.js index 9654980b..43f7e6f3 100644 --- a/apps/backend/src/services/order-service.js +++ b/apps/backend/src/services/order/order-service.js @@ -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), diff --git a/apps/backend/src/services/webhook-service.js b/apps/backend/src/services/order/webhook-service.js similarity index 97% rename from apps/backend/src/services/webhook-service.js rename to apps/backend/src/services/order/webhook-service.js index 8153f78b..086cddc7 100644 --- a/apps/backend/src/services/webhook-service.js +++ b/apps/backend/src/services/order/webhook-service.js @@ -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) diff --git a/apps/backend/src/services/platforms/agiso/README.md b/apps/backend/src/services/platforms/agiso/README.md new file mode 100644 index 00000000..8ad79c0a --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/README.md @@ -0,0 +1,9 @@ +## Agiso Platform Services + +Keep Agiso-wide shared helpers in this directory. + +Per-platform integrations live in subdirectories: + +- `xianyu/` +- `pdd/` +- `taobao/` diff --git a/apps/backend/src/services/platforms/agiso/pdd/README.md b/apps/backend/src/services/platforms/agiso/pdd/README.md new file mode 100644 index 00000000..fc40152b --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/pdd/README.md @@ -0,0 +1,3 @@ +## PDD + +Put all Agiso PDD-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/shop-config-service.js b/apps/backend/src/services/platforms/agiso/shop-config-service.js new file mode 100644 index 00000000..ecf97d19 --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/shop-config-service.js @@ -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]' +} diff --git a/apps/backend/src/services/platforms/agiso/taobao/README.md b/apps/backend/src/services/platforms/agiso/taobao/README.md new file mode 100644 index 00000000..c685793c --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/taobao/README.md @@ -0,0 +1,3 @@ +## Taobao + +Put all Agiso Taobao-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/xianyu/README.md b/apps/backend/src/services/platforms/agiso/xianyu/README.md new file mode 100644 index 00000000..b0a28579 --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/xianyu/README.md @@ -0,0 +1,3 @@ +## Xianyu + +Put all Agiso Xianyu-specific service integrations here. diff --git a/apps/backend/src/services/message-service.js b/apps/backend/src/services/platforms/agiso/xianyu/message-service.js similarity index 80% rename from apps/backend/src/services/message-service.js rename to apps/backend/src/services/platforms/agiso/xianyu/message-service.js index 6303060f..6193be92 100644 --- a/apps/backend/src/services/message-service.js +++ b/apps/backend/src/services/platforms/agiso/xianyu/message-service.js @@ -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]' +} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.js b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.js new file mode 100644 index 00000000..23793afd --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.js @@ -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]' +} diff --git a/apps/backend/src/services/ocr.js b/apps/backend/src/services/session/ocr.js similarity index 98% rename from apps/backend/src/services/ocr.js rename to apps/backend/src/services/session/ocr.js index b31584d2..f1df7c98 100644 --- a/apps/backend/src/services/ocr.js +++ b/apps/backend/src/services/session/ocr.js @@ -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 diff --git a/apps/backend/src/services/session-login-shared.js b/apps/backend/src/services/session/session-login-shared.js similarity index 94% rename from apps/backend/src/services/session-login-shared.js rename to apps/backend/src/services/session/session-login-shared.js index 43ca7706..65973e5d 100644 --- a/apps/backend/src/services/session-login-shared.js +++ b/apps/backend/src/services/session/session-login-shared.js @@ -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) diff --git a/apps/backend/src/services/session-proof.js b/apps/backend/src/services/session/session-proof.js similarity index 99% rename from apps/backend/src/services/session-proof.js rename to apps/backend/src/services/session/session-proof.js index 099f8e59..a56daaf8 100644 --- a/apps/backend/src/services/session-proof.js +++ b/apps/backend/src/services/session/session-proof.js @@ -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 } diff --git a/apps/backend/src/services/session-qq.js b/apps/backend/src/services/session/session-qq.js similarity index 100% rename from apps/backend/src/services/session-qq.js rename to apps/backend/src/services/session/session-qq.js diff --git a/apps/backend/src/services/session-redeem.js b/apps/backend/src/services/session/session-redeem.js similarity index 100% rename from apps/backend/src/services/session-redeem.js rename to apps/backend/src/services/session/session-redeem.js diff --git a/apps/backend/src/services/session-wx.js b/apps/backend/src/services/session/session-wx.js similarity index 100% rename from apps/backend/src/services/session-wx.js rename to apps/backend/src/services/session/session-wx.js diff --git a/apps/backend/src/services/session.js b/apps/backend/src/services/session/session.js similarity index 99% rename from apps/backend/src/services/session.js rename to apps/backend/src/services/session/session.js index 9af98ef9..7a41b28a 100644 --- a/apps/backend/src/services/session.js +++ b/apps/backend/src/services/session/session.js @@ -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() diff --git a/apps/backend/src/utils/json.js b/apps/backend/src/utils/json.js new file mode 100644 index 00000000..38b6879c --- /dev/null +++ b/apps/backend/src/utils/json.js @@ -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]' +} diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 10aaaa2e..09541a56 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -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'), diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index 13412964..1963a7f0 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -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) { 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> }) { + return apiPost<{ + filePath: string + shops: AdminAgisoShopConfigItem[] + }>('/api/v1/admin/platform-config/agiso-shops', payload) +} + export function fetchAdminOrders(params?: Record) { return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index d81bc65a..7df6fdb1 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -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 diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 0bc878e6..e0627d11 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -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) diff --git a/apps/frontend/src/views/admin/AdminLayout.vue b/apps/frontend/src/views/admin/AdminLayout.vue index 35b314bd..cd4d7ed8 100644 --- a/apps/frontend/src/views/admin/AdminLayout.vue +++ b/apps/frontend/src/views/admin/AdminLayout.vue @@ -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: '审计' }) } diff --git a/apps/frontend/src/views/admin/AdminPlatformShopsView.vue b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue new file mode 100644 index 00000000..ad9cf8bf --- /dev/null +++ b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue @@ -0,0 +1,416 @@ + + +