通知系统-1
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getAdminCloudtentaclesAsset,
|
getAdminCloudtentaclesAsset,
|
||||||
getAdminCloudtentaclesBindUrl,
|
getAdminCloudtentaclesBindUrl,
|
||||||
getAdminCloudtentaclesCategories,
|
getAdminCloudtentaclesCategories,
|
||||||
|
getAdminNotificationConfig,
|
||||||
getAdminKuaishouCloudFulfillmentConfig,
|
getAdminKuaishouCloudFulfillmentConfig,
|
||||||
getAdminKuaishouEticketSourceConfig,
|
getAdminKuaishouEticketSourceConfig,
|
||||||
getAdminCloudtentaclesKnapsack,
|
getAdminCloudtentaclesKnapsack,
|
||||||
@@ -29,10 +30,12 @@ import {
|
|||||||
retryAdminNinetyoneOrder,
|
retryAdminNinetyoneOrder,
|
||||||
runAdminCloudtentaclesFullFlow,
|
runAdminCloudtentaclesFullFlow,
|
||||||
sendAdminCloudtentaclesSmsCode,
|
sendAdminCloudtentaclesSmsCode,
|
||||||
|
testAdminNotification,
|
||||||
testAdminCloudtentaclesLogin,
|
testAdminCloudtentaclesLogin,
|
||||||
updateAdminKuaishouCloudFulfillmentConfig,
|
updateAdminKuaishouCloudFulfillmentConfig,
|
||||||
updateAdminKuaishouEticketSourceConfig,
|
updateAdminKuaishouEticketSourceConfig,
|
||||||
updateAdminCloudtentaclesSourceConfig,
|
updateAdminCloudtentaclesSourceConfig,
|
||||||
|
updateAdminNotificationConfig,
|
||||||
updateAdminAgisoShopConfigs,
|
updateAdminAgisoShopConfigs,
|
||||||
updateAdminFulfillmentBindingConfigs,
|
updateAdminFulfillmentBindingConfigs,
|
||||||
verifyAdminCloudtentaclesLoginCode,
|
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').AdminFulfillmentBindingLookupRouteBody} AdminFulfillmentBindingLookupRouteBody */
|
||||||
/** @typedef {import('../../types/admin-route-inputs.js').AdminEntityRouteParams} AdminEntityRouteParams */
|
/** @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').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').AdminKuaishouEticketDetailQueryRouteBody} AdminKuaishouEticketDetailQueryRouteBody */
|
||||||
/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */
|
/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */
|
||||||
/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketShopInfoRouteBody} AdminKuaishouEticketShopInfoRouteBody */
|
/** @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(
|
router.get('/platform-config/kuaishou-eticket-source', createJsonHandler(
|
||||||
() => getAdminKuaishouEticketSourceConfig(),
|
() => getAdminKuaishouEticketSourceConfig(),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,3 +49,9 @@ export {
|
|||||||
getAdminKuaishouCloudFulfillmentConfig,
|
getAdminKuaishouCloudFulfillmentConfig,
|
||||||
updateAdminKuaishouCloudFulfillmentConfig,
|
updateAdminKuaishouCloudFulfillmentConfig,
|
||||||
} from './kuaishou-cloud-fulfillment-service.js'
|
} from './kuaishou-cloud-fulfillment-service.js'
|
||||||
|
|
||||||
|
export {
|
||||||
|
getAdminNotificationConfig,
|
||||||
|
updateAdminNotificationConfig,
|
||||||
|
testAdminNotification,
|
||||||
|
} from './notification-service.js'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '../../../repositories/inventory-repo.js'
|
} from '../../../repositories/inventory-repo.js'
|
||||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
|
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||||
import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js'
|
import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js'
|
||||||
|
import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js'
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
import { nowIso } from '../../../utils/time.js'
|
import { nowIso } from '../../../utils/time.js'
|
||||||
import { getClaimContext, getClaimContextByTaskId } from './context.js'
|
import { getClaimContext, getClaimContextByTaskId } from './context.js'
|
||||||
@@ -297,6 +298,15 @@ async function finalizeClaimTaskRedeem(context) {
|
|||||||
updated_at: now,
|
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 || '兑换失败')), {
|
throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), {
|
||||||
task: failedTask,
|
task: failedTask,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import {
|
|||||||
getKuaishouEticketSourceConfig,
|
getKuaishouEticketSourceConfig,
|
||||||
resolveKuaishouEticketShopConfig,
|
resolveKuaishouEticketShopConfig,
|
||||||
} from '../platforms/kuaishou-eticket/source-config-service.js'
|
} from '../platforms/kuaishou-eticket/source-config-service.js'
|
||||||
|
import {
|
||||||
|
notifyKuaishouCloudAssetNotEnough,
|
||||||
|
notifyKuaishouCloudBindUrlRefreshFailed,
|
||||||
|
notifyKuaishouCloudConsumeFailed,
|
||||||
|
} from '../notification/domain-notifications.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso } from '../../utils/time.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 targetPrice = Number(targetSku.price || 0) || 0
|
||||||
const requiredAsset = targetPrice + flowWithResolvedBinding.purchase.minAssetReserve
|
const requiredAsset = targetPrice + flowWithResolvedBinding.purchase.minAssetReserve
|
||||||
if (assetBefore < requiredAsset) {
|
if (assetBefore < requiredAsset) {
|
||||||
|
await notifyKuaishouCloudAssetNotEnough({
|
||||||
|
task,
|
||||||
|
flow: flowWithResolvedBinding,
|
||||||
|
assetBefore,
|
||||||
|
requiredAsset,
|
||||||
|
skuName: targetSku.name,
|
||||||
|
})
|
||||||
throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, {
|
throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'kuaishou_cloud_asset_not_enough',
|
errorCode: 'kuaishou_cloud_asset_not_enough',
|
||||||
@@ -652,6 +664,11 @@ async function markKuaishouCloudBindUrlRefreshFailed(task, {
|
|||||||
actor,
|
actor,
|
||||||
}, now)
|
}, now)
|
||||||
|
|
||||||
|
await notifyKuaishouCloudBindUrlRefreshFailed({
|
||||||
|
task: updatedTask,
|
||||||
|
errorMessage,
|
||||||
|
})
|
||||||
|
|
||||||
return updatedTask
|
return updatedTask
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,6 +1015,17 @@ export async function returnKuaishouCloudFulfillmentTask(task, options = {}) {
|
|||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (consumeStatus !== 'success') {
|
||||||
|
await notifyKuaishouCloudConsumeFailed({
|
||||||
|
task: updatedTask,
|
||||||
|
order,
|
||||||
|
ticketCodeMasked: maskCode(ticketCode),
|
||||||
|
shopId,
|
||||||
|
shopName,
|
||||||
|
errorMessage: consumeErrorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
task: updatedTask,
|
task: updatedTask,
|
||||||
flow: normalizeKuaishouCloudFlow(parseTaskContext(updatedTask).kuaishouCloudFulfillment),
|
flow: normalizeKuaishouCloudFlow(parseTaskContext(updatedTask).kuaishouCloudFulfillment),
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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]'
|
||||||
|
}
|
||||||
@@ -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<string, unknown>} */ (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())
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildOpen91SourceEvent,
|
buildOpen91SourceEvent,
|
||||||
upsertOpen91PendingOrder,
|
upsertOpen91PendingOrder,
|
||||||
} from '../platforms/ninetyone/order-service.js'
|
} from '../platforms/ninetyone/order-service.js'
|
||||||
|
import { notifyOpen91PendingConfig } from '../notification/domain-notifications.js'
|
||||||
import {
|
import {
|
||||||
assertOpen91Config,
|
assertOpen91Config,
|
||||||
assertOpen91CreatePayload,
|
assertOpen91CreatePayload,
|
||||||
@@ -64,6 +65,14 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) {
|
|||||||
orderId: pending.order?.id || null,
|
orderId: pending.order?.id || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await notifyOpen91PendingConfig({
|
||||||
|
order: pending.order,
|
||||||
|
orderNo: normalized.orderNo,
|
||||||
|
productNo: normalized.productNo,
|
||||||
|
buyNum: normalized.buyNum,
|
||||||
|
reason: result.ignoreReason || '未命中履约配置',
|
||||||
|
})
|
||||||
|
|
||||||
return buildOpen91SuccessResponse({
|
return buildOpen91SuccessResponse({
|
||||||
orderNo: normalized.orderNo,
|
orderNo: normalized.orderNo,
|
||||||
outTradeNo: buildOpen91OutTradeNo(pending.order),
|
outTradeNo: buildOpen91OutTradeNo(pending.order),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '../../repositories/fulfillment-profile-repo.js'
|
} from '../../repositories/fulfillment-profile-repo.js'
|
||||||
import { reserveInventoryForTask } from './inventory-service.js'
|
import { reserveInventoryForTask } from './inventory-service.js'
|
||||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||||
|
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { randomId } from '../../utils/random.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') {
|
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',
|
task_status: 'manual_review',
|
||||||
inventory_status: 'not_required',
|
inventory_status: 'not_required',
|
||||||
user_action_status: 'not_required',
|
user_action_status: 'not_required',
|
||||||
last_error: task.last_error || '当前任务需要人工履约处理',
|
last_error: lastError,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
|
await notifyTaskAutoManualReview({
|
||||||
|
task: updatedTask || task,
|
||||||
|
reason: lastError,
|
||||||
|
source: 'manual_dispatch_profile',
|
||||||
|
})
|
||||||
|
return updatedTask
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inventorySkuCode) {
|
if (!inventorySkuCode) {
|
||||||
return updateTask(task.id, {
|
const lastError = '未匹配到 SKU,无法为任务分配库存凭据'
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: 'manual_review',
|
task_status: 'manual_review',
|
||||||
last_error: '未匹配到 SKU,无法为任务分配库存凭据',
|
last_error: lastError,
|
||||||
updated_at: now,
|
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) {
|
if (task.primary_inventory_item_id && task.primary_claim_token_id) {
|
||||||
@@ -253,11 +268,18 @@ async function preparePaidTask(task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!primaryRequirement?.credentialType) {
|
if (!primaryRequirement?.credentialType) {
|
||||||
return updateTask(task.id, {
|
const lastError = '履约档案未配置库存要求,无法自动分配库存'
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: 'manual_review',
|
task_status: 'manual_review',
|
||||||
last_error: '履约档案未配置库存要求,无法自动分配库存',
|
last_error: lastError,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
|
await notifyTaskAutoManualReview({
|
||||||
|
task: updatedTask || task,
|
||||||
|
reason: lastError,
|
||||||
|
source: 'missing_inventory_requirement',
|
||||||
|
})
|
||||||
|
return updatedTask
|
||||||
}
|
}
|
||||||
|
|
||||||
const reserved = await reserveInventoryForTask({
|
const reserved = await reserveInventoryForTask({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import https from 'node:https'
|
|||||||
|
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
import { logInfo } from '../../../utils/logger.js'
|
import { logInfo } from '../../../utils/logger.js'
|
||||||
|
import { notifyCloudtentaclesAuthExpired } from '../../notification/domain-notifications.js'
|
||||||
import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './shared.js'
|
import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './shared.js'
|
||||||
|
|
||||||
export async function cloudtentaclesRequest(pathname, options = {}) {
|
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) {
|
if (options.requireBusinessSuccess !== false && Number(payload?.code ?? 1) !== 0) {
|
||||||
throw createHttpError(String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败'), {
|
const message = String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败')
|
||||||
statusCode: Number(options.businessErrorStatusCode || 400),
|
const statusCode = Number(options.businessErrorStatusCode || 400)
|
||||||
errorCode: String(options.businessErrorCode || 'cloudtentacles_business_error'),
|
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) {
|
function inferContentType(body) {
|
||||||
if (body == null) {
|
if (body == null) {
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export {}
|
|||||||
* @typedef {import('./admin-write-inputs.js').AdminKuaishouEticketSourceConfigInput} AdminKuaishouEticketSourceConfigRouteBody
|
* @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
|
* @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -120,6 +120,36 @@ export {}
|
|||||||
* }} AdminKuaishouEticketSourceConfigInput
|
* }} 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 {{
|
* @typedef {{
|
||||||
* baseUrl?: string
|
* baseUrl?: string
|
||||||
|
|||||||
Vendored
+1
@@ -16,6 +16,7 @@ declare module 'vue' {
|
|||||||
AdminPlatformCloudtentaclesSection: typeof import('./components/admin/AdminPlatformCloudtentaclesSection.vue')['default']
|
AdminPlatformCloudtentaclesSection: typeof import('./components/admin/AdminPlatformCloudtentaclesSection.vue')['default']
|
||||||
AdminPlatformKuaishouEticketSection: typeof import('./components/admin/AdminPlatformKuaishouEticketSection.vue')['default']
|
AdminPlatformKuaishouEticketSection: typeof import('./components/admin/AdminPlatformKuaishouEticketSection.vue')['default']
|
||||||
AdminPlatformNinetyoneSection: typeof import('./components/admin/AdminPlatformNinetyoneSection.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']
|
AdminResultCard: typeof import('./components/admin/AdminResultCard.vue')['default']
|
||||||
AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default']
|
AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdminNotificationTestResult } from '@/types/admin'
|
||||||
|
import type { EditableNotificationRecipient } from '@/composables/admin/platform-shops/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
notificationFilePath: string
|
||||||
|
notificationForm: {
|
||||||
|
enabled: boolean
|
||||||
|
barkEnabled: boolean
|
||||||
|
barkServerUrl: string
|
||||||
|
testTitle: string
|
||||||
|
testBody: string
|
||||||
|
testUrl: string
|
||||||
|
}
|
||||||
|
notificationRecipients: EditableNotificationRecipient[]
|
||||||
|
notificationSaving: boolean
|
||||||
|
notificationTesting: boolean
|
||||||
|
notificationResultError: string
|
||||||
|
notificationTestResult: AdminNotificationTestResult | null
|
||||||
|
notificationStats: {
|
||||||
|
configuredRecipientCount: number
|
||||||
|
enabledRecipientCount: number
|
||||||
|
barkReady: boolean
|
||||||
|
lastSuccessCount: number
|
||||||
|
lastFailedCount: number
|
||||||
|
}
|
||||||
|
addNotificationRecipient: () => void
|
||||||
|
removeNotificationRecipient: (id: string) => void
|
||||||
|
handleNotificationSaveConfig: () => void | Promise<void>
|
||||||
|
handleNotificationTest: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="meta-card">
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">职责</span>
|
||||||
|
<span>内部运营通知,仅发送给后台管理员、客服或值班人员。</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">配置文件</span>
|
||||||
|
<span>{{ notificationFilePath || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<div>
|
||||||
|
<h3>Bark 通知</h3>
|
||||||
|
<p>已配置 {{ notificationStats.configuredRecipientCount }} 人,启用 {{ notificationStats.enabledRecipientCount }} 人。</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-actions">
|
||||||
|
<el-button round @click="addNotificationRecipient">新增接收人</el-button>
|
||||||
|
<el-button :loading="notificationSaving" round type="primary" @click="handleNotificationSaveConfig">保存配置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="notificationResultError" class="error-copy">{{ notificationResultError }}</p>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="checkbox-line">
|
||||||
|
<input v-model="notificationForm.enabled" class="checkbox-box" type="checkbox" />
|
||||||
|
<span>启用内部通知</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-line">
|
||||||
|
<input v-model="notificationForm.barkEnabled" class="checkbox-box" type="checkbox" />
|
||||||
|
<span>启用 Bark 通道</span>
|
||||||
|
</label>
|
||||||
|
<label class="field-block field-wide">
|
||||||
|
<span>Bark 服务地址</span>
|
||||||
|
<input v-model="notificationForm.barkServerUrl" class="text-input" placeholder="https://api.day.app" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>接收人</th>
|
||||||
|
<th>Device Key</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in notificationRecipients" :key="item.id">
|
||||||
|
<td>
|
||||||
|
<input v-model="item.name" class="text-input" placeholder="例如:客服A" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input v-model="item.deviceKey" class="text-input" placeholder="Bark device key" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label class="checkbox-line">
|
||||||
|
<input v-model="item.enabled" class="checkbox-box" type="checkbox" />
|
||||||
|
<span>{{ item.enabled ? '启用' : '停用' }}</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<el-button link type="danger" @click="removeNotificationRecipient(item.id)">删除</el-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="notificationRecipients.length === 0">
|
||||||
|
<td colspan="4" class="empty-inline">还没有 Bark 接收人。</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<div>
|
||||||
|
<h3>测试发送</h3>
|
||||||
|
<p>测试会发送给所有已启用且填写了 Device Key 的接收人。</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-actions">
|
||||||
|
<el-button :disabled="!notificationStats.barkReady" :loading="notificationTesting" round type="primary" @click="handleNotificationTest">发送测试</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field-block">
|
||||||
|
<span>标题</span>
|
||||||
|
<input v-model="notificationForm.testTitle" class="text-input" />
|
||||||
|
</label>
|
||||||
|
<label class="field-block field-wide">
|
||||||
|
<span>内容</span>
|
||||||
|
<input v-model="notificationForm.testBody" class="text-input" />
|
||||||
|
</label>
|
||||||
|
<label class="field-block field-wide">
|
||||||
|
<span>跳转链接</span>
|
||||||
|
<input v-model="notificationForm.testUrl" class="text-input" placeholder="可选" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notificationTestResult" class="result-grid">
|
||||||
|
<div class="result-card">
|
||||||
|
<span class="result-label">成功</span>
|
||||||
|
<strong>{{ notificationTestResult.successCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="result-card">
|
||||||
|
<span class="result-label">失败</span>
|
||||||
|
<strong>{{ notificationTestResult.failedCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="result-card">
|
||||||
|
<span class="result-label">通道</span>
|
||||||
|
<strong>{{ notificationTestResult.channel || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-if="notificationTestResult" class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>接收人</th>
|
||||||
|
<th>结果</th>
|
||||||
|
<th>状态码</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in notificationTestResult.results" :key="item.recipientId || item.recipientKeyMasked">
|
||||||
|
<td>{{ item.recipientName || item.recipientKeyMasked || '-' }}</td>
|
||||||
|
<td>{{ item.ok ? '成功' : '失败' }}</td>
|
||||||
|
<td>{{ item.status || '-' }}</td>
|
||||||
|
<td>{{ item.errorMessage || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type PlatformTab = 'agiso' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles'
|
export type PlatformTab = 'agiso' | 'notifications' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles'
|
||||||
|
|
||||||
export type EditableDefaults = {
|
export type EditableDefaults = {
|
||||||
messageTemplate: string
|
messageTemplate: string
|
||||||
@@ -23,3 +23,10 @@ export type EditableKuaishouEticketShop = {
|
|||||||
userAvatar: string
|
userAvatar: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EditableNotificationRecipient = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
deviceKey: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<EditableNotificationRecipient[]>([])
|
||||||
|
const notificationSaving = ref(false)
|
||||||
|
const notificationTesting = ref(false)
|
||||||
|
const notificationResultError = ref('')
|
||||||
|
const notificationTestResult = ref<AdminNotificationTestResult | null>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import type {
|
|||||||
AdminFulfillmentLookupResult,
|
AdminFulfillmentLookupResult,
|
||||||
AdminNinetyoneOrderActionResult,
|
AdminNinetyoneOrderActionResult,
|
||||||
AdminNinetyoneOrderListResult,
|
AdminNinetyoneOrderListResult,
|
||||||
|
AdminNotificationConfig,
|
||||||
|
AdminNotificationTestResult,
|
||||||
AdminKuaishouCloudFulfillmentConfig,
|
AdminKuaishouCloudFulfillmentConfig,
|
||||||
AdminKuaishouEticketConsumeResult,
|
AdminKuaishouEticketConsumeResult,
|
||||||
AdminKuaishouEticketDetailResult,
|
AdminKuaishouEticketDetailResult,
|
||||||
@@ -41,6 +43,28 @@ export function saveAdminAgisoShopConfigs(payload: {
|
|||||||
}>('/api/v1/admin/platform-config/agiso-shops', 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<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testAdminNotification(payload: {
|
||||||
|
title?: string
|
||||||
|
body?: string
|
||||||
|
url?: string
|
||||||
|
}) {
|
||||||
|
return apiPost<AdminNotificationTestResult>('/api/v1/admin/platform-config/notifications/test', payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminNinetyoneOrders(params: {
|
export function fetchAdminNinetyoneOrders(params: {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
|||||||
@@ -164,6 +164,44 @@ export interface AdminKuaishouEticketConsumeResult {
|
|||||||
raw: Record<string, unknown>
|
raw: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface AdminNinetyoneOrderItem {
|
||||||
orderId: number
|
orderId: number
|
||||||
orderNo: string
|
orderNo: string
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import { onMounted, ref } from 'vue'
|
|||||||
import AdminPlatformAgisoSection from '@/components/admin/AdminPlatformAgisoSection.vue'
|
import AdminPlatformAgisoSection from '@/components/admin/AdminPlatformAgisoSection.vue'
|
||||||
import AdminPlatformCloudtentaclesSection from '@/components/admin/AdminPlatformCloudtentaclesSection.vue'
|
import AdminPlatformCloudtentaclesSection from '@/components/admin/AdminPlatformCloudtentaclesSection.vue'
|
||||||
import AdminPlatformNinetyoneSection from '@/components/admin/AdminPlatformNinetyoneSection.vue'
|
import AdminPlatformNinetyoneSection from '@/components/admin/AdminPlatformNinetyoneSection.vue'
|
||||||
|
import AdminPlatformNotificationSection from '@/components/admin/AdminPlatformNotificationSection.vue'
|
||||||
import AdminPlatformKuaishouEticketSection from '@/components/admin/AdminPlatformKuaishouEticketSection.vue'
|
import AdminPlatformKuaishouEticketSection from '@/components/admin/AdminPlatformKuaishouEticketSection.vue'
|
||||||
import { useAdminAgisoPlatform } from '@/composables/admin/platform-shops/useAdminAgisoPlatform'
|
import { useAdminAgisoPlatform } from '@/composables/admin/platform-shops/useAdminAgisoPlatform'
|
||||||
import { useAdminCloudtentaclesPlatform } from '@/composables/admin/platform-shops/useAdminCloudtentaclesPlatform'
|
import { useAdminCloudtentaclesPlatform } from '@/composables/admin/platform-shops/useAdminCloudtentaclesPlatform'
|
||||||
import { useAdminNinetyonePlatform } from '@/composables/admin/platform-shops/useAdminNinetyonePlatform'
|
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 { useAdminKuaishouEticketPlatform } from '@/composables/admin/platform-shops/useAdminKuaishouEticketPlatform'
|
||||||
import type { PlatformTab } from '@/composables/admin/platform-shops/types'
|
import type { PlatformTab } from '@/composables/admin/platform-shops/types'
|
||||||
import {
|
import {
|
||||||
fetchAdminAgisoShopConfigs,
|
fetchAdminAgisoShopConfigs,
|
||||||
fetchAdminCloudtentaclesSourceConfig,
|
fetchAdminCloudtentaclesSourceConfig,
|
||||||
fetchAdminKuaishouEticketSourceConfig,
|
fetchAdminKuaishouEticketSourceConfig,
|
||||||
|
fetchAdminNotificationConfig,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
import { hasAdminRole } from '@/utils/admin-auth'
|
||||||
import '@/styles/admin-platform-shops.css'
|
import '@/styles/admin-platform-shops.css'
|
||||||
@@ -55,6 +58,22 @@ const {
|
|||||||
handleNinetyoneFailOrder,
|
handleNinetyoneFailOrder,
|
||||||
} = useAdminNinetyonePlatform()
|
} = useAdminNinetyonePlatform()
|
||||||
|
|
||||||
|
const {
|
||||||
|
notificationFilePath,
|
||||||
|
notificationForm,
|
||||||
|
notificationRecipients,
|
||||||
|
notificationSaving,
|
||||||
|
notificationTesting,
|
||||||
|
notificationResultError,
|
||||||
|
notificationTestResult,
|
||||||
|
notificationStats,
|
||||||
|
hydrateNotificationConfig,
|
||||||
|
addNotificationRecipient,
|
||||||
|
removeNotificationRecipient,
|
||||||
|
handleNotificationSaveConfig,
|
||||||
|
handleNotificationTest,
|
||||||
|
} = useAdminNotificationPlatform()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
kuaishouEticketFilePath,
|
kuaishouEticketFilePath,
|
||||||
kuaishouEticketShops,
|
kuaishouEticketShops,
|
||||||
@@ -132,13 +151,15 @@ async function loadConfigs() {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [agisoResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([
|
const [agisoResponse, notificationResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([
|
||||||
fetchAdminAgisoShopConfigs(),
|
fetchAdminAgisoShopConfigs(),
|
||||||
|
fetchAdminNotificationConfig(),
|
||||||
fetchAdminKuaishouEticketSourceConfig(),
|
fetchAdminKuaishouEticketSourceConfig(),
|
||||||
fetchAdminCloudtentaclesSourceConfig(),
|
fetchAdminCloudtentaclesSourceConfig(),
|
||||||
])
|
])
|
||||||
|
|
||||||
hydrateAgisoConfig(agisoResponse.data)
|
hydrateAgisoConfig(agisoResponse.data)
|
||||||
|
hydrateNotificationConfig(notificationResponse.data)
|
||||||
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
||||||
hydrateCloudtentaclesConfig(cloudtentaclesResponse.data)
|
hydrateCloudtentaclesConfig(cloudtentaclesResponse.data)
|
||||||
await loadNinetyoneOrders(1)
|
await loadNinetyoneOrders(1)
|
||||||
@@ -198,6 +219,23 @@ onMounted(loadConfigs)
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['platform-overview-card', { 'is-active': activePlatform === 'notifications' }]"
|
||||||
|
@click="switchPlatform('notifications')"
|
||||||
|
>
|
||||||
|
<div class="platform-head">
|
||||||
|
<span class="platform-badge">内部通知</span>
|
||||||
|
<span class="platform-tag">Bark / 值班</span>
|
||||||
|
</div>
|
||||||
|
<strong>{{ notificationStats.enabledRecipientCount }}</strong>
|
||||||
|
<span>启用接收人</span>
|
||||||
|
<div class="platform-metrics">
|
||||||
|
<span>配置 {{ notificationStats.configuredRecipientCount }} 人</span>
|
||||||
|
<span>最近成功 {{ notificationStats.lastSuccessCount }} 个</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="['platform-overview-card', { 'is-active': activePlatform === 'ninetyone' }]"
|
:class="['platform-overview-card', { 'is-active': activePlatform === 'ninetyone' }]"
|
||||||
@@ -266,6 +304,13 @@ onMounted(loadConfigs)
|
|||||||
>
|
>
|
||||||
91卡券接入
|
91卡券接入
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['switch-chip', { 'is-active': activePlatform === 'notifications' }]"
|
||||||
|
@click="switchPlatform('notifications')"
|
||||||
|
>
|
||||||
|
内部通知
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="['switch-chip', { 'is-active': activePlatform === 'kuaishouEticket' }]"
|
:class="['switch-chip', { 'is-active': activePlatform === 'kuaishouEticket' }]"
|
||||||
@@ -314,6 +359,22 @@ onMounted(loadConfigs)
|
|||||||
:handle-ninetyone-fail-order="handleNinetyoneFailOrder"
|
:handle-ninetyone-fail-order="handleNinetyoneFailOrder"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdminPlatformNotificationSection
|
||||||
|
v-else-if="activePlatform === 'notifications'"
|
||||||
|
:notification-file-path="notificationFilePath"
|
||||||
|
:notification-form="notificationForm"
|
||||||
|
:notification-recipients="notificationRecipients"
|
||||||
|
:notification-saving="notificationSaving"
|
||||||
|
:notification-testing="notificationTesting"
|
||||||
|
:notification-result-error="notificationResultError"
|
||||||
|
:notification-test-result="notificationTestResult"
|
||||||
|
:notification-stats="notificationStats"
|
||||||
|
:add-notification-recipient="addNotificationRecipient"
|
||||||
|
:remove-notification-recipient="removeNotificationRecipient"
|
||||||
|
:handle-notification-save-config="handleNotificationSaveConfig"
|
||||||
|
:handle-notification-test="handleNotificationTest"
|
||||||
|
/>
|
||||||
|
|
||||||
<AdminPlatformKuaishouEticketSection
|
<AdminPlatformKuaishouEticketSection
|
||||||
v-else-if="activePlatform === 'kuaishouEticket'"
|
v-else-if="activePlatform === 'kuaishouEticket'"
|
||||||
:kuaishou-eticket-file-path="kuaishouEticketFilePath"
|
:kuaishou-eticket-file-path="kuaishouEticketFilePath"
|
||||||
|
|||||||
Reference in New Issue
Block a user