refactor: strengthen client boundaries and quality gates

This commit is contained in:
hectorzhao
2026-08-28 14:26:58 +08:00
parent 3af145abe5
commit ad27acad7e
51 changed files with 7703 additions and 697 deletions
+312 -121
View File
@@ -1,10 +1,45 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { ApplicationCmppParams, CaptchaResponse, ClientApplicationReportField, ClientSignatureWorkspace, ClientSmsApplication, ClientSmsSignatureView, ClientSmsTemplate, DashboardResponse, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, EnterpriseCertification, FileObject, HttpApiConfigResponse, HttpApiCredential, HttpApiRequestLog, HttpWebhookDelivery, HttpWebhookEndpoint, ImportPreviewResponse, ManagedUser, OperationLogResponse, PagedResult, RechargeOrder, SmsBatchTask, SmsDrainageInfo, SmsMessageRecord, SmsUplinkMessage, SystemLogExportResult, UserPayload } from '../types';
import type {
ApplicationCmppParams,
CaptchaResponse,
ClientApplicationReportField,
ClientSignatureWorkspace,
ClientSmsApplication,
ClientSmsSignatureView,
ClientSmsTemplate,
DashboardResponse,
DeleteTargetRequest,
DeletionPreflight,
DeletionResult,
DeletionTargetType,
EnterpriseCertification,
FileObject,
HttpApiConfigResponse,
HttpApiCredential,
HttpApiRequestLog,
HttpWebhookDelivery,
HttpWebhookEndpoint,
ImportPreviewResponse,
ManagedUser,
OperationLogResponse,
PagedResult,
RechargeOrder,
SmsBatchTask,
SmsDrainageInfo,
SmsMessageRecord,
SmsUplinkMessage,
SystemLogExportResult,
} from '../types';
import { assertUploadFileSize } from '@/utils/fileUpload';
import type { LoginSession } from '../session';
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, redirectToPortalLogin } from '../session';
import {
clearSession,
dispatchSessionEvent,
hasRecentUserActivity,
readSession,
redirectToPortalLogin,
} from '../session';
import { readErrorBody } from '../core/httpClient';
import { DEFAULT_CLIENT_TENANT_ID } from '../types';
// Client methods moved intact during R1; tenant and session behavior still flows
// through the shared HTTP client and the existing upload path below.
@@ -12,126 +47,277 @@ export const clientApi = {
getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listUsers: (
query: { displayName?: string; login?: string; status?: string } = {},
tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID,
) => request<ManagedUser[]>(withQuery('/client/users', query), { tenantId }),
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }),
deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }),
changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }),
getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<EnterpriseCertification[]>('/client/enterprise-certification', { tenantId }),
submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
listUsers: (query: { displayName?: string; login?: string; status?: string } = {}) =>
request<ManagedUser[]>(withQuery('/client/users', query)),
createUser: (body: {
username?: string;
email?: string;
phone?: string;
displayName: string;
password: string;
status?: string;
}) => request<ManagedUser>('/client/users', { method: 'POST', body: JSON.stringify(body) }),
updateUser: (
id: string,
body: { username?: string; email?: string; phone?: string; displayName?: string; status?: string },
) => request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeUserStatus: (id: string, status: string) =>
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
deleteUser: (id: string) => request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE' }),
changeUserPassword: (id: string, password: string) =>
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password }) }),
getDashboard: () => request<DashboardResponse>('/client/operations/dashboard'),
listEnterpriseCertifications: () => request<EnterpriseCertification[]>('/client/enterprise-certification'),
submitEnterpriseCertification: (body: {
companyName: string;
licenseNo?: string;
contactName?: string;
contactPhone?: string;
materials?: Record<string, unknown>;
}) =>
request<EnterpriseCertification>('/client/enterprise-certification', {
method: 'POST',
tenantId,
body: JSON.stringify({ ...body, tenantId }),
body: JSON.stringify(body),
}),
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query), { tenantId }),
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query), { tenantId }),
listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/application-options', { tenantId }),
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`, { tenantId }),
listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }),
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential>(`/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<HttpWebhookEndpoint[]>(`/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<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`, { tenantId }),
listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookDelivery[]>(`/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<ClientApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }),
listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView[]>('/client/signature-options', { tenantId }),
getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query), { tenantId }),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>(`/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<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/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<ClientSmsTemplate[]>(withQuery('/client/templates', {
status: query.status,
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
}), { tenantId }),
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<ClientSmsTemplate>>(withQuery('/client/templates', {
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
page: query.page,
pageSize: query.pageSize,
}), { 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<ClientSmsTemplate>('/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<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`, { tenantId }),
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query), { tenantId }),
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>(`/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<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>('/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<ImportPreviewResponse>('/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<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`, { tenantId }),
listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsMessageRecord>>(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<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
listSystemLogs: (query: {
keyword?: string;
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) => request<OperationLogResponse>(withQuery('/client/operations/system-logs', query)),
exportSystemLogs: (query: {
keyword?: string;
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
}) =>
request<SystemLogExportResult>('/client/operations/system-logs/exports', {
method: 'POST',
body: JSON.stringify(query),
}),
listOrders: () => request<RechargeOrder[]>('/client/billing/orders'),
listOrdersPage: (query: { page: number; pageSize: number }) =>
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query)),
listApplications: () => request<ClientSmsApplication[]>('/client/applications'),
listApplicationsPage: (query: { page: number; pageSize: number }) =>
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query)),
listApplicationOptions: () => request<ClientSmsApplication[]>('/client/application-options'),
getApplicationCmppParams: (applicationId: string) =>
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`),
getApplicationHttpApiConfig: (applicationId: string) =>
request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`),
listHttpApiCredentials: (applicationId: string) =>
request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`),
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }) =>
request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, {
method: 'POST',
body: JSON.stringify(body),
}),
revokeHttpApiCredential: (applicationId: string, credentialId: string) =>
request<{ id: string; status: string }>(
`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`,
{ method: 'POST', body: JSON.stringify({}) },
),
listHttpWebhooks: (applicationId: string) =>
request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`),
saveHttpWebhook: (
applicationId: string,
eventType: 'receipt' | 'uplink',
body: { url: string; rotateSecret?: boolean; status?: string },
) =>
request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listHttpApiRequests: (applicationId: string) =>
request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`),
listHttpWebhookDeliveries: (applicationId: string) =>
request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`),
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string) =>
request<{ id: string; status: string }>(
`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`,
{ method: 'POST', body: JSON.stringify({}) },
),
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage') =>
request<ClientApplicationReportField[]>(
withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }),
),
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') =>
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType })),
listSignatures: () => request<ClientSmsSignatureView[]>('/client/signatures'),
listSignatureOptions: () => request<ClientSmsSignatureView[]>('/client/signature-options'),
getSignatureWorkspace: (
query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {},
) => request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query)),
createSignature: (body: {
applicationId?: string;
name: string;
purpose?: string;
drainageInfo?: Record<string, unknown>;
}) => request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', body: JSON.stringify(body) }),
updateSignature: (
id: string,
body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> },
) => request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
submitSignature: (id: string) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
changeSignatureStatus: (id: string, status: string) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
createSignatureMaterial: (
id: string,
body: { fileObjectId?: string; materialType: string; title: string; description?: string },
) =>
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, {
method: 'POST',
body: JSON.stringify(body),
}),
createDrainageInfo: (
signatureId: string,
body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> },
) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateDrainageInfo: (
id: string,
body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> },
) => request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}) =>
request<ClientSmsTemplate[]>(
withQuery('/client/templates', {
status: query.status,
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
}),
),
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsTemplate>>(
withQuery('/client/templates', {
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
page: query.page,
pageSize: query.pageSize,
}),
),
createTemplate: (body: {
applicationId: string;
signatureId?: string;
name: string;
content: string;
category?: string;
variables?: Array<{ name: string; example?: string; required?: boolean }>;
}) => request<ClientSmsTemplate>('/client/templates', { method: 'POST', body: JSON.stringify(body) }),
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 }>;
},
) => request<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
submitTemplate: (id: string) =>
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
changeTemplateStatus: (id: string, status: string) =>
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string) =>
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`),
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest) =>
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
listBatchTasks: (query: { status?: string } = {}) =>
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query)),
listBatchTasksPage: (query: {
status?: string;
keyword?: string;
applicationKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query)),
cancelBatchTask: (id: string) =>
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', body: JSON.stringify({}) }),
createBatchTask: (body: {
applicationId?: string;
templateId?: string;
content: string;
category?: string;
phones: string[];
sendMode?: 'immediate' | 'scheduled';
scheduledAt?: string;
variables?: Record<string, unknown>;
}) => request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', body: JSON.stringify(body) }),
previewImport: (body: {
applicationId?: string;
content: string;
fileName?: string;
delimiter?: ',' | '\t';
requiredVariables?: string[];
}) => request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', body: JSON.stringify(body) }),
confirmImport: (body: {
applicationId?: string;
templateId?: string;
content: string;
category?: string;
importContent: string;
sendMode?: 'immediate' | 'scheduled';
scheduledAt?: string;
requiredVariables?: string[];
variables?: Record<string, unknown>;
}) => request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', body: JSON.stringify(body) }),
listBatchTaskMessages: (id: string) => request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`),
listMessages: (
query: {
applicationId?: string;
taskId?: string;
messageId?: string;
phoneNumber?: string;
contentKeyword?: string;
status?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query)),
listUplinkMessages: (
query: {
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
} = {},
) => request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query)),
listUplinkMessagesPage: (query: {
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page: number;
pageSize: number;
}) => request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query)),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
assertUploadFileSize(file);
const form = new FormData();
@@ -143,7 +329,12 @@ export const clientApi = {
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' });
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') {
+221
View File
@@ -0,0 +1,221 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { fileDownloadUrl, readErrorBody, request, requestBlob, requestForm, withQuery } from './httpClient';
import { setReauthenticationHandler, writeSession } from '../session';
let receivedTenantHeader: string | null = null;
const server = setupServer(
http.get('http://localhost/api/client/users', ({ request: incoming }) => {
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return HttpResponse.json([]);
}),
http.get('http://localhost/api/admin/users', ({ request: incoming }) => {
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return HttpResponse.json([]);
}),
http.get('http://localhost/api/client/failure', () =>
HttpResponse.json({ message: '后端业务失败' }, { status: 500 }),
),
);
const nativeFetch = globalThis.fetch;
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
const interceptedFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
interceptedFetch(new URL(String(input), 'http://localhost'), init)) as typeof fetch;
});
afterEach(() => {
receivedTenantHeader = null;
server.resetHandlers();
});
afterAll(() => {
server.close();
globalThis.fetch = nativeFetch;
setReauthenticationHandler();
});
describe('request tenant and error boundaries', () => {
it('never forwards an explicit tenant header to a client route', async () => {
await expect(request('/client/users', { tenantId: 'tenant-attacker' })).resolves.toEqual([]);
expect(receivedTenantHeader).toBeNull();
});
it('keeps explicit tenant selection for non-client administrative routes', async () => {
await expect(request('/admin/users', { tenantId: 'tenant-admin-selected' })).resolves.toEqual([]);
expect(receivedTenantHeader).toBe('tenant-admin-selected');
});
it('surfaces a backend JSON error message instead of a generic status', async () => {
await expect(request('/client/failure')).rejects.toThrow('后端业务失败');
});
it.each([
[HttpResponse.json({ message: ['字段一', '字段二'] }, { status: 422 }), '字段一;字段二'],
[HttpResponse.json({ error: '网关错误' }, { status: 502 }), '网关错误'],
[new HttpResponse(null, { status: 503 }), '请求失败(503'],
])('normalizes additional server error body shapes', async (response, expected) => {
server.use(http.get('http://localhost/api/client/error-shape', () => response));
await expect(request('/client/error-shape')).rejects.toThrow(expected);
});
it('parses empty, JSON and plain-text error bodies safely', async () => {
await expect(readErrorBody(new Response())).resolves.toEqual({});
await expect(readErrorBody(HttpResponse.json({ code: 'BAD', message: ['字段一', '字段二'] }))).resolves.toEqual({
code: 'BAD',
message: ['字段一', '字段二'],
});
await expect(readErrorBody(new Response('代理错误'))).resolves.toEqual({ message: '代理错误' });
});
it('does not redirect a rejected login attempt', async () => {
server.use(
http.post('http://localhost/api/client/auth/login', () =>
HttpResponse.json({ message: 'Invalid login or password' }, { status: 401 }),
),
);
await expect(request('/client/auth/login', { method: 'POST', body: '{}' })).rejects.toThrow(
'Invalid login or password',
);
});
it('can suppress a session redirect for a background probe', async () => {
server.use(
http.get('http://localhost/api/client/probe', () => HttpResponse.json({ message: '未登录' }, { status: 401 })),
);
await expect(request('/client/probe', { suppressSessionRedirect: true })).rejects.toThrow('未登录');
});
it('raises the server lock message for an authenticated session', async () => {
writeSession({
portal: 'client',
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
server.use(
http.get('http://localhost/api/client/locked', () =>
HttpResponse.json({ code: 'SESSION_LOCKED', message: '会话测试锁定' }, { status: 401 }),
),
);
await expect(request('/client/locked')).rejects.toThrow('会话测试锁定');
});
it('reauthenticates once and retries a protected request', async () => {
writeSession({
portal: 'client',
user: { id: 'user-1', username: 'user', displayName: '用户', roles: ['enterprise_admin'] },
idleTimeoutSeconds: 7200,
lockRecoverySeconds: 14400,
absoluteExpiresAt: new Date(Date.now() + 3600000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.post('http://localhost/api/client/protected', () => {
attempts += 1;
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: HttpResponse.json({ success: true });
}),
);
const reauthenticate = vi.fn().mockResolvedValue(undefined);
setReauthenticationHandler(reauthenticate);
await expect(request('/client/protected', { method: 'POST', body: '{}' })).resolves.toEqual({ success: true });
expect(reauthenticate).toHaveBeenCalledOnce();
});
it('downloads blobs and preserves server text errors', async () => {
server.use(
http.get('http://localhost/api/client/file-ok', () => new HttpResponse('file-data', { status: 200 })),
http.get('http://localhost/api/client/file-fail', () => new HttpResponse('文件不存在', { status: 404 })),
);
await expect((await requestBlob('/client/file-ok')).text()).resolves.toBe('file-data');
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
});
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
writeSession({
portal: 'admin',
user: { id: 'a', username: 'a', displayName: 'A', roles: ['platform_admin'] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.get('http://localhost/api/admin/export', ({ request: incoming }) => {
attempts += 1;
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: new HttpResponse('csv');
}),
);
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
await expect((await requestBlob('/admin/export', { tenantId: 'tenant-1' })).text()).resolves.toBe('csv');
expect(receivedTenantHeader).toBe('tenant-1');
});
it('submits multipart forms without forcing a JSON content type', async () => {
let contentType = '';
server.use(
http.post('http://localhost/api/client/upload', ({ request: incoming }) => {
contentType = incoming.headers.get('content-type') ?? '';
return HttpResponse.json({ id: 'file-1' });
}),
);
const form = new FormData();
form.set('file', new Blob(['data']), 'data.txt');
await expect(requestForm('/client/upload', form)).resolves.toEqual({ id: 'file-1' });
expect(contentType).toContain('multipart/form-data; boundary=');
});
it('retries multipart forms after recent authentication and reports failures', async () => {
writeSession({
portal: 'client',
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.post('http://localhost/api/client/form-protected', () => {
attempts += 1;
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: HttpResponse.json({ ok: true });
}),
);
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
await expect(requestForm('/client/form-protected', new FormData())).resolves.toEqual({ ok: true });
server.use(
http.post('http://localhost/api/client/form-fail', () =>
HttpResponse.json({ error: '上传失败' }, { status: 400 }),
),
);
await expect(requestForm('/client/form-fail', new FormData())).rejects.toThrow('上传失败');
});
it('builds bounded queries and encoded download URLs', () => {
expect(withQuery('/client/messages', { page: 2, status: 'all', keyword: '', level: 'warn' })).toBe(
'/client/messages?page=2&level=warn',
);
expect(fileDownloadUrl('folder/file 1', 'inline', 'client')).toBe(
'/api/client/files/folder%2Ffile%201/download?disposition=inline',
);
expect(withQuery('/client/messages', { status: undefined })).toBe('/client/messages');
expect(fileDownloadUrl('file-1')).toBe('/api/admin/files/file-1/download?disposition=attachment');
});
});
+17 -13
View File
@@ -2,7 +2,6 @@ import {
clearSession,
currentRouteForPortal,
dispatchSessionEvent,
getSessionTenantId,
hasRecentUserActivity,
portalFromPath,
readSession,
@@ -19,7 +18,6 @@ type RequestOptions = RequestInit & {
suppressSessionRedirect?: boolean;
};
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
@@ -32,8 +30,14 @@ export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
}
}
export type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>;
export type SessionTiming = Pick<
LoginSession,
| 'idleTimeoutSeconds'
| 'lockRecoverySeconds'
| 'absoluteExpiresAt'
| 'lastActivityAt'
| 'recentAuthenticationExpiresAt'
>;
// Authentication failures are handled centrally so every domain API keeps the
// same lock, recovery and redirect behavior as the original adminApi facade.
@@ -87,9 +91,8 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
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);
if (options.tenantId && !path.startsWith('/client')) {
headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
@@ -114,9 +117,8 @@ export async function requestBlob(path: string, options: RequestOptions = {}): P
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);
if (options.tenantId && !path.startsWith('/client')) {
headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401) {
@@ -156,7 +158,6 @@ export async function requestForm<T>(path: string, form: FormData, reauthenticat
return response.json() as Promise<T>;
}
export function withQuery(path: string, query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
@@ -168,7 +169,10 @@ export function withQuery(path: string, query: Record<string, string | number |
return `${path}${suffix}`;
}
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') {
export function fileDownloadUrl(
fileObjectId: string,
disposition: 'attachment' | 'inline' = 'attachment',
portal: Portal = 'admin',
) {
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
}
+9 -6
View File
@@ -1,14 +1,17 @@
// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts.
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionResolutionAction =
'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
export type DeletionDependency = {
kind: string;
label: string;
count: number;
items: string[];
detailsVisible: boolean;
};
export type DeletionRequiredSelection = {
action: DeletionResolutionAction;