421 lines
14 KiB
TypeScript
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
|
|
}
|
|
]
|
|
})
|
|
}
|