import { clearSession, currentRouteForPortal, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, portalFromPath, readSession, redirectToPortalLogin, requestReauthentication, saveSessionRecovery, type LoginSession, type Portal, } from './session'; import { assertUploadFileSize } from '@/utils/fileUpload'; type RequestOptions = RequestInit & { tenantId?: string; reauthenticationAttempted?: boolean; suppressSessionRedirect?: boolean; }; export type DeletionTargetType = 'channel' | 'signature' | 'template'; export type DeletionDependency = { kind: string; label: string; count: number; items: string[] }; export type DeletionPreflight = { type: DeletionTargetType; id: string; expectedUpdatedAt: string; identity: Record; dependencies: DeletionDependency[]; impacts: string[]; blockedReasons: string[]; allowedActions: Array<'delete'>; recoverability: { mode: 'soft_delete'; description: string }; }; export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string }; export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean }; type ApiErrorBody = { message?: string | string[]; error?: string; code?: string }; async function readErrorBody(response: Response): Promise { const text = await response.text(); if (!text) return {}; try { return JSON.parse(text) as ApiErrorBody; } catch { return { message: text }; } } export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a'; type SessionTiming = Pick; function requestPortal(path: string): Portal | undefined { return portalFromPath(path); } async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) { if (!portal) return false; const session = readSession(portal); const body = await readErrorBody(response.clone()); if (body.code === 'SESSION_LOCKED' && session) { dispatchSessionEvent(portal, 'locked', { message: body.message }); throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定'); } if (suppressRedirect) return false; if (session) { saveSessionRecovery(portal, { returnUrl: currentRouteForPortal(portal), code: body.code, message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录', }); } clearSession(portal); dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message }); redirectToPortalLogin(portal); throw new Error('登录会话已失效,请重新登录'); } async function readErrorMessage(response: Response) { const fallback = `请求失败(${response.status})`; const text = await response.text(); if (!text) return fallback; try { const parsed = JSON.parse(text) as { message?: string | string[]; error?: string }; if (Array.isArray(parsed.message)) return parsed.message.join(';'); if (parsed.message) return parsed.message; if (parsed.error) return parsed.error; } catch { return text; } return text; } async function request(path: string, options: RequestOptions = {}): Promise { const headers = new Headers(options.headers); headers.set('Content-Type', 'application/json'); const portal = requestPortal(path); const session = portal ? readSession(portal) : null; if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); if (tenantId) { headers.set('x-tenant-id', tenantId); } const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login'; if (response.status === 401 && !isLoginAttempt) { await handleSessionFailure(response, portal, options.suppressSessionRedirect); } if (response.status === 403 && session && !options.reauthenticationAttempted) { const body = await readErrorBody(response.clone()); if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { await requestReauthentication(); return request(path, { ...options, reauthenticationAttempted: true }); } } if (!response.ok) { throw new Error(await readErrorMessage(response)); } return response.json() as Promise; } async function requestBlob(path: string, options: RequestOptions = {}): Promise { const headers = new Headers(options.headers); const portal = requestPortal(path); const session = portal ? readSession(portal) : null; if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); if (tenantId) { headers.set('x-tenant-id', tenantId); } const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); if (response.status === 401) { await handleSessionFailure(response, portal, options.suppressSessionRedirect); } if (response.status === 403 && session && !options.reauthenticationAttempted) { const body = await readErrorBody(response.clone()); if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') { await requestReauthentication(); return requestBlob(path, { ...options, reauthenticationAttempted: true }); } } if (!response.ok) { throw new Error(await readErrorMessage(response)); } return response.blob(); } async function requestForm(path: string, form: FormData, reauthenticationAttempted = false): Promise { const headers = new Headers(); const portal = requestPortal(path); const session = portal ? readSession(portal) : null; 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) { await handleSessionFailure(response, portal); 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(path, form, true); } } if (!response.ok) throw new Error(await readErrorMessage(response)); return response.json() as Promise; } export type AdminChannel = { id: string; code: string; name: string; carrier?: string | null; sendRegion?: string | null; gatewayHost: string; gatewayPort: number; enterpriseCode?: string | null; account: string; srcId: string; cmppVersion?: '2.0' | '3.0' | string | null; rateLimitPerSecond: number; unitPrice: number; status: string; config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null; connectionStates?: CmppConnectionState[]; }; export type ChannelConnectionLogResponse = { channelId: string; connectionStates: CmppConnectionState[]; logs: Array<{ id: string; time: string; event: string; action: string; resourceId?: string; detail?: unknown; }>; }; export type ChannelTestResponse = { channelId: string; status: string; testNo: string; submitted: number; messages: Array<{ phoneNumber: string; messageRecordId: string; submitId: string; streamMessageId?: string; }>; queuedAt: string; }; export type EnterpriseCertification = { id: string; tenantId: string; companyName: string; licenseNo?: string | null; contactName?: string | null; contactPhone?: string | null; materials?: Record | null; status: string; rejectReason?: string | null; submittedAt: string; reviewedAt?: string | null; tenant?: { id: string; name: string; code: string }; }; export type SmsTemplateAudit = { id: string; tenantId: string; applicationId: string; name: string; content: string; category?: string | null; auditStatus: string; rejectReason?: string | null; createdAt: string; updatedAt: string; application?: { name: string }; tenant?: { name: string }; }; export type ReviewPreflight = { type: 'signature' | 'template'; id: string; tenantId: string; status: string; expectedUpdatedAt: string; identity: Record; impacts: string[]; materialSummary: Record; blockedReasons: string[]; allowedActions: Array<'approve' | 'reject'>; }; export type ReviewDecisionResult = { operationId: string; replayed: boolean; decision: 'approve' | 'reject'; status: string; item: ClientSmsSignature | SmsTemplateAudit; }; export type TenantOption = { id: string; name: string; code: string; status: string; enterpriseProfile?: { creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string; } | null; }; export type TenantManagementRow = TenantOption & { account?: TenantAccount | null; todaySpendCents: number; todayRefundCents: number; }; export type CaptchaResponse = { captchaId: string; challenge: string; expiresInSeconds: number; }; export type ManagedUser = { id: string; tenantId?: string | null; username: string; email?: string | null; phone?: string | null; displayName: string; status: string; failedLoginCount: number; lockedUntil?: string | null; lastLoginAt?: string | null; createdAt: string; tenant?: TenantOption | null; roles: Array<{ role: { code: string; name: string; scope: string } }>; }; export type UserPayload = { tenantId?: string | null; username?: string; email?: string | null; phone?: string | null; displayName: string; password?: string; status?: string; roleCode: 'platform_admin' | 'enterprise_admin'; operatorId?: string; }; export type DashboardResponse = { taskCount: number; messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; returnedCents: number; billingUnits: number }; uplinkCount: number; billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } }; gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; pendingAuditCount: number; pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number }; downstreamDeliverySummary?: { pending: number; failed: number; delivered: number; stalledPending: number; stalledAck: number; recentFailed: number; alertCount: number; }; accounts: Array<{ id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; tenant?: TenantOption }>; recentTasks: Array>; recentRecharges: Array; }; export type ChannelQualityStat = { channelId: string; channelName: string; total: number; acceptedCount: number; submitFailureCount: number; submitFailureRate: number; successCount: number; unknownCount: number; failureCount: number; successRate: number; unknownRate: number; failureRate: number; averageArrivalMs?: number | null; }; export type SignatureQualityStat = { id: string; signatureId: string; signatureName: string; tenantId: string; tenantName: string; hasDrainage: boolean; total: number; successCount: number; unknownCount: number; failureCount: number; successRate: number; averageArrivalMs?: number | null; }; export type DailySendSummary = { total: number; successCount: number; unknownCount: number; failureCount: number; successRate: number; }; export type ApplicationQualityStat = DailySendSummary & { applicationId: string; applicationName: string; tenantId: string; tenantName: string; }; export type SendQualityResponse = { date: string; summary: DailySendSummary; channels: ChannelQualityStat[]; signatures: SignatureQualityStat[]; applications: ApplicationQualityStat[]; }; export type RechargeOrder = { id: string; tenantId: string; orderNo: string; amountCents: number; status: string; payMethod?: string | null; paidAt?: string | null; operatorId?: string | null; remark?: string | null; balanceAfterCents?: number | null; createdAt: string; tenant?: TenantOption; }; export type ManualRechargePreflight = { tenant: Pick; accountId: string; expectedAccountUpdatedAt: string; balanceCents: number; creditCents: number; amountCents: number; balanceAfterCents: number; direction: 'topup' | 'correction'; allowedActions: Array<'confirm'>; blockedReasons: string[]; }; export type ManualRechargeResult = RechargeOrder & { balanceAfterCents: number; operationId: string; replayed: boolean; }; export type ClientSmsApplication = { id: string; tenantId: string; name: string; scene?: string | null; customerUnitPrice?: number | null; queuePriority?: 'normal' | 'priority' | string | null; status: string; dailyLimit?: number | null; createdAt?: string; updatedAt?: string; sentToday?: number; deliveryRate?: number; cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; interfaceEnabled?: boolean | null; cmppConnections?: CmppDownstreamConnection[]; httpConfig?: HttpApiConfig | null; }; export type ClientSmsSignature = { id: string; tenantId: string; applicationId?: string | null; name: string; purpose?: string | null; drainageInfo?: Record | null; auditStatus: string; rejectReason?: string | null; createdAt: string; updatedAt: string; materials?: Array>; tenant?: TenantOption; application?: ClientSmsApplication | null; reportStatus?: string; reportTasks?: Array; reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>; carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; drainageReportTargets?: Record>; drainageCarrierReportSummary?: Record>; }; export type ClientSmsSignatureView = Pick & { pendingReport?: boolean; reportChangedAt?: string; application?: Pick | null; submittedMaterialCount: number; reportValues: Record; drainageInfo: { links: Array<{ id: string; siteName: string; url: string; remark?: string | null; reportValues: Record; auditStatus: string; rejectReason?: string | null; submittedAt: string; reviewedAt?: string | null; createdAt: string; updatedAt: string; }> }; }; export type ClientSignatureWorkspace = { items: ClientSmsSignatureView[]; summary: { total: number; pending: number; approved: number; rejected: number; draft: number }; }; export type SmsDrainageInfo = { id: string; tenantId: string; signatureId: string; applicationId?: string | null; siteName: string; url: string; remark?: string | null; reportValues?: Record | null; auditStatus: string; rejectReason?: string | null; submittedAt: string; reviewedAt?: string | null; createdAt: string; updatedAt: string; tenant?: TenantOption; signature?: ClientSmsSignature; application?: ClientSmsApplication | null; reportTasks?: ReportTask[]; }; export type ClientSmsTemplate = { id: string; tenantId: string; applicationId: string; signatureId?: string | null; name: string; content: string; category?: string | null; auditStatus: string; rejectReason?: string | null; createdAt: string; updatedAt: string; variables?: Array<{ name: string; example?: string | null; required?: boolean }>; application?: { id: string; name: string }; signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record | null }; tenant?: TenantOption; }; export type SmsBatchTask = { id: string; tenantId: string; taskNo: string; sourceType?: string; contentHash?: string | null; windowStartedAt?: string | null; windowEndsAt?: string | null; applicationId?: string | null; templateId?: string | null; content: string; category?: string | null; phoneTotal: number; status: string; auditStatus?: string | null; reviewReason?: string | null; rejectReason?: string | null; progressTotal: number; progressSent?: number; progressDelivered?: number; progressFailed?: number; submittedTotal?: number; successTotal?: number; failedTotal?: number; unknownTotal?: number; timeoutTotal?: number; scheduledAt?: string | null; canceledAt?: string | null; createdAt: string; tenant?: TenantOption; application?: { id: string; name: string }; template?: { id: string; name: string; content: string; billingUnits?: number }; messages?: SmsMessageRecord[]; messageStats?: Array<{ batchTaskId?: string | null; carrier?: string | null; province?: string | null; status: string; _count: { _all: number }; _sum: { billingUnits?: number | null }; }>; }; export type ImportPreviewResponse = { fileName?: string; encoding: string; totalRows: number; validCount: number; errorCount: number; phones: string[]; errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }>; }; export type SmsMessageRecord = { id: string; tenantId?: string | null; batchTaskId?: string | null; applicationId?: string | null; channelId?: string | null; messageId: string; phoneNumber: string; carrier?: string | null; province?: string | null; content: string; clientSrcId?: string | null; applicationExtension?: string | null; billingUnits: number; amountCents: number; status: string; errorMessage?: string | null; errorCode?: string | null; queuedAt: string; submittedAt?: string | null; deliveredAt?: string | null; receiptStatus?: string | null; submitStatus?: string | null; channel?: AdminChannel | null; tenant?: TenantOption | null; application?: { id: string; name: string }; submitRecords?: SmsSubmitRecord[]; receiptRecords?: SmsReceiptRecord[]; downstreamDeliveries?: Array<{ id: string; deliveryType: string; status: string; deliveredAt?: string | null; lastError?: string | null; }>; }; export type SmsSubmitRecord = { id: string; channelId: string; submitId: string; sequenceId?: number | null; gatewayMessageId?: string | null; submitStatus: string; errorCode?: string | null; errorMessage?: string | null; submittedAt?: string | null; createdAt: string; channel?: AdminChannel | null; }; export type SmsReceiptRecord = { id: string; channelId?: string | null; messageId: string; gatewayMessageId: string; sequenceId?: number | null; receiptStatus: string; rawStatus: string; errorCode?: string | null; deliveredAt: string; createdAt: string; channel?: AdminChannel | null; }; export type SmsMessageSegmentAudit = { id: string; tenantId: string; batchTaskId?: string | null; messageRecordId: string; submitRecordId?: string | null; channelId?: string | null; submitId: string; attempt: number; segmentTotal: number; segmentIndex: number; sequenceId?: number | null; gatewayMessageId?: string | null; submitStatus: string; receiptStatus?: string | null; rawStatus?: string | null; compensationType?: string | null; errorCode?: string | null; errorMessage?: string | null; submittedAt?: string | null; deliveredAt?: string | null; createdAt: string; updatedAt: string; channel?: AdminChannel | null; }; export type SmsUplinkMessage = { id: string; tenantId?: string | null; channelId: string; applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; sequenceId?: number | null; phoneNumber: string; destId: string; content: string; matchStatus?: string; matchReason?: string | null; receivedAt: string; createdAt: string; tenant?: TenantOption | null; application?: { id: string; name: string } | null; messageRecord?: SmsMessageRecord | null; channel?: AdminChannel | null; matchCandidates?: SmsUplinkMatchCandidate[]; }; export type SmsUplinkMatchCandidate = { id: string; uplinkMessageId: string; tenantId: string; applicationId: string; messageRecordId?: string | null; matchSource: string; confidence: number; reason?: string | null; status: string; claimedAt?: string | null; claimedById?: string | null; createdAt: string; updatedAt: string; tenant?: TenantOption | null; application?: { id: string; name: string } | null; messageRecord?: SmsMessageRecord | null; }; export type HttpApiConfig = { enabled: boolean; sendEnabled: boolean; messageQueryEnabled: boolean; receiptWebhookEnabled: boolean; uplinkWebhookEnabled: boolean; uplinkQueryEnabled: boolean; credentialSelfServiceEnabled: boolean; qpsLimit: number; timestampToleranceSeconds: number; maxCredentialCount: number; uplinkRetentionDays: number; maxQueryRangeDays: number; maxPageSize: number; receiptDeliveryMode: 'cmpp' | 'http' | 'both' | 'none'; uplinkDeliveryMode: 'cmpp' | 'http' | 'both' | 'none'; webhookRetryEnabled: boolean; webhookMaxAttempts: number; webhookTimeoutSeconds: number; requireHttps: boolean; allowClientManualRetry: boolean; allowClientTest: boolean; }; export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; config: HttpApiConfig | null; ipAllowlist: string[] }; export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string }; export type HttpWebhookEndpoint = { id: string; eventType: 'receipt' | 'uplink'; url: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; lastTestAt?: string | null; lastTestStatus?: string | null; updatedAt: string }; export type HttpApiRequestLog = { id: string; requestId: string; clientMessageId?: string | null; sourceIp?: string | null; httpStatus?: number | null; businessCode?: string | null; status: string; durationMs?: number | null; createdAt: string; completedAt?: string | null }; export type HttpWebhookDelivery = { id: string; status: string; attemptCount: number; lastHttpStatus?: number | null; lastError?: string | null; createdAt: string; event: { eventId: string; eventType: string; messageId?: string | null }; endpoint: { eventType: string; url: string } }; export type DictionaryItem = Record & { id: string; status?: string; createdAt?: string; updatedAt?: string; }; export type ChannelGroup = DictionaryItem & { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string | null; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: ChannelGroupItem[]; }; export type ChannelGroupItem = DictionaryItem & { groupId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom' | null; province?: string | null; priority: number; weight?: number; isBackup?: boolean; channel?: AdminChannel; }; export type ChannelReportField = DictionaryItem & { channelId: string; drainageFieldId?: string | null; reportType?: 'signature' | 'drainage' | 'both'; code: string; name: string; fieldType: string; 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 ReportMaterialPreflightTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string; }; export type ReportMaterialPreflightItem = { id: string; reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number; name: string; tenantName: string; applicationId?: string; applicationName: string; eligible: boolean; blockedReasons: string[]; targets: ReportMaterialPreflightTarget[]; }; export type ReportMaterialBatchPreflight = { checkedAt: string; eligible: boolean; eligibleItemCount: number; blockedItemCount: number; eligibleTargetCount: number; skippedTargetCount: number; items: ReportMaterialPreflightItem[]; }; export type ReportMaterialBatchResult = Record & { id: string; batchNo: string; status: string; operationId: string; replayed: boolean; result: { successCount: number; skippedCount: number; failedCount: number; items: ReportMaterialPreflightItem[] }; }; 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; name: string; fieldType: string; required: boolean; description?: string | null; reportTypes: string[]; commonReportTypes?: Array<'signature' | 'drainage'>; channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>; }; export type ClientApplicationReportField = Omit; export type CommonReportField = DictionaryItem & { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder: number; drainageField: DictionaryItem & { code?: string; name?: string; fieldType?: string; description?: string | null }; }; export type ReportTask = DictionaryItem & { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string | null; status: string; signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record | null }; drainageInfo?: SmsDrainageInfo | null; channel?: { id: string; name: string; code: string }; }; export type ReportRecord = DictionaryItem & { taskId: string; channelId: string; action: string; statusBefore?: string | null; statusAfter?: string | null; reason?: string | null; sourceEntry?: 'system' | 'legacy' | 'enterprise_signature' | 'report_task' | 'channel_report'; channel?: AdminChannel; task?: ReportTask; }; export type FileObject = { id: string; tenantId?: string | null; bucket: string; objectKey: string; fileName: string; contentType: string; sizeBytes: string | number; purpose: string; createdAt: string; }; export type FileRef = { fileObjectId: string; fileName: string; contentType?: string; }; export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') { return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`; } export type RiskReviewTask = { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; taskNo: string; sourceType?: string; contentHash?: string | null; windowStartedAt?: string | null; windowEndsAt?: string | null; content: string; category?: string | null; phoneTotal: number; uniquePhoneTotal: number; duplicateRatio: number; illegalRatio: number; blacklistHitRatio: number; variableIssues?: unknown; status: string; riskDecision: string; reviewReason?: string | null; rejectReason?: string | null; createdAt: string; reviewedAt?: string | null; reviewedBy?: { id: string; username: string; displayName: string } | null; riskHits?: Array<{ id: string; ruleName: string; reason: string }>; _count?: { messageRecords: number }; }; export type TenantAccount = { id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; updatedAt?: string; tenant?: TenantOption; }; export type OperationLogItem = { id: string; time: string; level: 'info' | 'success' | 'warning' | 'error'; tenant: string; module: string; operator: string; action: string; resourceId: string; detail: Record; ip: string; userAgent: string; }; export type OperationLogResponse = { items: OperationLogItem[]; total: number; page: number; pageSize: number; modules: string[]; }; export type ProtocolInteractionLogItem = { id: string; protocol: 'cmpp' | 'http'; direction: 'client_to_platform' | 'platform_to_channel' | 'channel_to_platform' | 'platform_to_client'; eventType: string; status: 'received' | 'accepted' | 'success' | 'failed' | 'retrying'; tenantId?: string | null; applicationId?: string | null; channelId?: string | null; account?: string | null; messageId?: string | null; gatewayMessageId?: string | null; traceId?: string | null; requestId?: string | null; phoneMasked?: string | null; resultCode?: string | null; durationMs?: number | null; payloadBytes?: number | null; retryCount?: number | null; detail?: Record | null; createdAt: string; }; export type ProtocolInteractionLogResponse = { items: ProtocolInteractionLogItem[]; total: number; page: number; pageSize: number; eventTypes: string[]; }; export type SystemLogExportResult = { operationId: string; status: 'completed'; fileName: string; recordCount: number; truncated: boolean; content: string; filters: { keyword?: string; level?: string; module?: string; range?: string }; }; export type PagedResponse = { items: T[]; total: number; page: number; pageSize: number; }; export type DailyReconciliationReport = { id: string; reportDate: string; tenantId: string; tenantName: string; applicationId: string; applicationName: string; submittedUnits: number; sentUnits: number; unknownUnits: number; successUnits: number; failedUnits: number; generatedAt: string; updatedAt: string; }; export type DailyProfitReport = { id: string; reportDate: string; dimensionType: 'application' | 'channel'; dimensionId: string; dimensionName: string; tenantId?: string | null; tenantName?: string | null; applicationId?: string | null; channelId?: string | null; submittedUnits: number; sentUnits: number; unknownUnits: number; successUnits: number; failedUnits: number; revenueCents: number; refundCents: number; costCents: number; profitCents: number; profitRateBps: number; generatedAt: string; updatedAt: string; }; export type DailyQualityReport = { id: string; reportDate: string; dimensionType: 'application' | 'channel' | 'signature' | 'drainage'; dimensionId: string; dimensionName: string; tenantId?: string | null; tenantName?: string | null; applicationId?: string | null; channelId?: string | null; signatureId?: string | null; drainageInfoId?: string | null; submittedUnits: number; sentUnits: number; unknownUnits: number; successUnits: number; failedUnits: number; successRateBps: number; avgArrivalMs?: number | null; generatedAt: string; updatedAt: string; }; export type CursorPage = { items: T[]; pageSize: number; hasMore: boolean; nextCursor: string | null; }; export type EnterpriseApplication = { id: string; tenantId: string; name: string; scene?: string | null; status: string; dailyLimit?: number | null; customerUnitPrice?: number | null; queuePriority?: 'normal' | 'priority' | string | null; maxPhonesPerTask?: number | null; templateMismatchMode?: string | null; downstreamReceiptRetryEnabled?: boolean | null; downstreamUplinkRetryEnabled?: boolean | null; cmppAccount?: string | null; cmppEnterpriseCode?: string | null; cmppApplicationExtension?: string | null; cmppAccessNumberFillEnabled?: boolean | null; cmppAccessNumberFillPrefix?: string | null; cmppClientSrcId?: string | null; interfaceEnabled?: boolean | null; interfaceType?: 'cmpp20' | string | null; cmppMaxConnections?: number | null; cmppWindowSize?: number | null; ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; httpConfig?: HttpApiConfig | null; tenant?: TenantOption; sentToday?: number; deliveryRate?: number; cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; cmppConnections?: CmppDownstreamConnection[]; }; export type CmppDownstreamConnection = { id: string; tenantId: string; applicationId: string; account: string; enterpriseCode: string; connectionId: string; remoteIp?: string | null; protocol?: string | null; status: string; connectedAt: string; lastHeartbeatAt?: string | null; lastSubmitAt?: string | null; lastDeliverAt?: string | null; disconnectedAt?: string | null; lastError?: string | null; updatedAt: string; }; export type CmppConnectionState = { id: string; tenantId?: string | null; applicationId?: string | null; channelId: string; connectionId: string; status: string; desiredConnections: number; currentConnections: number; lastConnectedAt?: string | null; lastDisconnectedAt?: string | null; lastHeartbeatAt?: string | null; reconnectCount: number; lastReconnectAttemptAt?: string | null; nextReconnectAt?: string | null; lastErrorCategory?: string | null; lastError?: string | null; updatedAt: string; channel?: AdminChannel; }; export type ApplicationConnectionsResponse = { application: EnterpriseApplication; connections: CmppDownstreamConnection[]; summary: { desiredConnections: number; currentConnections: number; status: string }; }; export type ApplicationCmppParams = { applicationId: string; applicationName: string; tenantName: string; appCode: string; gatewayHost: string; gatewayPort: number; enterpriseCode: string; account: string; passwordCipher: string; srcId: string; applicationExtension?: string | null; accessNumberFillEnabled?: boolean; accessNumberFillPrefix?: string | null; interfaceEnabled?: boolean; interfaceType?: string; maxConnections: number; heartbeatSeconds: number; windowSize: number; protocolVersion: string; }; export type DownstreamDeliveryRecord = { id: string; tenantId: string; applicationId: string; messageRecordId?: string | null; messageId?: string | null; deliveryType: string; status: string; payload: Record; retryCount: number; manualRetryCount: number; lastRetriedAt?: string | null; retryEnabled: boolean; nextRetryAt?: string | null; sentAt?: string | null; acknowledgedAt?: string | null; ackDeadlineAt?: string | null; ackResult?: number | null; ackSequenceId?: string | null; ackMessageId?: string | null; connectionId?: string | null; deliveredAt?: string | null; lastError?: string | null; createdAt: string; updatedAt: string; tenant?: TenantOption | null; application?: EnterpriseApplication | null; messageRecord?: SmsMessageRecord | null; }; export type BatchRequeueResponse = { total: number; successCount: number; failedCount: number; results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }>; }; export type DownstreamDeliveryDashboard = { summary: { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number; stalledPending: number; stalledAck: number; recentFailed: number; alertCount: number; }; typeBreakdown: Array<{ deliveryType: string; total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number; }>; retryBuckets: Array<{ label: string; count: number; }>; topApplications: Array<{ applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number; }>; }; export type GatewayDownstreamRecoveryStatus = { id: string; account: string; tenantId?: string | null; applicationId?: string | null; gatewayInstanceId?: string | null; state: string; lockOwner?: string | null; lockExpiresAt?: string | null; lastAttemptAt?: string | null; lastSuccessAt?: string | null; lastFailureAt?: string | null; nextRetryAt?: string | null; attemptCount: number; failureCategory?: string | null; lastError?: string | null; lastSkipReason?: string | null; createdAt: string; updatedAt: string; tenant?: TenantOption | null; 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 | 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 | null; application?: Pick | null; channel?: Pick | null; }; export type GatewaySubmitExceptionResponse = PagedResponse & { summary: { pending: number; requeueing: number; requeued: number; resolved: number; oldestPendingAt?: string | null; }; }; export type DownstreamRecoveryStatusResponse = PagedResponse & { summary: { total: number; running: number; success: number; failed: number; waitingConnection: number; backoff: number; failureCategories: Array<{ category: string; count: number }>; }; }; export type DownstreamRecoveryStatusExportQuery = { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; }; function withQuery(path: string, query: Record) { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== '' && value !== 'all') { params.set(key, String(value)); } }); const suffix = params.toString() ? `?${params}` : ''; return `${path}${suffix}`; } export const portalSessionApi = { current: (portal: Portal) => request(`/${portal}/auth/session`, { suppressSessionRedirect: true }), touch: (portal: Portal) => request(`/${portal}/auth/session/touch`, { method: 'POST', body: '{}' }), lock: (portal: Portal) => request<{ locked: boolean }>(`/${portal}/auth/session/lock`, { method: 'POST', body: '{}' }), unlock: (portal: Portal, password: string) => request(`/${portal}/auth/session/unlock`, { method: 'POST', body: JSON.stringify({ password }) }), reauthenticate: (portal: Portal, password: string) => request>(`/${portal}/auth/reauthenticate`, { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }), logout: (portal: Portal) => request<{ success: boolean }>(`/${portal}/auth/logout`, { method: 'POST', body: '{}' }), changeOwnPassword: (portal: Portal, body: { currentPassword: string; password: string }) => request(`/${portal}/auth/password`, { method: 'POST', body: JSON.stringify(body) }), }; export const adminApi = { getCaptcha: () => request('/admin/auth/captcha'), login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), touchSession: () => portalSessionApi.touch('admin'), lockSession: () => portalSessionApi.lock('admin'), unlockSession: (password: string) => portalSessionApi.unlock('admin', password), reauthenticate: (password: string) => portalSessionApi.reauthenticate('admin', password), logout: () => portalSessionApi.logout('admin'), changeOwnPassword: (body: { currentPassword: string; password: string }) => portalSessionApi.changeOwnPassword('admin', body), listTenants: () => request('/admin/tenants'), listTenantManagementRows: () => request('/admin/tenants/management-list'), getTenant: (id: string) => request(`/admin/tenants/${id}`), createTenant: (body: { name: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => request('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), updateTenant: (id: string, body: { name?: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => request(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeTenantStatus: (id: string, status: string) => request(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), deleteTenant: (id: string) => request(`/admin/tenants/${id}`, { method: 'DELETE' }), listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request(withQuery('/admin/users', query)), createUser: (body: UserPayload) => request('/admin/users', { method: 'POST', body: JSON.stringify(body) }), updateUser: (id: string, body: Omit) => request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeUserStatus: (id: string, status: string, operatorId?: string) => request(`/admin/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status, operatorId }) }), deleteUser: (id: string, operatorId?: string) => request(`/admin/users/${id}`, { method: 'DELETE', body: JSON.stringify({ operatorId }) }), changeUserPassword: (id: string, password: string, operatorId?: string) => request(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }), getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), getSendQuality: (date?: string) => request(withQuery('/admin/operations/send-quality', { date })), listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs', query)), listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs/protocol-interactions', query)), exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) => request('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), listAccounts: () => request('/admin/billing/accounts'), updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) => request(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), preflightManualRecharge: (body: { tenantId: string; amountCents: number }) => request('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) => request(withQuery('/admin/enterprise-applications', query)), getEnterpriseApplication: (id: string) => request(`/admin/enterprise-applications/${id}`), createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => request('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => request(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeApplicationStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-applications/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }), }), listApplicationConnections: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/connections`), listApplicationReportFields: (applicationId: string, reportType?: 'signature' | 'drainage') => request(withQuery(`/admin/enterprise-applications/${applicationId}/report-fields`, { reportType })), listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') => request(withQuery('/admin/report-fields/common', { reportType })), getApplicationCmppParams: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), getApplicationHttpApiConfig: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/http-api`), updateApplicationHttpApiConfig: (applicationId: string, body: Partial & { ipAllowlist?: string[] }) => request(`/admin/enterprise-applications/${applicationId}/http-api`, { method: 'PUT', body: JSON.stringify(body) }), listApplicationHttpWebhooks: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks`), saveApplicationHttpWebhook: ( applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, ) => request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', body: JSON.stringify(body), }), listChannels: () => request('/admin/channels'), listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/reports/reconciliation', query)), exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) => requestBlob(withQuery('/admin/reports/reconciliation/export', query)), listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => request & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)), exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => requestBlob(withQuery('/admin/reports/profit/export', query)), listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => request & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => requestBlob(withQuery('/admin/reports/quality/export', query)), createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => request(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { method: 'POST', body: JSON.stringify(body), }), testChannel: (id: string, body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string }) => request(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }), changeChannelStatus: (id: string, status: string, reason?: string) => request(`/admin/channels/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }), }), deleteChannel: (id: string, reason?: string) => request(`/admin/channels/${id}`, { method: 'DELETE', body: JSON.stringify({ reason }), }), getDeletionPreflight: (type: DeletionTargetType, id: string) => request(`/admin/deletions/${type}/${id}/preflight`), deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) => request(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }), listChannelConnectionLogs: (id: string) => request(`/admin/channels/${id}/connection-logs`), listTemplateAudits: (query: { keyword?: string; status?: string }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); if (query.status && query.status !== 'all') params.set('status', query.status); const suffix = params.toString() ? `?${params}` : ''; return request(`/admin/enterprise-templates${suffix}`); }, approveTemplate: (id: string) => request(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), rejectTemplate: (id: string, reason = '运营审核驳回') => request(`/admin/templates/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }), }), approveSignature: (id: string) => request(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), rejectSignature: (id: string, reason = '运营审核驳回') => request(`/admin/signatures/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }), }), getReviewPreflight: (type: 'signature' | 'template', id: string) => request(`/admin/reviews/${type}/${id}/preflight`), submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) => request(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) => request(withQuery('/admin/enterprise-signatures', query)), createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record }) => request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) => request(withQuery('/admin/drainage-infos', query)), createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }) => request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }) => request(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), approveDrainageInfo: (id: string) => request(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), rejectDrainageInfo: (id: string, reason: string) => request(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), changeDrainageInfoStatus: (id: string, status: string, reason?: string) => request(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) => request(withQuery('/admin/enterprise-templates', query)), createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => request('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => request(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); if (query.status && query.status !== 'all') params.set('status', query.status); const suffix = params.toString() ? `?${params}` : ''; return request(`/admin/enterprise-certifications${suffix}`); }, getEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}`), approveEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}/approve`, { method: 'POST', body: JSON.stringify({}), }), rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request(`/admin/enterprise-certifications/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }), }), listChannelGroups: () => request('/admin/channel-groups'), createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) => request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array> }) => request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteChannelGroup: (id: string) => request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), addChannelGroupItem: (body: Record) => request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), listChannelRouteRules: () => request('/admin/channel-route-rules'), createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), listChannelConnections: (id: string) => request(`/admin/channels/${id}/connections`), replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => request(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), createChannelReportField: (body: Record) => request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array>) => request(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }), listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } = {}) => request(withQuery('/admin/report-materials/pending', query)), listReportImportProfiles: (reportType?: 'signature' | 'drainage') => request(withQuery('/admin/report-materials/import-profiles', { reportType })), saveReportImportProfile: (body: Omit & { id?: string }) => request('/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 }) => { assertUploadFileSize(file); const form = new FormData(); form.set('file', file); Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); }); return requestForm & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form); }, commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }) => request>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }), listReportMaterialBatches: () => request>>('/admin/report-materials/batches'), preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) => request('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }), createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => request('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) => request }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => request>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => request>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/send/batch-tasks', query)), terminateAdminBatchTask: (id: string) => request(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }), listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) => request(withQuery('/admin/send/messages', query)), listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) => request(withQuery('/admin/operations/message-segment-audits', query)), listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => request(withQuery('/admin/operations/messages', query)), listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => request(withQuery('/admin/operations/uplink-messages', query)), claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) => request(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }), listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) => request(withQuery('/admin/operations/gateway-submit-dead-letters', query)), requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) => request(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }), listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) => request(withQuery('/admin/operations/downstream-deliveries/dashboard', query)), listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) => request(withQuery('/admin/operations/downstream-recovery-statuses', query)), getDownstreamRecoveryStatus: (id: string) => request(`/admin/operations/downstream-recovery-statuses/${id}`), exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) => requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)), listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string } = {}) => request>(withQuery('/admin/operations/downstream-deliveries', query)), requeueDownstreamDelivery: (id: string) => request(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }), batchRequeueDownstreamDeliveries: (ids: string[]) => request('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }), listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), approveRiskReviewTask: (id: string, reason?: string) => request(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }), rejectRiskReviewTask: (id: string, reason?: string) => request(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), rejectRiskReviewTasks: (ids: string[], reason: string) => request('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }), listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/sensitive-words', query)), createSensitiveWord: (body: { word: string; level?: string; status?: string }) => request('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }), deleteSensitiveWord: (id: string) => request(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }), listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/global', query)), createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => request('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), deleteGlobalBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)), createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), deletePhoneSegment: (id: string) => request(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }), listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), deletePhoneCarrierRule: (id: string) => request(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }), listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), deleteDrainageField: (id: string) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), listCommonReportFields: () => request('/admin/dictionaries/common-report-fields'), createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { assertUploadFileSize(file); const form = new FormData(); form.set('file', file); form.set('purpose', body.purpose); if (body.prefix) { form.set('prefix', body.prefix); } const headers = new Headers(); const session = readSession('admin'); if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); if (tenantId) { headers.set('x-tenant-id', tenantId); } const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); if (response.status === 401 && session) { const error = await readErrorBody(response.clone()); if (error.code === 'SESSION_LOCKED') { dispatchSessionEvent('admin', 'locked', { message: error.message }); } else { clearSession('admin'); dispatchSessionEvent('admin', 'logout', { code: error.code, message: error.message }); redirectToPortalLogin('admin'); } } if (!response.ok) { throw new Error(await response.text()); } return response.json() as Promise; }, }; export const clientApi = { getCaptcha: () => request('/client/auth/captcha'), login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => request('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }), listUsers: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/users', { tenantId }), createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }), updateUser: (id: string, body: Omit, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }), deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }), changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }), getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/operations/dashboard', { tenantId }), listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/enterprise-certification', { tenantId }), submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/enterprise-certification', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }), }), listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/system-logs', query), { tenantId }), exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string }) => request('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/orders', { tenantId }), listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/applications', { tenantId }), getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/cmpp-params`, { tenantId }), getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api`, { tenantId }), listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }), createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/credentials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), revokeHttpApiCredential: (applicationId: string, credentialId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`, { method: 'POST', tenantId, body: JSON.stringify({}) }), listHttpWebhooks: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhooks`, { tenantId }), saveHttpWebhook: (applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/requests`, { tenantId }), listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhook-deliveries`, { tenantId }), retryHttpWebhookDelivery: (applicationId: string, deliveryId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`, { method: 'POST', tenantId, body: JSON.stringify({}) }), listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }), listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/report-fields/common', { reportType }), { tenantId }), listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { tenantId }), getSignatureWorkspace: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures-workspace', { tenantId }), createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }), updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/templates', { status: query.status, keyword: query.keyword, includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), }), { tenantId }), createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), getDeletionPreflight: (type: Exclude, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/deletions/${type}/${id}/preflight`, { tenantId }), deleteGovernedTarget: (type: Exclude, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }), listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/send/batch-tasks', query), { tenantId }), cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/send/batch-tasks/${id}/messages`, { tenantId }), listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/messages', query), { tenantId }), listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/uplink-messages', query), { tenantId }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => { assertUploadFileSize(file); const form = new FormData(); form.set('file', file); form.set('purpose', body.purpose); if (body.prefix) { form.set('prefix', body.prefix); } const headers = new Headers(); const session = readSession('client'); if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); if (response.status === 401 && session) { const error = await readErrorBody(response.clone()); if (error.code === 'SESSION_LOCKED') { dispatchSessionEvent('client', 'locked', { message: error.message }); } else { clearSession('client'); dispatchSessionEvent('client', 'logout', { code: error.code, message: error.message }); redirectToPortalLogin('client'); } } if (!response.ok) { throw new Error(await response.text()); } return response.json() as Promise; }, };