import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session'; type RequestOptions = RequestInit & { tenantId?: string; reauthenticationAttempted?: 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'; 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 session = readSession(); 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 && 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 && !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 session = readSession(); 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 && 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 && !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(); } 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; [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 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; 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; recentFailed: number; alertCount: number; }; accounts: Array<{ id: string; tenantId: string; balanceCents: number; status: string; tenant?: TenantOption }>; recentTasks: Array>; recentRecharges: Array; }; 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 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'; cmppConnections?: CmppDownstreamConnection[]; }; 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 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; batchTaskId?: string | null; applicationId?: string | null; channelId?: string | null; messageId: string; phoneNumber: string; carrier?: string | null; province?: string | null; content: string; 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[]; }; 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 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; rateLimitPerSecond?: number | null; 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; drainageField?: DictionaryItem | null; }; export type ApplicationReportField = { id: string; code: string; name: string; fieldType: string; required: boolean; description?: string | null; reportTypes: string[]; channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both' }>; }; 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') { return `/api/admin/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; riskHits?: Array<{ id: string; ruleName: string; reason: string }>; _count?: { messageRecords: number }; }; export type TenantAccount = { id: string; tenantId: string; balanceCents: number; status: 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 PagedResponse = { items: T[]; total: number; page: number; pageSize: number; }; 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; interfaceEnabled?: boolean | null; interfaceType?: 'cmpp20' | string | null; cmppMaxConnections?: number | null; cmppWindowSize?: number | null; ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | 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; 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; 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; 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 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 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: () => request>('/auth/session/touch', { method: 'POST', body: '{}' }), lockSession: () => request<{ locked: boolean }>('/auth/session/lock', { method: 'POST', body: '{}' }), unlockSession: (password: string) => request>('/auth/session/unlock', { method: 'POST', body: JSON.stringify({ password }) }), reauthenticate: (password: string) => request>('/auth/reauthenticate', { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }), logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST', body: '{}' }), changeOwnPassword: (body: { currentPassword: string; password: string }) => request('/auth/password', { method: 'POST', body: JSON.stringify(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 })), listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs', query)), listAccounts: () => request('/admin/billing/accounts'), listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), createManualRecharge: (body: { tenantId: string; amountCents: number; operatorId?: 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; 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; 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 })), getApplicationCmppParams: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), listChannels: () => request('/admin/channels'), createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) => request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: 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 }), }), 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 }), }), 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) }), 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)), listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: 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 } = {}) => 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 }) }), 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) }), 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) }), 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) }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { 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(); 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('locked', { message: error.message }); } else { clearSession(); dispatchSessionEvent('logout', { code: error.code, message: error.message }); window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login'); } } 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 }), 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 }), listApplicationReportFields: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/report-fields`, { tenantId }), listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { 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 }) }), 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 } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/templates', query), { 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 }) }), 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 } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/uplink-messages', query), { tenantId }), createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => { 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(); 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('locked', { message: error.message }); } else { clearSession(); dispatchSessionEvent('logout', { code: error.code, message: error.message }); window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login'); } } if (!response.ok) { throw new Error(await response.text()); } return response.json() as Promise; }, };