GuDingZiChan/backend/src/services/youyidianNotificationClient.ts

421 lines
14 KiB
TypeScript

import crypto from 'crypto'
import { normalizeInternalRedirect } from '../utils/dingtalkRedirect'
export type NotificationCenterStatus =
'pending' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed'
export type NotificationCenterDelivery = {
id: string
projectCode: string
status: NotificationCenterStatus
recipientUserId: string
title: string
processQueryKey?: string | null
lastErrorCode?: string | null
lastErrorMessage?: string | null
sentAt?: string | null
readAt?: string | null
}
export class NotificationCenterError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly deterministic = false,
public readonly httpStatus?: number
) {
super(message)
this.name = 'NotificationCenterError'
}
}
const config = () => ({
baseUrl: String(process.env.YYD_NOTIFICATION_CENTER_URL || '').trim().replace(/\/+$/, ''),
projectCode: String(process.env.YYD_NOTIFICATION_PROJECT_CODE || '').trim(),
secret: String(process.env.YYD_NOTIFICATION_PROJECT_SECRET || '').trim()
})
export const getNotificationCenterConfigStatus = () => {
const value = config()
const missing: string[] = []
if (!value.baseUrl) missing.push('YYD_NOTIFICATION_CENTER_URL')
if (value.baseUrl) {
try {
const url = new URL(value.baseUrl)
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.port && url.port !== '443')
) {
missing.push('YYD_NOTIFICATION_CENTER_URL(必须为不含凭证和查询参数的HTTPS地址)')
}
} catch {
missing.push('YYD_NOTIFICATION_CENTER_URL(格式无效)')
}
}
if (!value.projectCode) missing.push('YYD_NOTIFICATION_PROJECT_CODE')
if (value.projectCode && value.projectCode !== 'fixed_asset') {
missing.push('YYD_NOTIFICATION_PROJECT_CODE(固定资产必须为fixed_asset)')
}
if (!value.secret) missing.push('YYD_NOTIFICATION_PROJECT_SECRET')
if (value.secret && value.secret.length < 24) {
missing.push('YYD_NOTIFICATION_PROJECT_SECRET(长度不足)')
}
return { configured: missing.length === 0, missing }
}
export const getNotificationCenterLoginRollout = () => {
const enabled = String(process.env.YYD_NOTIFICATION_LOGIN_ENABLED || '').trim().toLowerCase() === 'true'
const fullRollout = String(process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT || '').trim().toLowerCase() === 'true'
const testUserIds = [...new Set(
String(process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
)]
return { enabled, fullRollout, testUserIds }
}
export const getNotificationCenterLoginConfigStatus = () => {
const missing = [...getNotificationCenterConfigStatus().missing]
const rollout = getNotificationCenterLoginRollout()
if (!rollout.enabled) missing.push('YYD_NOTIFICATION_LOGIN_ENABLED')
if (rollout.enabled && !rollout.fullRollout && rollout.testUserIds.length === 0) {
missing.push('YYD_NOTIFICATION_LOGIN_TEST_USER_IDS')
}
return {
configured: missing.length === 0,
enabled: rollout.enabled,
rollout_mode: !rollout.enabled
? 'disabled' as const
: rollout.fullRollout
? 'full' as const
: 'test' as const,
test_user_count: rollout.testUserIds.length,
missing
}
}
export const isNotificationCenterLoginAllowedForUser = (dingtalkUserId: string) => {
const status = getNotificationCenterLoginConfigStatus()
const normalizedUserId = String(dingtalkUserId || '').trim()
if (!status.configured || !normalizedUserId) return false
const rollout = getNotificationCenterLoginRollout()
return rollout.fullRollout || rollout.testUserIds.includes(normalizedUserId)
}
const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex')
export const createNotificationCenterSignature = (input: {
secret: string
timestamp: string
nonce: string
method: string
path: string
body: string
}) => {
const canonical = [
input.timestamp,
input.nonce,
input.method.toUpperCase(),
input.path,
sha256(input.body)
].join('\n')
return crypto.createHmac('sha256', input.secret).update(canonical, 'utf8').digest('hex')
}
const signedHeaders = (method: string, path: string, body: string) => {
const value = config()
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = crypto.randomBytes(18).toString('base64url')
const signature = createNotificationCenterSignature({
secret: value.secret,
timestamp,
nonce,
method,
path,
body
})
return {
'Content-Type': 'application/json',
'x-yyd-project-code': value.projectCode,
'x-yyd-timestamp': timestamp,
'x-yyd-nonce': nonce,
'x-yyd-signature': signature
}
}
const request = async (path: string, method: 'GET' | 'POST', payload?: Record<string, unknown>) => {
const value = config()
const status = getNotificationCenterConfigStatus()
if (!status.configured) {
throw new NotificationCenterError(
`由壹点通知中心配置不完整: ${status.missing.join(', ')}`,
'CONFIG_INCOMPLETE',
true
)
}
const body = payload ? JSON.stringify(payload) : ''
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8_000)
let response: Response
try {
response = await fetch(`${value.baseUrl}${path}`, {
method,
headers: signedHeaders(method, path, body),
body: body || undefined,
signal: controller.signal
})
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new NotificationCenterError('由壹点通知中心请求超时', 'TIMEOUT')
}
throw new NotificationCenterError(
error?.message || '由壹点通知中心网络请求失败',
'NETWORK'
)
} finally {
clearTimeout(timeout)
}
const data = await response.json().catch(() => ({})) as Record<string, any>
if (!response.ok) {
const code = String(data.error || `HTTP_${response.status}`)
const deterministic = response.status === 401 || response.status === 404 || response.status === 422 ||
(response.status === 409 && code !== 'NONCE_REPLAYED')
throw new NotificationCenterError(
`由壹点通知中心请求失败: ${code}`,
code,
deterministic,
response.status
)
}
return data
}
const validStatuses = new Set<NotificationCenterStatus>([
'pending', 'sending', 'retrying', 'sent', 'unread', 'read', 'failed'
])
const validateDelivery = (data: Record<string, any>) => {
const value = config()
const id = String(data.id || '').trim()
const projectCode = String(data.projectCode || '').trim()
const status = String(data.status || '').trim() as NotificationCenterStatus
if (!id || !validStatuses.has(status)) {
throw new NotificationCenterError('由壹点通知中心返回了无效任务数据', 'INVALID_RESPONSE')
}
if (projectCode !== value.projectCode) {
throw new NotificationCenterError('由壹点通知中心返回的项目代码不匹配', 'PROJECT_MISMATCH', true)
}
return data as NotificationCenterDelivery
}
const buildActionUrl = (actionUrl: string) => {
const publicBaseUrl = String(process.env.APP_PUBLIC_BASE_URL || '').trim().replace(/\/$/, '')
const safePath = normalizeInternalRedirect(actionUrl)
let baseUrl: URL
try {
baseUrl = new URL(publicBaseUrl)
} catch {
throw new NotificationCenterError('固定资产公开地址格式无效', 'PUBLIC_BASE_URL_INVALID', true)
}
if (
baseUrl.protocol !== 'https:' ||
baseUrl.username ||
baseUrl.password ||
baseUrl.pathname !== '/' ||
baseUrl.search ||
baseUrl.hash ||
(baseUrl.port && baseUrl.port !== '443')
) {
throw new NotificationCenterError('固定资产公开地址必须为不含路径、凭证和查询参数的HTTPS地址', 'PUBLIC_BASE_URL_INVALID', true)
}
return new URL(safePath, `${baseUrl.toString().replace(/\/$/, '')}/`).toString()
}
export const buildYouyidianLoginEntryUrl = (redirectPath: unknown) => {
const value = config()
const status = getNotificationCenterLoginConfigStatus()
if (!status.configured) {
throw new NotificationCenterError(
`由壹点通知中心配置不完整: ${status.missing.join(', ')}`,
'CONFIG_INCOMPLETE',
true
)
}
const safeRedirect = normalizeInternalRedirect(redirectPath)
const query = new URLSearchParams({
projectCode: value.projectCode,
redirectPath: safeRedirect
})
return `${value.baseUrl}/v1/auth/h5-entry?${query.toString()}`
}
export type NotificationCenterLoginTicket = {
dingtalkUserId: string
redirectPath: string
}
export const redeemYouyidianLoginTicket = async (ticket: string): Promise<NotificationCenterLoginTicket> => {
const status = getNotificationCenterLoginConfigStatus()
if (!status.configured) {
throw new NotificationCenterError(
`由壹点通知中心免登配置未启用: ${status.missing.join(', ')}`,
'CONFIG_INCOMPLETE',
true
)
}
let data: Record<string, any>
try {
data = await request('/v1/auth/tickets/redeem', 'POST', { ticket })
} catch (error) {
if (!(error instanceof NotificationCenterError) || error.code !== 'NONCE_REPLAYED') throw error
data = await request('/v1/auth/tickets/redeem', 'POST', { ticket })
}
const dingtalkUserId = String(data.dingtalkUserId || '').trim()
if (!dingtalkUserId) {
throw new NotificationCenterError('由壹点通知中心返回了无效的登录身份', 'INVALID_RESPONSE', true)
}
let redirectPath: string
try {
redirectPath = normalizeInternalRedirect(data.redirectPath)
} catch {
throw new NotificationCenterError('由壹点通知中心返回了无效的跳转地址', 'INVALID_RESPONSE', true)
}
return { dingtalkUserId, redirectPath }
}
export const submitYouyidianNotification = async (input: {
idempotencyKey: string
recipientUserId: string
title: string
content: string
actionTitle?: string
actionUrl: string
}) => validateDelivery(await request('/v1/notifications', 'POST', {
idempotencyKey: input.idempotencyKey,
recipientUserId: input.recipientUserId,
title: input.title,
content: input.content,
actionTitle: input.actionTitle || '查看详情',
actionUrl: buildActionUrl(input.actionUrl)
}))
export const getYouyidianNotificationStatus = async (id: string) => {
const path = `/v1/notifications/${encodeURIComponent(id)}`
return validateDelivery(await request(path, 'GET'))
}
export const prepareNotificationCenterManualRetryPayload = (
payloadJson: string,
identity: {
eventType: string
businessId: number
recipientUserId: string
sourceDeliveryId: number
operatorUserId: number
createdAt?: Date
}
) => {
const payload = JSON.parse(payloadJson) as Record<string, unknown>
const previousSequence = Number(payload.notification_center_manual_retry_sequence)
const sequence = Number.isSafeInteger(previousSequence) && previousSequence >= 0
? previousSequence + 1
: 1
const messageVersion = String(payload.message_version || 'legacy')
.trim()
.replace(/[^A-Za-z0-9._-]/g, '_')
.slice(0, 30) || 'legacy'
let idempotencyKey = [
String(identity.eventType || '').trim(),
String(identity.businessId),
String(identity.recipientUserId || '').trim(),
messageVersion,
`manual-v${sequence}`
].join(':')
if (idempotencyKey.length > 160) {
const identityHash = sha256([
identity.eventType,
identity.businessId,
identity.recipientUserId
].join(':')).slice(0, 32)
idempotencyKey = `manual:${identity.sourceDeliveryId}:${messageVersion}:${identityHash}:v${sequence}`
}
return JSON.stringify({
...payload,
notification_center_manual_retry_sequence: sequence,
notification_center_idempotency_key: idempotencyKey,
notification_center_manual_retry_source_delivery_id: identity.sourceDeliveryId,
notification_center_manual_retry_by: identity.operatorUserId,
notification_center_manual_retry_at: (identity.createdAt || new Date()).toISOString()
})
}
export const prepareNotificationCenterResumePayload = (
payloadJson: string,
failure: {
code?: string | null
message?: string | null
attemptCount: number
operatorUserId: number
resumedAt?: Date
}
) => {
const payload = JSON.parse(payloadJson) as Record<string, unknown>
const previousHistory = Array.isArray(payload.notification_center_resume_history)
? payload.notification_center_resume_history.slice(-19)
: []
const resumeCount = Number(payload.notification_center_resume_count)
return JSON.stringify({
...payload,
notification_center_resume_count: Number.isSafeInteger(resumeCount) && resumeCount >= 0
? resumeCount + 1
: 1,
notification_center_resume_history: [
...previousHistory,
{
resumed_at: (failure.resumedAt || new Date()).toISOString(),
previous_error_code: String(failure.code || 'UNKNOWN').slice(0, 100),
previous_error_message: String(failure.message || '').slice(0, 500),
previous_attempt_count: failure.attemptCount,
operator_user_id: failure.operatorUserId
}
]
})
}
export const prepareNotificationCenterQueryResumePayload = (
payloadJson: string,
failure: {
code?: string | null
message?: string | null
operatorUserId: number
resumedAt?: Date
}
) => {
const payload = JSON.parse(payloadJson) as Record<string, unknown>
const previousHistory = Array.isArray(payload.notification_center_query_resume_history)
? payload.notification_center_query_resume_history.slice(-19)
: []
const resumeCount = Number(payload.notification_center_query_resume_count)
return JSON.stringify({
...payload,
notification_center_status_query_error: null,
notification_center_query_resume_count: Number.isSafeInteger(resumeCount) && resumeCount >= 0
? resumeCount + 1
: 1,
notification_center_query_resume_history: [
...previousHistory,
{
resumed_at: (failure.resumedAt || new Date()).toISOString(),
previous_error_code: String(failure.code || 'UNKNOWN').slice(0, 100),
previous_error_message: String(failure.message || '').slice(0, 500),
operator_user_id: failure.operatorUserId
}
]
})
}