Files
order_site/apps/backend/src/services/message-service.js
T
2026-04-08 16:30:42 +08:00

232 lines
6.3 KiB
JavaScript

import { runtimeConfig } from '../config/runtime.js'
import crypto from 'node:crypto'
import {
createMessageDelivery,
findLatestSuccessfulMessageDeliveryByTask,
updateMessageDelivery,
} from '../repositories/message-delivery-repo.js'
import { nowIso } from '../utils/time.js'
const AGISO_MESSAGE_CHANNEL = 'agiso_im'
export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, claimUrl, expiredAt }) {
if (!order || !task || !claimUrl) {
return { sent: false, skipped: true, reason: 'missing_message_context' }
}
const config = runtimeConfig.platforms?.agiso?.messaging || {}
if (!config.enabled) {
return { sent: false, skipped: true, reason: 'messaging_disabled' }
}
const endpoint = String(config.sendMessageEndpoint || '').trim()
const accessToken = String(config.accessToken || '').trim()
const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim()
if (!endpoint || !accessToken || !appSecret) {
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
}
const successful = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_MESSAGE_CHANNEL)
if (successful) {
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
}
const messageContent = renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template: config.messageTemplate })
const url = buildRequestUrl(endpoint)
const requestBody = buildRequestBody({
tid: String(order.platform_order_id || ''),
msg: messageContent,
appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken,
apiVersion: String(config.apiVersion || '1').trim() || '1',
})
const createdAt = nowIso()
const delivery = createMessageDelivery({
platform: 'agiso',
channel: AGISO_MESSAGE_CHANNEL,
orderId: order.id,
taskId: task.id,
platformOrderId: order.platform_order_id,
recipientKey: order.platform_order_id,
messageContent,
claimUrl,
status: 'pending',
requestUrl: url,
requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)),
requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)),
responseStatus: 0,
responseJson: '{}',
errorMessage: '',
sentAt: null,
createdAt,
updatedAt: createdAt,
})
try {
const response = await fetch(url, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = safeParseJson(rawText)
const success = isAgisoSendSuccess(response.status, parsed)
const updated = updateMessageDelivery(delivery.id, {
status: success ? 'success' : 'failed',
response_status: response.status,
response_json: JSON.stringify(parsed ?? { rawText }),
error_message: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
sent_at: success ? nowIso() : null,
updated_at: nowIso(),
})
return {
sent: success,
skipped: false,
deliveryId: updated?.id || delivery.id,
responseStatus: response.status,
response: parsed ?? { rawText },
errorMessage: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
updateMessageDelivery(delivery.id, {
status: 'failed',
response_status: 0,
response_json: '{}',
error_message: message,
sent_at: null,
updated_at: nowIso(),
})
return {
sent: false,
skipped: false,
deliveryId: delivery.id,
errorMessage: message,
}
}
}
function buildRequestUrl(endpoint) {
return new URL(endpoint).toString()
}
function buildRequestHeaders({ accessToken, apiVersion }) {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({ tid, msg, appSecret }) {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload = {
tid,
msg,
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params, appSecret) {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template }) {
const source = String(template || '').trim() || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
return source
.replaceAll('{platformOrderId}', String(order.platform_order_id || ''))
.replaceAll('{taskNo}', String(task.task_no || ''))
.replaceAll('{claimUrl}', claimUrl)
.replaceAll('{expiredAt}', String(expiredAt || '尽快'))
}
function isAgisoSendSuccess(statusCode, payload) {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object') {
return true
}
if (payload.IsSuccess === true) {
return true
}
if (Number(payload.Error_Code) === 0) {
return true
}
if (Number(payload.code) === 0) {
return true
}
return false
}
function resolveAgisoErrorMessage(payload, rawText, statusCode) {
if (payload && typeof payload === 'object') {
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 发消息失败,HTTP ${statusCode}`
}
function safeParseJson(rawText) {
const normalized = String(rawText || '').trim()
if (!normalized) {
return null
}
try {
return JSON.parse(normalized)
} catch {
return null
}
}
function maskHeadersForStorage(headers) {
const output = { ...headers }
if (output.Authorization) {
output.Authorization = '[masked]'
}
return output
}
function maskBodyForStorage(body) {
const output = { ...body }
if (output.sign) {
output.sign = '[masked]'
}
return output
}