From b6c493d5e7cdde2fb9f5a6703163e4bbe1e69a44 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 14 May 2026 17:29:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=B3=BB=E7=BB=9F-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/data/notification-config.json | 23 ++ .../src/routes/admin/platform-config.js | 60 +++++ .../platform-config/notification-service.js | 62 +++++ .../services/admin/platform-config/service.js | 6 + .../src/services/claim/session/actions.js | 10 + .../kuaishou-cloud-task-service.js | 28 +++ .../src/services/notification/bark-service.js | 135 ++++++++++ .../services/notification/config-service.js | 107 ++++++++ .../notification/domain-notifications.js | 235 ++++++++++++++++++ .../notification/notification-service.js | 96 +++++++ .../services/open-91/order-create-service.js | 9 + .../services/order/delivery-task-service.js | 34 ++- .../platforms/cloudtentacles/http-client.js | 24 +- apps/backend/src/types/admin-route-inputs.js | 8 + apps/backend/src/types/admin-write-inputs.js | 30 +++ apps/frontend/src/components.d.ts | 1 + .../AdminPlatformNotificationSection.vue | 171 +++++++++++++ .../composables/admin/platform-shops/types.ts | 9 +- .../useAdminNotificationPlatform.ts | 152 +++++++++++ .../src/services/admin/platform-config.ts | 24 ++ apps/frontend/src/types/admin.ts | 38 +++ .../views/admin/AdminPlatformShopsView.vue | 63 ++++- 22 files changed, 1314 insertions(+), 11 deletions(-) create mode 100644 apps/backend/data/notification-config.json create mode 100644 apps/backend/src/services/admin/platform-config/notification-service.js create mode 100644 apps/backend/src/services/notification/bark-service.js create mode 100644 apps/backend/src/services/notification/config-service.js create mode 100644 apps/backend/src/services/notification/domain-notifications.js create mode 100644 apps/backend/src/services/notification/notification-service.js create mode 100644 apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue create mode 100644 apps/frontend/src/composables/admin/platform-shops/useAdminNotificationPlatform.ts diff --git a/apps/backend/data/notification-config.json b/apps/backend/data/notification-config.json new file mode 100644 index 00000000..56bd5f6c --- /dev/null +++ b/apps/backend/data/notification-config.json @@ -0,0 +1,23 @@ +{ + "enabled": true, + "channels": { + "bark": { + "enabled": true, + "serverUrl": "https://api.day.app", + "recipients": [ + { + "id": "cdab629b-0480-4741-a4b5-4c11fb8fbc6b", + "name": "弋宸先生", + "deviceKey": "NaiQzbZkJTcaW23VWU9AXf", + "enabled": true + }, + { + "id": "82af4cb0-f6b2-4636-b1ca-29f482c49b30", + "name": "yml-测试1", + "deviceKey": "5EdwoMkrpuAfNLZN8aVrh5", + "enabled": true + } + ] + } + } +} diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index b92be0b3..10717353 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -11,6 +11,7 @@ import { getAdminCloudtentaclesAsset, getAdminCloudtentaclesBindUrl, getAdminCloudtentaclesCategories, + getAdminNotificationConfig, getAdminKuaishouCloudFulfillmentConfig, getAdminKuaishouEticketSourceConfig, getAdminCloudtentaclesKnapsack, @@ -29,10 +30,12 @@ import { retryAdminNinetyoneOrder, runAdminCloudtentaclesFullFlow, sendAdminCloudtentaclesSmsCode, + testAdminNotification, testAdminCloudtentaclesLogin, updateAdminKuaishouCloudFulfillmentConfig, updateAdminKuaishouEticketSourceConfig, updateAdminCloudtentaclesSourceConfig, + updateAdminNotificationConfig, updateAdminAgisoShopConfigs, updateAdminFulfillmentBindingConfigs, verifyAdminCloudtentaclesLoginCode, @@ -45,6 +48,8 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' /** @typedef {import('../../types/admin-route-inputs.js').AdminFulfillmentBindingLookupRouteBody} AdminFulfillmentBindingLookupRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminEntityRouteParams} AdminEntityRouteParams */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketSourceConfigRouteBody} AdminKuaishouEticketSourceConfigRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminNotificationConfigRouteBody} AdminNotificationConfigRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminNotificationTestRouteBody} AdminNotificationTestRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketDetailQueryRouteBody} AdminKuaishouEticketDetailQueryRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketShopInfoRouteBody} AdminKuaishouEticketShopInfoRouteBody */ @@ -94,6 +99,61 @@ router.post('/platform-config/agiso-shops', createJsonHandler( }, )) +router.get('/platform-config/notifications', createJsonHandler( + () => getAdminNotificationConfig(), + { + successMessage: 'ok', + errorMessage: '读取内部通知配置失败', + scope: '[admin/platform-config/notifications]', + }, +)) + +router.post('/platform-config/notifications', createJsonHandler( + (req) => updateAdminNotificationConfig(/** @type {AdminNotificationConfigRouteBody} */ (req.body)), + { + successMessage: '内部通知配置已保存', + errorMessage: '保存内部通知配置失败', + scope: '[admin/platform-config/notifications]', + audit: (_req, data) => { + const result = /** @type {{ filePath?: string, source?: { enabled?: boolean, channels?: { bark?: { recipients?: unknown[] } } } }} */ (data) + return { + action: 'platform_notification_config_updated', + targetType: 'platform_config', + targetId: 'notifications', + data: { + filePath: String(result.filePath || '').trim(), + enabled: Boolean(result.source?.enabled), + barkRecipientCount: Array.isArray(result.source?.channels?.bark?.recipients) + ? result.source.channels.bark.recipients.length + : 0, + }, + } + }, + }, +)) + +router.post('/platform-config/notifications/test', createJsonHandler( + (req) => testAdminNotification(/** @type {AdminNotificationTestRouteBody} */ (req.body)), + { + successMessage: '内部通知测试已执行', + errorMessage: '内部通知测试失败', + scope: '[admin/platform-config/notifications/test]', + audit: (_req, data) => { + const result = /** @type {{ successCount?: number, failedCount?: number, channel?: string }} */ (data) + return { + action: 'platform_notification_test_sent', + targetType: 'platform_config', + targetId: 'notifications', + data: { + channel: String(result.channel || '').trim(), + successCount: Number(result.successCount || 0), + failedCount: Number(result.failedCount || 0), + }, + } + }, + }, +)) + router.get('/platform-config/kuaishou-eticket-source', createJsonHandler( () => getAdminKuaishouEticketSourceConfig(), { diff --git a/apps/backend/src/services/admin/platform-config/notification-service.js b/apps/backend/src/services/admin/platform-config/notification-service.js new file mode 100644 index 00000000..6b05946b --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/notification-service.js @@ -0,0 +1,62 @@ +// @ts-check + +import { + getNotificationConfig, + getNotificationConfigFilePath, + saveNotificationConfig, +} from '../../notification/config-service.js' +import { sendInternalNotification } from '../../notification/notification-service.js' +import { maskSecret } from './mappers.js' + +/** @typedef {import('../../../types/admin-write-inputs.js').AdminNotificationConfigInput} AdminNotificationConfigInput */ +/** @typedef {import('../../../types/admin-write-inputs.js').AdminNotificationTestInput} AdminNotificationTestInput */ + +export function getAdminNotificationConfig() { + const config = getNotificationConfig() + + return { + filePath: getNotificationConfigFilePath(), + source: mapAdminNotificationConfig(config), + } +} + +/** @param {AdminNotificationConfigInput} [payload] */ +export function updateAdminNotificationConfig(payload = /** @type {AdminNotificationConfigInput} */ ({})) { + const saved = saveNotificationConfig(payload) + + return { + filePath: getNotificationConfigFilePath(), + source: mapAdminNotificationConfig(saved), + } +} + +/** @param {AdminNotificationTestInput} [payload] */ +export async function testAdminNotification(payload = /** @type {AdminNotificationTestInput} */ ({})) { + return sendInternalNotification({ + title: String(payload.title || '订单系统测试通知').trim() || '订单系统测试通知', + body: String(payload.body || '这是一条 Bark 内部通知测试。').trim() || '这是一条 Bark 内部通知测试。', + category: 'test', + url: String(payload.url || '').trim(), + }) +} + +function mapAdminNotificationConfig(config = {}) { + const bark = config.channels?.bark || {} + + return { + enabled: config.enabled !== false, + channels: { + bark: { + enabled: bark.enabled !== false, + serverUrl: String(bark.serverUrl || 'https://api.day.app').trim(), + recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item) => ({ + id: String(item.id || '').trim(), + name: String(item.name || '').trim(), + deviceKey: String(item.deviceKey || '').trim(), + deviceKeyMasked: maskSecret(item.deviceKey), + enabled: item.enabled !== false, + })), + }, + }, + } +} diff --git a/apps/backend/src/services/admin/platform-config/service.js b/apps/backend/src/services/admin/platform-config/service.js index 65dc474b..51c7d037 100644 --- a/apps/backend/src/services/admin/platform-config/service.js +++ b/apps/backend/src/services/admin/platform-config/service.js @@ -49,3 +49,9 @@ export { getAdminKuaishouCloudFulfillmentConfig, updateAdminKuaishouCloudFulfillmentConfig, } from './kuaishou-cloud-fulfillment-service.js' + +export { + getAdminNotificationConfig, + updateAdminNotificationConfig, + testAdminNotification, +} from './notification-service.js' diff --git a/apps/backend/src/services/claim/session/actions.js b/apps/backend/src/services/claim/session/actions.js index 86fc848b..cd62e3db 100644 --- a/apps/backend/src/services/claim/session/actions.js +++ b/apps/backend/src/services/claim/session/actions.js @@ -12,6 +12,7 @@ import { } from '../../../repositories/inventory-repo.js' import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js' import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js' +import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { getClaimContext, getClaimContextByTaskId } from './context.js' @@ -297,6 +298,15 @@ async function finalizeClaimTaskRedeem(context) { updated_at: now, }) + if (['retry_pending', 'waiting_inventory', 'manual_review'].includes(String(failureState.taskStatus || '').trim())) { + await notifyClaimRedeemNeedsAttention({ + task: failedTask, + order: context.order, + status: failureState.taskStatus, + errorMessage: failureState.lastError, + }) + } + throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), { task: failedTask, }) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js index cb8c9b30..6c17b7d5 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js @@ -30,6 +30,11 @@ import { getKuaishouEticketSourceConfig, resolveKuaishouEticketShopConfig, } from '../platforms/kuaishou-eticket/source-config-service.js' +import { + notifyKuaishouCloudAssetNotEnough, + notifyKuaishouCloudBindUrlRefreshFailed, + notifyKuaishouCloudConsumeFailed, +} from '../notification/domain-notifications.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' @@ -284,6 +289,13 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) { const targetPrice = Number(targetSku.price || 0) || 0 const requiredAsset = targetPrice + flowWithResolvedBinding.purchase.minAssetReserve if (assetBefore < requiredAsset) { + await notifyKuaishouCloudAssetNotEnough({ + task, + flow: flowWithResolvedBinding, + assetBefore, + requiredAsset, + skuName: targetSku.name, + }) throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, { statusCode: 409, errorCode: 'kuaishou_cloud_asset_not_enough', @@ -652,6 +664,11 @@ async function markKuaishouCloudBindUrlRefreshFailed(task, { actor, }, now) + await notifyKuaishouCloudBindUrlRefreshFailed({ + task: updatedTask, + errorMessage, + }) + return updatedTask } @@ -998,6 +1015,17 @@ export async function returnKuaishouCloudFulfillmentTask(task, options = {}) { now, ) + if (consumeStatus !== 'success') { + await notifyKuaishouCloudConsumeFailed({ + task: updatedTask, + order, + ticketCodeMasked: maskCode(ticketCode), + shopId, + shopName, + errorMessage: consumeErrorMessage, + }) + } + return { task: updatedTask, flow: normalizeKuaishouCloudFlow(parseTaskContext(updatedTask).kuaishouCloudFulfillment), diff --git a/apps/backend/src/services/notification/bark-service.js b/apps/backend/src/services/notification/bark-service.js new file mode 100644 index 00000000..94cfbca9 --- /dev/null +++ b/apps/backend/src/services/notification/bark-service.js @@ -0,0 +1,135 @@ +// @ts-check + +import { createHttpError } from '../../utils/http.js' + +const DEFAULT_TIMEOUT_MS = 10000 + +/** + * @typedef {{ + * serverUrl: string + * recipient: { + * id?: string + * name?: string + * deviceKey?: string + * } + * title: string + * body?: string + * group?: string + * url?: string + * }} BarkSendInput + */ + +/** @param {BarkSendInput} input */ +export async function sendBarkNotification(input) { + const serverUrl = String(input.serverUrl || 'https://api.day.app').trim().replace(/\/+$/, '') + const deviceKey = String(input.recipient?.deviceKey || '').trim() + const title = String(input.title || '').trim() + const body = String(input.body || '').trim() + + if (!deviceKey) { + throw createHttpError('Bark 接收人缺少 deviceKey', { + statusCode: 400, + errorCode: 'bark_device_key_required', + }) + } + + if (!title && !body) { + throw createHttpError('Bark 通知标题或内容不能为空', { + statusCode: 400, + errorCode: 'bark_message_required', + }) + } + + const endpoint = buildBarkEndpoint(serverUrl, deviceKey, title || body, body) + const params = new URLSearchParams() + params.set('group', String(input.group || '订单系统').trim() || '订单系统') + if (input.url) { + params.set('url', String(input.url).trim()) + } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + + try { + const response = await fetch(`${endpoint}?${params.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json, text/plain, */*', + }, + signal: controller.signal, + }) + const responseText = await response.text() + const parsed = parseJsonResponse(responseText) + + if (!response.ok || isBarkFailure(parsed)) { + throw createHttpError(resolveBarkErrorMessage(parsed, responseText, response.status), { + statusCode: response.ok ? 502 : response.status, + errorCode: 'bark_send_failed', + context: { + status: response.status, + response: parsed || responseText, + }, + }) + } + + return { + ok: true, + status: response.status, + response: parsed || responseText, + } + } catch (error) { + if (error?.name === 'AbortError') { + throw createHttpError('Bark 通知发送超时', { + statusCode: 503, + errorCode: 'bark_send_timeout', + cause: error, + }) + } + throw error + } finally { + clearTimeout(timer) + } +} + +function buildBarkEndpoint(serverUrl, deviceKey, title, body) { + const segments = [ + serverUrl, + encodeURIComponent(deviceKey), + encodeURIComponent(title), + ] + + if (body) { + segments.push(encodeURIComponent(body)) + } + + return segments.join('/') +} + +function parseJsonResponse(text) { + if (!text) { + return null + } + + try { + return JSON.parse(text) + } catch { + return null + } +} + +function isBarkFailure(parsed) { + if (!parsed || typeof parsed !== 'object') { + return false + } + + const code = Number(parsed.code ?? parsed.status ?? 0) + return Number.isFinite(code) && code !== 0 && code !== 200 +} + +function resolveBarkErrorMessage(parsed, responseText, status) { + if (parsed && typeof parsed === 'object') { + return String(parsed.message || parsed.msg || parsed.error || '').trim() || `Bark 通知发送失败,HTTP ${status}` + } + + return String(responseText || '').trim() || `Bark 通知发送失败,HTTP ${status}` +} diff --git a/apps/backend/src/services/notification/config-service.js b/apps/backend/src/services/notification/config-service.js new file mode 100644 index 00000000..e6e308ea --- /dev/null +++ b/apps/backend/src/services/notification/config-service.js @@ -0,0 +1,107 @@ +// @ts-check + +import fs from 'node:fs' +import path from 'node:path' + +import { PROJECT_ROOT } from '../../config/runtime.js' + +const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notification-config.json') +const DEFAULT_BARK_SERVER_URL = 'https://api.day.app' + +export function getNotificationConfigFilePath() { + return NOTIFICATION_CONFIG_FILE_PATH +} + +export function getNotificationConfig() { + return loadNotificationConfigFromFile() +} + +export function saveNotificationConfig(rawValue) { + const normalized = normalizeNotificationConfig(rawValue) + fs.mkdirSync(path.dirname(NOTIFICATION_CONFIG_FILE_PATH), { recursive: true }) + fs.writeFileSync(NOTIFICATION_CONFIG_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') + return normalized +} + +export function listEnabledBarkRecipients(config = getNotificationConfig()) { + if (config.enabled === false || config.channels?.bark?.enabled === false) { + return [] + } + + return (Array.isArray(config.channels?.bark?.recipients) ? config.channels.bark.recipients : []) + .filter((item) => item.enabled !== false && String(item.deviceKey || '').trim()) +} + +function loadNotificationConfigFromFile() { + if (!fs.existsSync(NOTIFICATION_CONFIG_FILE_PATH)) { + return createDefaultNotificationConfig() + } + + try { + const rawText = fs.readFileSync(NOTIFICATION_CONFIG_FILE_PATH, 'utf8') + return normalizeNotificationConfig(JSON.parse(rawText)) + } catch { + return createDefaultNotificationConfig() + } +} + +function normalizeNotificationConfig(rawValue) { + const source = isPlainObject(rawValue) ? rawValue : {} + const bark = isPlainObject(source.channels?.bark) ? source.channels.bark : {} + + return { + enabled: typeof source.enabled === 'boolean' ? source.enabled : true, + channels: { + bark: { + enabled: typeof bark.enabled === 'boolean' ? bark.enabled : true, + serverUrl: normalizeBarkServerUrl(bark.serverUrl), + recipients: (Array.isArray(bark.recipients) ? bark.recipients : []) + .map((item) => normalizeBarkRecipient(item)) + .filter(Boolean), + }, + }, + } +} + +function normalizeBarkRecipient(rawValue) { + if (!isPlainObject(rawValue)) { + return null + } + + const name = String(rawValue.name || '').trim() + const deviceKey = String(rawValue.deviceKey || '').trim() + const id = String(rawValue.id || deviceKey || name || '').trim() + + if (!name && !deviceKey) { + return null + } + + return { + id, + name, + deviceKey, + enabled: typeof rawValue.enabled === 'boolean' ? rawValue.enabled : true, + } +} + +function normalizeBarkServerUrl(value) { + const normalized = String(value || DEFAULT_BARK_SERVER_URL).trim() || DEFAULT_BARK_SERVER_URL + return normalized.replace(/\/+$/, '') +} + +function createDefaultNotificationConfig() { + return { + enabled: true, + channels: { + bark: { + enabled: true, + serverUrl: DEFAULT_BARK_SERVER_URL, + recipients: [], + }, + }, + } +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/notification/domain-notifications.js b/apps/backend/src/services/notification/domain-notifications.js new file mode 100644 index 00000000..8e2d0bbe --- /dev/null +++ b/apps/backend/src/services/notification/domain-notifications.js @@ -0,0 +1,235 @@ +// @ts-check + +import { logWarn } from '../../utils/logger.js' +import { sendInternalNotification } from './notification-service.js' + +const DEFAULT_COOLDOWN_MS = 10 * 60 * 1000 +const notificationCooldownMap = new Map() + +/** + * @typedef {{ + * title: string + * body?: string + * category?: string + * url?: string + * cooldownKey?: string + * cooldownMs?: number + * }} InternalNotificationPayload + */ + +/** @param {InternalNotificationPayload} payload */ +export async function notifyInternalSafely(payload) { + const cooldownKey = String(payload.cooldownKey || '').trim() + if (cooldownKey && isNotificationCoolingDown(cooldownKey, payload.cooldownMs)) { + return { + skipped: true, + reason: 'cooldown', + } + } + + try { + const result = await sendInternalNotification({ + title: payload.title, + body: payload.body, + category: payload.category, + url: payload.url, + }) + markNotificationCooldown(cooldownKey) + return result + } catch (error) { + logWarn('[notification/internal]', '内部通知发送失败,已跳过', { + title: payload.title, + category: payload.category, + error, + }) + markNotificationCooldown(cooldownKey) + return { + skipped: true, + reason: 'send_failed', + } + } +} + +export function notifyKuaishouCloudAssetNotEnough({ + task = {}, + flow = {}, + assetBefore = 0, + requiredAsset = 0, + skuName = '', +} = {}) { + const taskRecord = toRecord(task) + const flowRecord = toRecord(flow) + const binding = toRecord(flowRecord.binding) + + return notifyInternalSafely({ + title: '快手 Cloud 余额不足', + body: [ + formatTaskLine(taskRecord), + `当前余额:${Number(assetBefore || 0)}`, + `最低需要:${Number(requiredAsset || 0)}`, + `商品:${String(skuName || binding.skuName || flowRecord.internalSkuName || '').trim() || '-'}`, + ].join('\n'), + category: 'kuaishou_cloud_asset_not_enough', + cooldownKey: [ + 'kuaishou_cloud_asset_not_enough', + taskRecord.id || '', + binding.skuId || '', + ].join(':'), + }) +} + +export function notifyCloudtentaclesAuthExpired({ + pathname = '', + errorCode = '', + message = '', +} = {}) { + return notifyInternalSafely({ + title: 'cloudtentacles 登录已过期', + body: [ + `接口:${String(pathname || '').trim() || '-'}`, + `错误码:${String(errorCode || '').trim() || '-'}`, + `原因:${String(message || '').trim() || '登录态已失效,请到后台重新登录'}`, + ].join('\n'), + category: 'cloudtentacles_auth_expired', + cooldownKey: ['cloudtentacles_auth_expired', pathname, errorCode].join(':'), + }) +} + +export function notifyOpen91PendingConfig({ + order = {}, + orderNo = '', + productNo = '', + buyNum = 0, + reason = '未命中履约配置', +} = {}) { + const orderRecord = toRecord(order) + + return notifyInternalSafely({ + title: '91卡券订单待补配置', + body: [ + `订单:${String(orderNo || orderRecord.platform_order_id || '').trim() || '-'}`, + `商品:${String(productNo || '').trim() || '-'}`, + `数量:${Number(buyNum || 0) || 0}`, + `原因:${String(reason || '').trim() || '未命中履约配置'}`, + ].join('\n'), + category: 'open91_pending_config', + cooldownKey: ['open91_pending_config', orderNo || orderRecord.platform_order_id || '', productNo].join(':'), + }) +} + +export function notifyKuaishouCloudBindUrlRefreshFailed({ + task = {}, + errorMessage = '', +} = {}) { + const taskRecord = toRecord(task) + + return notifyInternalSafely({ + title: '快手 Cloud 链接刷新失败', + body: [ + formatTaskLine(taskRecord), + `原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`, + '旧虚拟号已退还,新绑定链接未准备成功。', + ].join('\n'), + category: 'kuaishou_cloud_bind_url_refresh_failed', + cooldownKey: ['kuaishou_cloud_bind_url_refresh_failed', taskRecord.id || ''].join(':'), + }) +} + +export function notifyKuaishouCloudConsumeFailed({ + task = {}, + order = {}, + ticketCodeMasked = '', + shopId = '', + shopName = '', + errorMessage = '', +} = {}) { + const taskRecord = toRecord(task) + const orderRecord = toRecord(order) + + return notifyInternalSafely({ + title: '快手核销失败,需人工处理', + body: [ + formatTaskLine(taskRecord), + `订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`, + `券码:${String(ticketCodeMasked || '').trim() || '-'}`, + `店铺:${String(shopName || shopId || '').trim() || '-'}`, + `原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`, + ].join('\n'), + category: 'kuaishou_cloud_consume_failed', + cooldownKey: ['kuaishou_cloud_consume_failed', taskRecord.id || ''].join(':'), + }) +} + +export function notifyTaskAutoManualReview({ + task = {}, + reason = '', + source = '', +} = {}) { + const taskRecord = toRecord(task) + + return notifyInternalSafely({ + title: '任务已自动转人工处理', + body: [ + formatTaskLine(taskRecord), + `来源:${String(source || '').trim() || '-'}`, + `原因:${String(reason || taskRecord.last_error || '').trim() || '-'}`, + ].join('\n'), + category: 'task_auto_manual_review', + cooldownKey: ['task_auto_manual_review', taskRecord.id || '', source].join(':'), + }) +} + +export function notifyClaimRedeemNeedsAttention({ + task = {}, + order = {}, + status = '', + errorMessage = '', +} = {}) { + const taskRecord = toRecord(task) + const orderRecord = toRecord(order) + + return notifyInternalSafely({ + title: status === 'waiting_inventory' ? '库存不足,任务等待补货' : '兑换失败,任务需关注', + body: [ + formatTaskLine(taskRecord), + `订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`, + `状态:${String(status || taskRecord.task_status || '').trim() || '-'}`, + `原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`, + ].join('\n'), + category: status === 'waiting_inventory' ? 'task_waiting_inventory' : 'claim_redeem_failed', + cooldownKey: ['claim_redeem_needs_attention', taskRecord.id || '', status].join(':'), + }) +} + +function formatTaskLine(task) { + const taskRecord = toRecord(task) + return [ + `任务:${String(taskRecord.task_no || taskRecord.id || '').trim() || '-'}`, + `平台订单:${String(taskRecord.platform_order_id || '').trim() || '-'}`, + ].join(' / ') +} + +function toRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value) + ? /** @type {Record} */ (value) + : {} +} + +function isNotificationCoolingDown(cooldownKey, cooldownMs = DEFAULT_COOLDOWN_MS) { + const key = String(cooldownKey || '').trim() + if (!key) { + return false + } + + const lastSentAt = Number(notificationCooldownMap.get(key) || 0) + return Date.now() - lastSentAt < Number(cooldownMs || DEFAULT_COOLDOWN_MS) +} + +function markNotificationCooldown(cooldownKey) { + const key = String(cooldownKey || '').trim() + if (!key) { + return + } + + notificationCooldownMap.set(key, Date.now()) +} diff --git a/apps/backend/src/services/notification/notification-service.js b/apps/backend/src/services/notification/notification-service.js new file mode 100644 index 00000000..c593dbce --- /dev/null +++ b/apps/backend/src/services/notification/notification-service.js @@ -0,0 +1,96 @@ +// @ts-check + +import { logWarn } from '../../utils/logger.js' +import { getNotificationConfig, listEnabledBarkRecipients } from './config-service.js' +import { sendBarkNotification } from './bark-service.js' + +/** + * @typedef {{ + * title?: string + * body?: string + * category?: string + * url?: string + * }} NotificationInput + */ + +/** @param {NotificationInput} input */ +export async function sendInternalNotification(input = {}) { + const config = getNotificationConfig() + const bark = /** @type {{ serverUrl?: string }} */ (config.channels?.bark || {}) + const recipients = listEnabledBarkRecipients(config) + const title = String(input.title || '订单系统通知').trim() || '订单系统通知' + const body = String(input.body || '').trim() + const category = String(input.category || 'system').trim() || 'system' + + if (config.enabled === false) { + return { + enabled: false, + channel: 'bark', + successCount: 0, + failedCount: 0, + skippedCount: recipients.length, + results: [], + } + } + + const results = await Promise.all(recipients.map(async (recipient) => { + try { + const result = await sendBarkNotification({ + serverUrl: bark.serverUrl, + recipient, + title, + body, + group: `订单系统/${category}`, + url: input.url, + }) + return mapNotificationResult(recipient, true, '', result.status, result.response) + } catch (error) { + logWarn('[notification/bark]', 'Bark 内部通知发送失败', { + recipientId: recipient.id, + recipientName: recipient.name, + error, + }) + return mapNotificationResult( + recipient, + false, + error instanceof Error ? error.message : 'Bark 内部通知发送失败', + Number(error?.statusCode || 0) || 0, + error?.context || null, + ) + } + })) + + return { + enabled: true, + channel: 'bark', + successCount: results.filter((item) => item.ok).length, + failedCount: results.filter((item) => !item.ok).length, + skippedCount: 0, + results, + } +} + +function mapNotificationResult(recipient, ok, errorMessage, status, response) { + return { + recipientId: String(recipient.id || '').trim(), + recipientName: String(recipient.name || '').trim(), + recipientKeyMasked: maskDeviceKey(recipient.deviceKey), + ok, + status, + errorMessage, + response, + } +} + +function maskDeviceKey(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)}` +} diff --git a/apps/backend/src/services/open-91/order-create-service.js b/apps/backend/src/services/open-91/order-create-service.js index f977a292..7b7cc553 100644 --- a/apps/backend/src/services/open-91/order-create-service.js +++ b/apps/backend/src/services/open-91/order-create-service.js @@ -5,6 +5,7 @@ import { buildOpen91SourceEvent, upsertOpen91PendingOrder, } from '../platforms/ninetyone/order-service.js' +import { notifyOpen91PendingConfig } from '../notification/domain-notifications.js' import { assertOpen91Config, assertOpen91CreatePayload, @@ -64,6 +65,14 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) { orderId: pending.order?.id || null, }) + await notifyOpen91PendingConfig({ + order: pending.order, + orderNo: normalized.orderNo, + productNo: normalized.productNo, + buyNum: normalized.buyNum, + reason: result.ignoreReason || '未命中履约配置', + }) + return buildOpen91SuccessResponse({ orderNo: normalized.orderNo, outTradeNo: buildOpen91OutTradeNo(pending.order), diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 6b5c63f7..dba5df39 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -5,6 +5,7 @@ import { } from '../../repositories/fulfillment-profile-repo.js' import { reserveInventoryForTask } from './inventory-service.js' import { createTaskClaimToken } from '../claim/claim-service.js' +import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' @@ -227,21 +228,35 @@ async function preparePaidTask(task) { } if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { - return updateTask(task.id, { + const lastError = task.last_error || '当前任务需要人工履约处理' + const updatedTask = await updateTask(task.id, { task_status: 'manual_review', inventory_status: 'not_required', user_action_status: 'not_required', - last_error: task.last_error || '当前任务需要人工履约处理', + last_error: lastError, updated_at: now, }) + await notifyTaskAutoManualReview({ + task: updatedTask || task, + reason: lastError, + source: 'manual_dispatch_profile', + }) + return updatedTask } if (!inventorySkuCode) { - return updateTask(task.id, { + const lastError = '未匹配到 SKU,无法为任务分配库存凭据' + const updatedTask = await updateTask(task.id, { task_status: 'manual_review', - last_error: '未匹配到 SKU,无法为任务分配库存凭据', + last_error: lastError, updated_at: now, }) + await notifyTaskAutoManualReview({ + task: updatedTask || task, + reason: lastError, + source: 'missing_sku', + }) + return updatedTask } if (task.primary_inventory_item_id && task.primary_claim_token_id) { @@ -253,11 +268,18 @@ async function preparePaidTask(task) { } if (!primaryRequirement?.credentialType) { - return updateTask(task.id, { + const lastError = '履约档案未配置库存要求,无法自动分配库存' + const updatedTask = await updateTask(task.id, { task_status: 'manual_review', - last_error: '履约档案未配置库存要求,无法自动分配库存', + last_error: lastError, updated_at: now, }) + await notifyTaskAutoManualReview({ + task: updatedTask || task, + reason: lastError, + source: 'missing_inventory_requirement', + }) + return updatedTask } const reserved = await reserveInventoryForTask({ diff --git a/apps/backend/src/services/platforms/cloudtentacles/http-client.js b/apps/backend/src/services/platforms/cloudtentacles/http-client.js index 7447ecf6..f4498f27 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/http-client.js +++ b/apps/backend/src/services/platforms/cloudtentacles/http-client.js @@ -5,6 +5,7 @@ import https from 'node:https' import { createHttpError } from '../../../utils/http.js' import { logInfo } from '../../../utils/logger.js' +import { notifyCloudtentaclesAuthExpired } from '../../notification/domain-notifications.js' import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './shared.js' export async function cloudtentaclesRequest(pathname, options = {}) { @@ -41,9 +42,21 @@ export async function cloudtentaclesRequest(pathname, options = {}) { } if (options.requireBusinessSuccess !== false && Number(payload?.code ?? 1) !== 0) { - throw createHttpError(String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败'), { - statusCode: Number(options.businessErrorStatusCode || 400), - errorCode: String(options.businessErrorCode || 'cloudtentacles_business_error'), + const message = String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败') + const statusCode = Number(options.businessErrorStatusCode || 400) + const errorCode = String(options.businessErrorCode || 'cloudtentacles_business_error') + + if (statusCode === 401 || isCloudtentaclesExpiredMessage(message)) { + await notifyCloudtentaclesAuthExpired({ + pathname, + errorCode, + message, + }) + } + + throw createHttpError(message, { + statusCode, + errorCode, }) } @@ -73,6 +86,11 @@ export async function cloudtentaclesRequest(pathname, options = {}) { } } +function isCloudtentaclesExpiredMessage(message) { + const normalized = String(message || '').trim().toLowerCase() + return normalized.includes('expired') || normalized.includes('过期') || normalized.includes('失效') +} + function inferContentType(body) { if (body == null) { return '' diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index 0e19662f..02e30e2b 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -34,6 +34,14 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminKuaishouEticketSourceConfigInput} AdminKuaishouEticketSourceConfigRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminNotificationConfigInput} AdminNotificationConfigRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminNotificationTestInput} AdminNotificationTestRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index aefdb359..cf563933 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -120,6 +120,36 @@ export {} * }} AdminKuaishouEticketSourceConfigInput */ +/** + * @typedef {{ + * id?: string + * name?: string + * deviceKey?: string + * enabled?: boolean + * }} AdminNotificationBarkRecipientInput + */ + +/** + * @typedef {{ + * enabled?: boolean + * channels?: { + * bark?: { + * enabled?: boolean + * serverUrl?: string + * recipients?: AdminNotificationBarkRecipientInput[] + * } + * } + * }} AdminNotificationConfigInput + */ + +/** + * @typedef {{ + * title?: string + * body?: string + * url?: string + * }} AdminNotificationTestInput + */ + /** * @typedef {{ * baseUrl?: string diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index 734c7b65..2ee3ab29 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -16,6 +16,7 @@ declare module 'vue' { AdminPlatformCloudtentaclesSection: typeof import('./components/admin/AdminPlatformCloudtentaclesSection.vue')['default'] AdminPlatformKuaishouEticketSection: typeof import('./components/admin/AdminPlatformKuaishouEticketSection.vue')['default'] AdminPlatformNinetyoneSection: typeof import('./components/admin/AdminPlatformNinetyoneSection.vue')['default'] + AdminPlatformNotificationSection: typeof import('./components/admin/AdminPlatformNotificationSection.vue')['default'] AdminResultCard: typeof import('./components/admin/AdminResultCard.vue')['default'] AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default'] ElButton: typeof import('element-plus/es')['ElButton'] diff --git a/apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue b/apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue new file mode 100644 index 00000000..e61c3580 --- /dev/null +++ b/apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue @@ -0,0 +1,171 @@ + + + diff --git a/apps/frontend/src/composables/admin/platform-shops/types.ts b/apps/frontend/src/composables/admin/platform-shops/types.ts index 0452fd45..2f4fbf27 100644 --- a/apps/frontend/src/composables/admin/platform-shops/types.ts +++ b/apps/frontend/src/composables/admin/platform-shops/types.ts @@ -1,4 +1,4 @@ -export type PlatformTab = 'agiso' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles' +export type PlatformTab = 'agiso' | 'notifications' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles' export type EditableDefaults = { messageTemplate: string @@ -23,3 +23,10 @@ export type EditableKuaishouEticketShop = { userAvatar: string enabled: boolean } + +export type EditableNotificationRecipient = { + id: string + name: string + deviceKey: string + enabled: boolean +} diff --git a/apps/frontend/src/composables/admin/platform-shops/useAdminNotificationPlatform.ts b/apps/frontend/src/composables/admin/platform-shops/useAdminNotificationPlatform.ts new file mode 100644 index 00000000..a049aa90 --- /dev/null +++ b/apps/frontend/src/composables/admin/platform-shops/useAdminNotificationPlatform.ts @@ -0,0 +1,152 @@ +import { computed, ref } from 'vue' + +import { showError, showSuccess } from '@/lib/feedback' +import { + saveAdminNotificationConfig, + testAdminNotification, +} from '@/services/admin' +import type { + AdminNotificationConfig, + AdminNotificationTestResult, +} from '@/types/admin' + +import type { EditableNotificationRecipient } from './types' + +export function useAdminNotificationPlatform() { + const notificationFilePath = ref('') + const notificationForm = ref({ + enabled: true, + barkEnabled: true, + barkServerUrl: 'https://api.day.app', + testTitle: '订单系统测试通知', + testBody: '这是一条 Bark 内部通知测试。', + testUrl: '', + }) + const notificationRecipients = ref([]) + const notificationSaving = ref(false) + const notificationTesting = ref(false) + const notificationResultError = ref('') + const notificationTestResult = ref(null) + + const notificationStats = computed(() => { + const enabledRecipients = notificationRecipients.value.filter((item) => item.enabled && item.deviceKey.trim()) + + return { + configuredRecipientCount: notificationRecipients.value.length, + enabledRecipientCount: enabledRecipients.length, + barkReady: notificationForm.value.enabled && notificationForm.value.barkEnabled && enabledRecipients.length > 0, + lastSuccessCount: notificationTestResult.value?.successCount || 0, + lastFailedCount: notificationTestResult.value?.failedCount || 0, + } + }) + + function hydrateNotificationConfig(data: { + filePath: string + source: AdminNotificationConfig + }) { + notificationFilePath.value = data.filePath + notificationForm.value.enabled = data.source.enabled !== false + notificationForm.value.barkEnabled = data.source.channels.bark.enabled !== false + notificationForm.value.barkServerUrl = data.source.channels.bark.serverUrl || 'https://api.day.app' + notificationRecipients.value = data.source.channels.bark.recipients.map((item) => ({ + id: item.id || crypto.randomUUID(), + name: item.name, + deviceKey: item.deviceKey, + enabled: item.enabled !== false, + })) + } + + function addNotificationRecipient() { + notificationRecipients.value.unshift({ + id: crypto.randomUUID(), + name: '', + deviceKey: '', + enabled: true, + }) + } + + function removeNotificationRecipient(id: string) { + notificationRecipients.value = notificationRecipients.value.filter((item) => item.id !== id) + } + + function buildNotificationConfigPayload(): AdminNotificationConfig { + return { + enabled: notificationForm.value.enabled, + channels: { + bark: { + enabled: notificationForm.value.barkEnabled, + serverUrl: notificationForm.value.barkServerUrl.trim() || 'https://api.day.app', + recipients: notificationRecipients.value.map((item) => ({ + id: item.id, + name: item.name.trim(), + deviceKey: item.deviceKey.trim(), + deviceKeyMasked: '', + enabled: item.enabled, + })), + }, + }, + } + } + + async function handleNotificationSaveConfig() { + notificationSaving.value = true + notificationResultError.value = '' + + try { + const response = await saveAdminNotificationConfig(buildNotificationConfigPayload()) + notificationFilePath.value = response.data.filePath + hydrateNotificationConfig(response.data) + showSuccess('内部通知配置已保存') + } catch (error) { + notificationResultError.value = error instanceof Error ? error.message : '内部通知配置保存失败' + showError(notificationResultError.value) + } finally { + notificationSaving.value = false + } + } + + async function handleNotificationTest() { + notificationTesting.value = true + notificationResultError.value = '' + notificationTestResult.value = null + + try { + const saved = await saveAdminNotificationConfig(buildNotificationConfigPayload()) + notificationFilePath.value = saved.data.filePath + hydrateNotificationConfig(saved.data) + + const response = await testAdminNotification({ + title: notificationForm.value.testTitle.trim(), + body: notificationForm.value.testBody.trim(), + url: notificationForm.value.testUrl.trim(), + }) + notificationTestResult.value = response.data + if (response.data.successCount > 0) { + showSuccess(`内部通知测试完成,成功 ${response.data.successCount} 个`) + } else { + showError('内部通知测试未成功发送,请检查 Bark 配置') + } + } catch (error) { + notificationResultError.value = error instanceof Error ? error.message : '内部通知测试失败' + showError(notificationResultError.value) + } finally { + notificationTesting.value = false + } + } + + return { + notificationFilePath, + notificationForm, + notificationRecipients, + notificationSaving, + notificationTesting, + notificationResultError, + notificationTestResult, + notificationStats, + hydrateNotificationConfig, + addNotificationRecipient, + removeNotificationRecipient, + handleNotificationSaveConfig, + handleNotificationTest, + } +} diff --git a/apps/frontend/src/services/admin/platform-config.ts b/apps/frontend/src/services/admin/platform-config.ts index a1f509f6..fef5c2c9 100644 --- a/apps/frontend/src/services/admin/platform-config.ts +++ b/apps/frontend/src/services/admin/platform-config.ts @@ -13,6 +13,8 @@ import type { AdminFulfillmentLookupResult, AdminNinetyoneOrderActionResult, AdminNinetyoneOrderListResult, + AdminNotificationConfig, + AdminNotificationTestResult, AdminKuaishouCloudFulfillmentConfig, AdminKuaishouEticketConsumeResult, AdminKuaishouEticketDetailResult, @@ -41,6 +43,28 @@ export function saveAdminAgisoShopConfigs(payload: { }>('/api/v1/admin/platform-config/agiso-shops', payload) } +export function fetchAdminNotificationConfig() { + return apiGet<{ + filePath: string + source: AdminNotificationConfig + }>('/api/v1/admin/platform-config/notifications') +} + +export function saveAdminNotificationConfig(payload: AdminNotificationConfig) { + return apiPost<{ + filePath: string + source: AdminNotificationConfig + }>('/api/v1/admin/platform-config/notifications', payload as unknown as Record) +} + +export function testAdminNotification(payload: { + title?: string + body?: string + url?: string +}) { + return apiPost('/api/v1/admin/platform-config/notifications/test', payload) +} + export function fetchAdminNinetyoneOrders(params: { page?: number pageSize?: number diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 0bb91865..e8dbf29c 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -164,6 +164,44 @@ export interface AdminKuaishouEticketConsumeResult { raw: Record } +export interface AdminNotificationBarkRecipient { + id: string + name: string + deviceKey: string + deviceKeyMasked: string + enabled: boolean +} + +export interface AdminNotificationConfig { + enabled: boolean + channels: { + bark: { + enabled: boolean + serverUrl: string + recipients: AdminNotificationBarkRecipient[] + } + } +} + +export interface AdminNotificationSendResult { + recipientId: string + recipientName: string + recipientKeyMasked: string + ok: boolean + status: number + errorMessage: string + response: unknown +} + +export interface AdminNotificationTestResult { + enabled: boolean + channel: string + successCount: number + failedCount: number + skippedCount: number + results: AdminNotificationSendResult[] +} + export interface AdminNinetyoneOrderItem { orderId: number orderNo: string diff --git a/apps/frontend/src/views/admin/AdminPlatformShopsView.vue b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue index c10468f9..f7c991e0 100644 --- a/apps/frontend/src/views/admin/AdminPlatformShopsView.vue +++ b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue @@ -4,16 +4,19 @@ import { onMounted, ref } from 'vue' import AdminPlatformAgisoSection from '@/components/admin/AdminPlatformAgisoSection.vue' import AdminPlatformCloudtentaclesSection from '@/components/admin/AdminPlatformCloudtentaclesSection.vue' import AdminPlatformNinetyoneSection from '@/components/admin/AdminPlatformNinetyoneSection.vue' +import AdminPlatformNotificationSection from '@/components/admin/AdminPlatformNotificationSection.vue' import AdminPlatformKuaishouEticketSection from '@/components/admin/AdminPlatformKuaishouEticketSection.vue' import { useAdminAgisoPlatform } from '@/composables/admin/platform-shops/useAdminAgisoPlatform' import { useAdminCloudtentaclesPlatform } from '@/composables/admin/platform-shops/useAdminCloudtentaclesPlatform' import { useAdminNinetyonePlatform } from '@/composables/admin/platform-shops/useAdminNinetyonePlatform' +import { useAdminNotificationPlatform } from '@/composables/admin/platform-shops/useAdminNotificationPlatform' import { useAdminKuaishouEticketPlatform } from '@/composables/admin/platform-shops/useAdminKuaishouEticketPlatform' import type { PlatformTab } from '@/composables/admin/platform-shops/types' import { fetchAdminAgisoShopConfigs, fetchAdminCloudtentaclesSourceConfig, fetchAdminKuaishouEticketSourceConfig, + fetchAdminNotificationConfig, } from '@/services/admin' import { hasAdminRole } from '@/utils/admin-auth' import '@/styles/admin-platform-shops.css' @@ -55,6 +58,22 @@ const { handleNinetyoneFailOrder, } = useAdminNinetyonePlatform() +const { + notificationFilePath, + notificationForm, + notificationRecipients, + notificationSaving, + notificationTesting, + notificationResultError, + notificationTestResult, + notificationStats, + hydrateNotificationConfig, + addNotificationRecipient, + removeNotificationRecipient, + handleNotificationSaveConfig, + handleNotificationTest, +} = useAdminNotificationPlatform() + const { kuaishouEticketFilePath, kuaishouEticketShops, @@ -132,13 +151,15 @@ async function loadConfigs() { errorMessage.value = '' try { - const [agisoResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([ + const [agisoResponse, notificationResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([ fetchAdminAgisoShopConfigs(), + fetchAdminNotificationConfig(), fetchAdminKuaishouEticketSourceConfig(), fetchAdminCloudtentaclesSourceConfig(), ]) hydrateAgisoConfig(agisoResponse.data) + hydrateNotificationConfig(notificationResponse.data) hydrateKuaishouEticketConfig(kuaishouEticketResponse.data) hydrateCloudtentaclesConfig(cloudtentaclesResponse.data) await loadNinetyoneOrders(1) @@ -198,6 +219,23 @@ onMounted(loadConfigs) + + +