gongxiangp2/lib/ding-workflow-sync.ts
2026-08-24 11:34:17 +08:00

662 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'node:crypto'
import { hostname } from 'node:os'
import type { Session } from 'next-auth'
import mssql, { type ConnectionPool } from 'mssql'
import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
import { prisma } from '@/lib/prisma'
const SYNC_STATE_ID = 'ding-workflow-hourly'
const TERMINAL_WORKFLOW_STATUSES = new Set(['COMPLETED', 'TERMINATED', 'CANCELED'])
const LEASE_DURATION_MS = 20 * 60 * 1000
const DEFAULT_SYNC_INTERVAL_MS = 60 * 60 * 1000
const INITIAL_SYNC_DELAY_MS = 5_000
const UPSERT_BATCH_SIZE = 750
export const DING_WORKFLOW_ALLOWED_PROCESS_CODES = [
'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673',
] as const
const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES]
const ALLOWED_PROCESS_CODE_SET = new Set<string>(DING_WORKFLOW_ALLOWED_PROCESS_CODES)
const WORKFLOW_TABLE = {
schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt',
table: process.env.REPORT_DB_WORKFLOW_TABLE || 'ding_Workflow',
}
const WORKFLOW_SCHEME_TABLE = {
schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt',
table: process.env.REPORT_DB_WORKFLOW_SCHEME_TABLE || 'ding_WorkflowScheme',
}
const WORKFLOW_TASK_TABLE = {
schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt',
table: process.env.REPORT_DB_WORKFLOW_TASK_TABLE || 'ding_WorkflowTask',
}
type SourceWorkflowRow = {
processInstanceId: unknown
businessId: unknown
title: unknown
result: unknown
originatorUserId: unknown
originatorDeptId: unknown
originatorDeptName: unknown
status: unknown
bizAction: unknown
processCode: unknown
flowType: unknown
joinedAtRaw: unknown
sourceCreatedAtRaw: unknown
sourceUpdatedAtRaw: unknown
sourceFinishedAtRaw: unknown
}
type SourceTaskAggregate = {
processInstanceId: unknown
taskCount: unknown
activeTaskCount: unknown
latestTaskStatus: unknown
latestTaskAtRaw: unknown
}
type SourceSchemeAggregate = {
processInstanceId: unknown
schemeFieldCount: unknown
}
type WorkflowSnapshotPayload = {
processInstanceId: string
businessId: string | null
title: string | null
result: string | null
originatorUserId: string | null
originatorDeptId: string | null
originatorDeptName: string | null
status: string
bizAction: string | null
processCode: string | null
flowType: string | null
joinedAtRaw: string | null
sourceCreatedAtRaw: string | null
sourceUpdatedAtRaw: string | null
sourceFinishedAtRaw: string | null
isOngoing: boolean
schemeFieldCount: number
taskCount: number
activeTaskCount: number
latestTaskStatus: string | null
latestTaskAtRaw: string | null
}
export type DingWorkflowSyncStatus = 'pending' | 'syncing' | 'ready' | 'error'
export type OngoingDingWorkflowSummary = {
total: number
lastSyncedAt: string | null
syncStatus: DingWorkflowSyncStatus
}
export type OngoingDingWorkflowListItem = {
id: string
businessId: string | null
title: string
status: string
originatorUserId: string | null
originatorDeptName: string | null
sourceCreatedAt: string | null
sourceUpdatedAt: string | null
activeTaskCount: number
taskCount: number
schemeFieldCount: number
latestTaskStatus: string | null
}
export type OngoingDingWorkflowPage = {
items: OngoingDingWorkflowListItem[]
pagination: {
page: number
pageSize: number
total: number
totalPages: number
}
sync: OngoingDingWorkflowSummary
}
export class DingWorkflowMetricApiError extends Error {
status: number
constructor(message: string, status = 400) {
super(message)
this.status = status
}
}
function clean(value: unknown): string | null {
if (value === null || value === undefined) return null
const normalized = String(value).trim()
return normalized || null
}
export function normalizeDingWorkflowProcessCode(value: unknown): string | null {
return clean(value)?.toUpperCase() || null
}
export function isAllowedDingWorkflowProcessCode(value: unknown): boolean {
const processCode = normalizeDingWorkflowProcessCode(value)
return Boolean(processCode && ALLOWED_PROCESS_CODE_SET.has(processCode))
}
function nonNegativeInteger(value: unknown): number {
const number = Number(value)
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0
}
function quoteIdentifier(value: string) {
return '[' + String(value).replace(/]/g, ']]') + ']'
}
function tableRef(table: { schema: string; table: string }) {
return `${quoteIdentifier(table.schema)}.${quoteIdentifier(table.table)}`
}
export function isDingWorkflowOngoing(status: unknown, finishedAt: unknown): boolean {
const normalizedStatus = String(status || '').trim().toUpperCase()
return Boolean(normalizedStatus)
&& !TERMINAL_WORKFLOW_STATUSES.has(normalizedStatus)
&& !clean(finishedAt)
}
export function sourceWorkflowTimestampToIso(value: unknown): string | null {
const normalized = clean(value)
if (!normalized) return null
const hasTimeZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
const candidate = hasTimeZone
? normalized
: `${normalized.replace(' ', 'T')}+08:00`
const parsed = new Date(candidate)
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
function maxSourceCursor(rows: WorkflowSnapshotPayload[], current: string | null) {
return rows.reduce<string | null>((latest, row) => {
const candidate = row.sourceUpdatedAtRaw
if (!candidate) return latest
return !latest || candidate > latest ? candidate : latest
}, current)
}
export function isReportWorkflowDbConfigured() {
return Boolean(
process.env.REPORT_DB_SERVER
&& process.env.REPORT_DB_DATABASE
&& process.env.REPORT_DB_USER
&& process.env.REPORT_DB_PASSWORD,
)
}
function buildSourceConfig() {
return {
server: process.env.REPORT_DB_SERVER as string,
database: process.env.REPORT_DB_DATABASE as string,
user: process.env.REPORT_DB_USER as string,
password: process.env.REPORT_DB_PASSWORD as string,
port: Number(process.env.REPORT_DB_PORT || 1433),
options: {
encrypt: process.env.REPORT_DB_ENCRYPT === 'true',
trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false',
},
connectionTimeout: 20_000,
requestTimeout: 300_000,
pool: { max: 3, min: 0, idleTimeoutMillis: 30_000 },
}
}
async function assertDashboardAccess(session: Session | null) {
const userId = session?.user?.id
if (!userId) throw new DingWorkflowMetricApiError('请先登录', 401)
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, isSuperAdmin: true, isActive: true, status: true },
})
if (!user || !user.isActive || user.status === 'archived' || user.status === 'suspended') {
throw new DingWorkflowMetricApiError('账号不可用,请重新登录', 401)
}
const allowed = await resolveBrandCenterAccessForUser(userId, {
role: user.role,
isSuperAdmin: user.isSuperAdmin,
})
if (!allowed) throw new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403)
}
async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null) {
const request = pool.request()
request.input('cursor', mssql.NVarChar(64), cursor)
DING_WORKFLOW_ALLOWED_PROCESS_CODES.forEach((processCode, index) => {
request.input(`allowedProcessCode${index}`, mssql.NVarChar(255), processCode)
})
const result = await request.query<SourceWorkflowRow>(`
SELECT
CONVERT(nvarchar(255), processInstanceId) AS processInstanceId,
CONVERT(nvarchar(255), businessId) AS businessId,
CONVERT(nvarchar(max), title) AS title,
CONVERT(nvarchar(100), result) AS result,
CONVERT(nvarchar(255), originatorUserId) AS originatorUserId,
CONVERT(nvarchar(255), originatorDeptId) AS originatorDeptId,
CONVERT(nvarchar(500), originatorDeptName) AS originatorDeptName,
CONVERT(nvarchar(100), status) AS status,
CONVERT(nvarchar(100), bizAction) AS bizAction,
CONVERT(nvarchar(255), processCode) AS processCode,
CONVERT(nvarchar(100), flowType) AS flowType,
CONVERT(nvarchar(64), joinAt) AS joinedAtRaw,
CONVERT(nvarchar(64), createAt) AS sourceCreatedAtRaw,
CONVERT(nvarchar(64), updateAt) AS sourceUpdatedAtRaw,
CONVERT(nvarchar(64), finishTime) AS sourceFinishedAtRaw
FROM ${tableRef(WORKFLOW_TABLE)}
WHERE
UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(255), processCode), '')))) IN (
@allowedProcessCode0,
@allowedProcessCode1
)
AND (
@cursor IS NULL
OR UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED')
OR TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), updateAt), '')) >=
DATEADD(minute, -5, TRY_CONVERT(datetime2, @cursor))
)
`)
return result.recordset
}
async function fetchOngoingAggregates(pool: ConnectionPool, processInstanceIds: string[]) {
if (processInstanceIds.length === 0) {
return {
taskByWorkflow: new Map<string, SourceTaskAggregate>(),
schemeByWorkflow: new Map<string, SourceSchemeAggregate>(),
}
}
const request = pool.request()
request.multiple = true
request.input('workflowIds', mssql.NVarChar(mssql.MAX), JSON.stringify(processInstanceIds))
const result = await request.query(`
;WITH workflow_ids AS (
SELECT CONVERT(nvarchar(255), [value]) AS processInstanceId
FROM OPENJSON(@workflowIds)
), task_rows AS (
SELECT
CONVERT(nvarchar(255), task.processInstanceId) AS processInstanceId,
CONVERT(nvarchar(100), task.status) AS status,
CONVERT(nvarchar(64), task.createTime) AS createTime,
ROW_NUMBER() OVER (
PARTITION BY task.processInstanceId
ORDER BY TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), task.createTime), '')) DESC, task.id DESC
) AS rowNumber
FROM ${tableRef(WORKFLOW_TASK_TABLE)} task
INNER JOIN workflow_ids ids
ON ids.processInstanceId = CONVERT(nvarchar(255), task.processInstanceId)
)
SELECT
processInstanceId,
COUNT_BIG(*) AS taskCount,
SUM(CASE WHEN UPPER(LTRIM(RTRIM(COALESCE(status, '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED') THEN 1 ELSE 0 END) AS activeTaskCount,
MAX(CASE WHEN rowNumber = 1 THEN status END) AS latestTaskStatus,
MAX(CASE WHEN rowNumber = 1 THEN createTime END) AS latestTaskAtRaw
FROM task_rows
GROUP BY processInstanceId;
;WITH workflow_ids AS (
SELECT CONVERT(nvarchar(255), [value]) AS processInstanceId
FROM OPENJSON(@workflowIds)
)
SELECT
CONVERT(nvarchar(255), scheme.processInstanceId) AS processInstanceId,
COUNT_BIG(*) AS schemeFieldCount
FROM ${tableRef(WORKFLOW_SCHEME_TABLE)} scheme
INNER JOIN workflow_ids ids
ON ids.processInstanceId = CONVERT(nvarchar(255), scheme.processInstanceId)
GROUP BY scheme.processInstanceId;
`)
const taskRows = (result.recordsets?.[0] || []) as SourceTaskAggregate[]
const schemeRows = (result.recordsets?.[1] || []) as SourceSchemeAggregate[]
return {
taskByWorkflow: new Map(taskRows.map((row) => [String(row.processInstanceId), row])),
schemeByWorkflow: new Map(schemeRows.map((row) => [String(row.processInstanceId), row])),
}
}
function buildSnapshotPayload(
sourceRows: SourceWorkflowRow[],
taskByWorkflow: Map<string, SourceTaskAggregate>,
schemeByWorkflow: Map<string, SourceSchemeAggregate>,
): WorkflowSnapshotPayload[] {
return sourceRows.flatMap((row) => {
const processInstanceId = clean(row.processInstanceId)
const status = clean(row.status)
if (!processInstanceId || !status) return []
const ongoing = isDingWorkflowOngoing(status, row.sourceFinishedAtRaw)
const task = taskByWorkflow.get(processInstanceId)
const scheme = schemeByWorkflow.get(processInstanceId)
return [{
processInstanceId,
businessId: clean(row.businessId),
title: clean(row.title),
result: clean(row.result),
originatorUserId: clean(row.originatorUserId),
originatorDeptId: clean(row.originatorDeptId),
originatorDeptName: clean(row.originatorDeptName),
status: status.toUpperCase(),
bizAction: clean(row.bizAction),
processCode: normalizeDingWorkflowProcessCode(row.processCode),
flowType: clean(row.flowType),
joinedAtRaw: clean(row.joinedAtRaw),
sourceCreatedAtRaw: clean(row.sourceCreatedAtRaw),
sourceUpdatedAtRaw: clean(row.sourceUpdatedAtRaw),
sourceFinishedAtRaw: clean(row.sourceFinishedAtRaw),
isOngoing: ongoing,
schemeFieldCount: ongoing ? nonNegativeInteger(scheme?.schemeFieldCount) : 0,
taskCount: ongoing ? nonNegativeInteger(task?.taskCount) : 0,
activeTaskCount: ongoing ? nonNegativeInteger(task?.activeTaskCount) : 0,
latestTaskStatus: ongoing ? clean(task?.latestTaskStatus)?.toUpperCase() || null : null,
latestTaskAtRaw: ongoing ? clean(task?.latestTaskAtRaw) : null,
}]
})
}
async function upsertSnapshotBatch(rows: WorkflowSnapshotPayload[], syncedAt: Date) {
if (rows.length === 0) return
await prisma.$executeRawUnsafe(`
INSERT INTO "ding_workflow_snapshots" (
"process_instance_id", "business_id", "title", "result",
"originator_user_id", "originator_dept_id", "originator_dept_name",
"status", "biz_action", "process_code", "flow_type", "joined_at_raw",
"source_created_at_raw", "source_updated_at_raw", "source_finished_at_raw",
"is_ongoing", "scheme_field_count", "task_count", "active_task_count",
"latest_task_status", "latest_task_at_raw", "synced_at", "updated_at"
)
SELECT
incoming."processInstanceId", incoming."businessId", incoming."title", incoming."result",
incoming."originatorUserId", incoming."originatorDeptId", incoming."originatorDeptName",
incoming."status", incoming."bizAction", incoming."processCode", incoming."flowType", incoming."joinedAtRaw",
incoming."sourceCreatedAtRaw", incoming."sourceUpdatedAtRaw", incoming."sourceFinishedAtRaw",
incoming."isOngoing", incoming."schemeFieldCount", incoming."taskCount", incoming."activeTaskCount",
incoming."latestTaskStatus", incoming."latestTaskAtRaw", $2::timestamp, $2::timestamp
FROM jsonb_to_recordset($1::jsonb) AS incoming(
"processInstanceId" text, "businessId" text, "title" text, "result" text,
"originatorUserId" text, "originatorDeptId" text, "originatorDeptName" text,
"status" text, "bizAction" text, "processCode" text, "flowType" text, "joinedAtRaw" text,
"sourceCreatedAtRaw" text, "sourceUpdatedAtRaw" text, "sourceFinishedAtRaw" text,
"isOngoing" boolean, "schemeFieldCount" integer, "taskCount" integer, "activeTaskCount" integer,
"latestTaskStatus" text, "latestTaskAtRaw" text
)
ON CONFLICT ("process_instance_id") DO UPDATE SET
"business_id" = EXCLUDED."business_id",
"title" = EXCLUDED."title",
"result" = EXCLUDED."result",
"originator_user_id" = EXCLUDED."originator_user_id",
"originator_dept_id" = EXCLUDED."originator_dept_id",
"originator_dept_name" = EXCLUDED."originator_dept_name",
"status" = EXCLUDED."status",
"biz_action" = EXCLUDED."biz_action",
"process_code" = EXCLUDED."process_code",
"flow_type" = EXCLUDED."flow_type",
"joined_at_raw" = EXCLUDED."joined_at_raw",
"source_created_at_raw" = EXCLUDED."source_created_at_raw",
"source_updated_at_raw" = EXCLUDED."source_updated_at_raw",
"source_finished_at_raw" = EXCLUDED."source_finished_at_raw",
"is_ongoing" = EXCLUDED."is_ongoing",
"scheme_field_count" = EXCLUDED."scheme_field_count",
"task_count" = EXCLUDED."task_count",
"active_task_count" = EXCLUDED."active_task_count",
"latest_task_status" = EXCLUDED."latest_task_status",
"latest_task_at_raw" = EXCLUDED."latest_task_at_raw",
"synced_at" = EXCLUDED."synced_at",
"updated_at" = EXCLUDED."updated_at"
`, JSON.stringify(rows), syncedAt)
}
async function acquireSyncLease(leaseOwner: string, startedAt: Date) {
await prisma.dingWorkflowSyncState.upsert({
where: { id: SYNC_STATE_ID },
create: { id: SYNC_STATE_ID },
update: {},
})
const claimed = await prisma.dingWorkflowSyncState.updateMany({
where: {
id: SYNC_STATE_ID,
OR: [
{ leaseExpiresAt: null },
{ leaseExpiresAt: { lt: startedAt } },
{ leaseOwner },
],
},
data: {
status: 'syncing',
lastStartedAt: startedAt,
lastError: null,
leaseOwner,
leaseExpiresAt: new Date(startedAt.getTime() + LEASE_DURATION_MS),
},
})
if (claimed.count !== 1) return null
return prisma.dingWorkflowSyncState.findUniqueOrThrow({ where: { id: SYNC_STATE_ID } })
}
export async function syncDingWorkflowSnapshots() {
if (!isReportWorkflowDbConfigured()) {
throw new Error('未配置上游报表库(REPORT_DB_*),无法同步钉钉流程')
}
const startedAt = new Date()
const leaseOwner = `${hostname()}:${process.pid}:${randomUUID()}`
const state = await acquireSyncLease(leaseOwner, startedAt)
if (!state) return { skipped: true, rowsUpserted: 0, rowsDeleted: 0, ongoingCount: null }
const pool = new mssql.ConnectionPool(buildSourceConfig())
try {
await pool.connect()
const sourceRows = await fetchSourceWorkflows(pool, state.sourceCursorRaw)
const ongoingIds = sourceRows.flatMap((row) => {
const id = clean(row.processInstanceId)
return id && isDingWorkflowOngoing(row.status, row.sourceFinishedAtRaw) ? [id] : []
})
const { taskByWorkflow, schemeByWorkflow } = await fetchOngoingAggregates(pool, ongoingIds)
const rows = buildSnapshotPayload(sourceRows, taskByWorkflow, schemeByWorkflow)
const syncedAt = new Date()
for (let index = 0; index < rows.length; index += UPSERT_BATCH_SIZE) {
await upsertSnapshotBatch(rows.slice(index, index + UPSERT_BATCH_SIZE), syncedAt)
}
const deleted = await prisma.dingWorkflowSnapshot.deleteMany({
where: {
OR: [
{ processCode: null },
{ processCode: { notIn: ALLOWED_PROCESS_CODE_VALUES } },
],
},
})
const ongoingCount = await prisma.dingWorkflowSnapshot.count({
where: {
isOngoing: true,
processCode: { in: ALLOWED_PROCESS_CODE_VALUES },
},
})
const sourceCursorRaw = maxSourceCursor(rows, state.sourceCursorRaw)
await prisma.dingWorkflowSyncState.updateMany({
where: { id: SYNC_STATE_ID, leaseOwner },
data: {
status: 'ready',
sourceCursorRaw,
lastCompletedAt: syncedAt,
lastError: null,
leaseOwner: null,
leaseExpiresAt: null,
rowsUpserted: rows.length,
currentOngoingCount: ongoingCount,
},
})
return { skipped: false, rowsUpserted: rows.length, rowsDeleted: deleted.count, ongoingCount }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await prisma.dingWorkflowSyncState.updateMany({
where: { id: SYNC_STATE_ID, leaseOwner },
data: {
status: 'error',
lastError: message.slice(0, 2_000),
leaseOwner: null,
leaseExpiresAt: null,
},
}).catch(() => undefined)
throw error
} finally {
await pool.close().catch(() => undefined)
}
}
function normalizeSyncStatus(state: { status: string; lastCompletedAt: Date | null } | null): DingWorkflowSyncStatus {
if (!state?.lastCompletedAt) return state?.status === 'syncing' ? 'syncing' : state?.status === 'error' ? 'error' : 'pending'
if (state.status === 'syncing') return 'syncing'
if (state.status === 'error') return 'error'
return 'ready'
}
async function readSummaryWithoutAccessCheck(): Promise<OngoingDingWorkflowSummary> {
const [total, state] = await prisma.$transaction([
prisma.dingWorkflowSnapshot.count({
where: {
isOngoing: true,
processCode: { in: ALLOWED_PROCESS_CODE_VALUES },
},
}),
prisma.dingWorkflowSyncState.findUnique({
where: { id: SYNC_STATE_ID },
select: { status: true, lastCompletedAt: true },
}),
])
return {
total,
lastSyncedAt: state?.lastCompletedAt?.toISOString() || null,
syncStatus: normalizeSyncStatus(state),
}
}
export async function getOngoingDingWorkflowSummary(session: Session | null) {
await assertDashboardAccess(session)
return readSummaryWithoutAccessCheck()
}
export async function getOngoingDingWorkflowProjects(
session: Session | null,
params?: { page?: number; pageSize?: number; query?: string },
): Promise<OngoingDingWorkflowPage> {
await assertDashboardAccess(session)
const page = Math.max(1, Number(params?.page || 1) || 1)
const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20))
const query = clean(params?.query)
const where = {
isOngoing: true,
processCode: { in: ALLOWED_PROCESS_CODE_VALUES },
...(query ? {
OR: [
{ title: { contains: query, mode: 'insensitive' as const } },
{ businessId: { contains: query, mode: 'insensitive' as const } },
{ processInstanceId: { contains: query, mode: 'insensitive' as const } },
{ originatorDeptName: { contains: query, mode: 'insensitive' as const } },
{ originatorUserId: { contains: query, mode: 'insensitive' as const } },
],
} : {}),
}
const [total, rows, sync] = await Promise.all([
prisma.dingWorkflowSnapshot.count({ where }),
prisma.dingWorkflowSnapshot.findMany({
where,
select: {
processInstanceId: true,
businessId: true,
title: true,
status: true,
originatorUserId: true,
originatorDeptName: true,
sourceCreatedAtRaw: true,
sourceUpdatedAtRaw: true,
activeTaskCount: true,
taskCount: true,
schemeFieldCount: true,
latestTaskStatus: true,
},
orderBy: [
{ sourceUpdatedAtRaw: { sort: 'desc', nulls: 'last' } },
{ processInstanceId: 'desc' },
],
skip: (page - 1) * pageSize,
take: pageSize,
}),
readSummaryWithoutAccessCheck(),
])
return {
items: rows.map((row) => ({
id: row.processInstanceId,
businessId: row.businessId,
title: row.title || '未命名流程',
status: row.status,
originatorUserId: row.originatorUserId,
originatorDeptName: row.originatorDeptName,
sourceCreatedAt: sourceWorkflowTimestampToIso(row.sourceCreatedAtRaw),
sourceUpdatedAt: sourceWorkflowTimestampToIso(row.sourceUpdatedAtRaw),
activeTaskCount: row.activeTaskCount,
taskCount: row.taskCount,
schemeFieldCount: row.schemeFieldCount,
latestTaskStatus: row.latestTaskStatus,
})),
pagination: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
sync,
}
}
type DingWorkflowSchedulerGlobal = typeof globalThis & {
__dingWorkflowHourlySyncStarted?: boolean
}
export function ensureHourlyDingWorkflowSyncStarted() {
const schedulerGlobal = globalThis as DingWorkflowSchedulerGlobal
if (schedulerGlobal.__dingWorkflowHourlySyncStarted) return
schedulerGlobal.__dingWorkflowHourlySyncStarted = true
if (!isReportWorkflowDbConfigured()) {
console.info('[ding-workflow-sync] 未配置上游报表库,跳过每小时同步')
return
}
const configuredHours = Number(process.env.DING_WORKFLOW_SYNC_INTERVAL_HOURS || 1)
const intervalMs = Number.isFinite(configuredHours) && configuredHours > 0
? Math.max(5 * 60 * 1000, configuredHours * 60 * 60 * 1000)
: DEFAULT_SYNC_INTERVAL_MS
const runOnce = () => {
void syncDingWorkflowSnapshots()
.then((result) => {
if (result.skipped) {
console.info('[ding-workflow-sync] 另一实例正在同步,本轮跳过')
return
}
console.info(`[ding-workflow-sync] 同步完成:更新 ${result.rowsUpserted} 条,清理 ${result.rowsDeleted} 条,进行中 ${result.ongoingCount} 条`)
})
.catch((error) => console.warn('[ding-workflow-sync] 同步失败:', error))
}
const initialTimer = setTimeout(runOnce, INITIAL_SYNC_DELAY_MS)
initialTimer.unref?.()
const interval = setInterval(runOnce, intervalMs)
interval.unref?.()
console.info(`[ding-workflow-sync] 已调度每小时增量同步,周期 ${Math.round(intervalMs / 60_000)} 分钟`)
}