feat: add report material workflows and gateway safeguards

This commit is contained in:
hectorzhao
2026-07-15 18:23:48 +08:00
parent cf9f4ce4cd
commit 7091a8bed4
41 changed files with 3606 additions and 71 deletions
+139 -1
View File
@@ -103,6 +103,33 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise<
return response.blob();
}
async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
const headers = new Headers();
const session = readSession();
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
if (response.status === 401 && session) {
const body = await readErrorBody(response.clone());
if (body.code === 'SESSION_LOCKED') {
dispatchSessionEvent('locked', { message: body.message });
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
}
clearSession();
dispatchSessionEvent('logout', { code: body.code, message: body.message });
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
throw new Error('登录会话已失效,请重新登录');
}
if (response.status === 403 && session && !reauthenticationAttempted) {
const body = await readErrorBody(response.clone());
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
await requestReauthentication();
return requestForm<T>(path, form, true);
}
}
if (!response.ok) throw new Error(await readErrorMessage(response));
return response.json() as Promise<T>;
}
export type AdminChannel = {
id: string;
code: string;
@@ -556,7 +583,6 @@ export type ChannelGroupItem = DictionaryItem & {
priority: number;
weight?: number;
isBackup?: boolean;
rateLimitPerSecond?: number | null;
channel?: AdminChannel;
};
@@ -570,9 +596,53 @@ export type ChannelReportField = DictionaryItem & {
required: boolean;
description?: string | null;
sortOrder?: number;
exportName?: string | null;
columnWidth?: number;
imageWidth?: number;
imageHeight?: number;
defaultValue?: string | null;
transform?: string | null;
drainageField?: DictionaryItem | null;
};
export type ReportMaterialPendingItem = {
id: string;
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string | null;
materialVersion: number;
changedAt: string;
name: string;
detail?: string | null;
signatureName?: string;
tenant?: TenantOption;
application?: ClientSmsApplication | null;
};
export type ReportImportMapping = {
sourceHeader: string;
sourceHeaderPath?: string;
sourceColumnIndex: number;
targetFieldCode: string;
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
fieldType: 'string' | 'image' | 'file';
required?: boolean;
transform?: string;
sortOrder?: number;
};
export type ReportImportProfile = {
id: string;
name: string;
reportType: 'signature' | 'drainage';
tenantId?: string | null;
applicationId?: string | null;
sheetName?: string | null;
headerRowCount: number;
dataStartRow: number;
columns: ReportImportMapping[];
};
export type ApplicationReportField = {
id: string;
code: string;
@@ -962,6 +1032,51 @@ export type GatewayDownstreamRecoveryStatus = {
application?: EnterpriseApplication | null;
};
export type GatewaySubmitException = {
id: string;
streamMessageId: string;
tenantId?: string | null;
applicationId?: string | null;
channelId?: string | null;
traceId?: string | null;
messageId?: string | null;
submitId?: string | null;
status: 'pending' | 'requeueing' | 'requeued' | 'resolved' | string;
failureCode: string;
failureMessage: string;
attempts: number;
maxAttempts: number;
commandPayload?: Record<string, unknown> | null;
rawPayloadAvailable?: boolean;
messageState?: {
status: string;
submitStatus?: string | null;
receiptStatus?: string | null;
phoneNumber: string;
content: string;
} | null;
manualRetryCount: number;
lastRetryStreamId?: string | null;
lastRetriedAt?: string | null;
resolvedAt?: string | null;
resolvedStatus?: string | null;
createdAt: string;
updatedAt: string;
tenant?: Pick<TenantOption, 'id' | 'name' | 'code' | 'status'> | null;
application?: Pick<EnterpriseApplication, 'id' | 'tenantId' | 'name' | 'status'> | null;
channel?: Pick<AdminChannel, 'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond'> | null;
};
export type GatewaySubmitExceptionResponse = PagedResponse<GatewaySubmitException> & {
summary: {
pending: number;
requeueing: number;
requeued: number;
resolved: number;
oldestPendingAt?: string | null;
};
};
export type DownstreamRecoveryStatusResponse = PagedResponse<GatewayDownstreamRecoveryStatus> & {
summary: {
total: number;
@@ -1163,6 +1278,25 @@ export const adminApi = {
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array<Record<string, unknown>>) =>
request<ChannelReportField[]>(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }),
listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } = {}) =>
request<ReportMaterialPendingItem[]>(withQuery('/admin/report-materials/pending', query)),
listReportImportProfiles: (reportType?: 'signature' | 'drainage') =>
request<ReportImportProfile[]>(withQuery('/admin/report-materials/import-profiles', { reportType })),
saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) =>
request<ReportImportProfile>('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }),
analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => {
const form = new FormData();
form.set('file', file);
Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); });
return requestForm<Record<string, unknown> & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array<Record<string, unknown>>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form);
},
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
createReportMaterialBatch: (body: { createdById?: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }> }) =>
request<Record<string, unknown>>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
@@ -1188,6 +1322,10 @@ export const adminApi = {
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),