feat: add HTTP API and complete client workflows

This commit is contained in:
hectorzhao
2026-07-16 11:34:06 +08:00
parent 4f07b331e5
commit dcb6162dcf
40 changed files with 2548 additions and 365 deletions
+83 -8
View File
@@ -46,7 +46,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
headers.set('x-tenant-id', tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401 && session) {
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
if (response.status === 401 && session && !isLoginAttempt) {
const body = await readErrorBody(response.clone());
if (body.code === 'SESSION_LOCKED') {
dispatchSessionEvent('locked', { message: body.message });
@@ -318,6 +319,7 @@ export type ClientSmsApplication = {
deliveryRate?: number;
cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive';
cmppConnections?: CmppDownstreamConnection[];
httpConfig?: HttpApiConfig | null;
};
export type ClientSmsSignature = {
@@ -342,6 +344,32 @@ export type ClientSmsSignature = {
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
};
export type ClientSmsSignatureView = Pick<ClientSmsSignature,
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials'
> & {
application?: Pick<ClientSmsApplication, 'id' | 'name' | 'status'> | null;
submittedMaterialCount: number;
reportValues: Record<string, unknown>;
drainageInfo: { links: Array<{
id: string;
siteName: string;
url: string;
remark?: string | null;
reportValues: Record<string, unknown>;
auditStatus: string;
rejectReason?: string | null;
submittedAt: string;
reviewedAt?: string | null;
createdAt: string;
updatedAt: string;
}> };
};
export type ClientSignatureWorkspace = {
items: ClientSmsSignatureView[];
summary: { total: number; pending: number; approved: number; rejected: number; draft: number };
};
export type SmsDrainageInfo = {
id: string;
tenantId: string;
@@ -557,6 +585,36 @@ export type SmsUplinkMatchCandidate = {
messageRecord?: SmsMessageRecord | null;
};
export type HttpApiConfig = {
enabled: boolean;
sendEnabled: boolean;
messageQueryEnabled: boolean;
receiptWebhookEnabled: boolean;
uplinkWebhookEnabled: boolean;
uplinkQueryEnabled: boolean;
credentialSelfServiceEnabled: boolean;
qpsLimit: number;
timestampToleranceSeconds: number;
maxCredentialCount: number;
uplinkRetentionDays: number;
maxQueryRangeDays: number;
maxPageSize: number;
receiptDeliveryMode: 'cmpp' | 'http' | 'both' | 'none';
uplinkDeliveryMode: 'cmpp' | 'http' | 'both' | 'none';
webhookRetryEnabled: boolean;
webhookMaxAttempts: number;
webhookTimeoutSeconds: number;
requireHttps: boolean;
allowClientManualRetry: boolean;
allowClientTest: boolean;
};
export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; config: HttpApiConfig | null; ipAllowlist: string[] };
export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string };
export type HttpWebhookEndpoint = { id: string; eventType: 'receipt' | 'uplink'; url: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; lastTestAt?: string | null; lastTestStatus?: string | null; updatedAt: string };
export type HttpApiRequestLog = { id: string; requestId: string; clientMessageId?: string | null; sourceIp?: string | null; httpStatus?: number | null; businessCode?: string | null; status: string; durationMs?: number | null; createdAt: string; completedAt?: string | null };
export type HttpWebhookDelivery = { id: string; status: string; attemptCount: number; lastHttpStatus?: number | null; lastError?: string | null; createdAt: string; event: { eventId: string; eventType: string; messageId?: string | null }; endpoint: { eventType: string; url: string } };
export type DictionaryItem = Record<string, unknown> & {
id: string;
status?: string;
@@ -655,6 +713,8 @@ export type ApplicationReportField = {
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>;
};
export type ClientApplicationReportField = Omit<ApplicationReportField, 'channels' | 'commonReportTypes'>;
export type CommonReportField = DictionaryItem & {
drainageFieldId: string;
reportType: 'signature' | 'drainage';
@@ -1167,6 +1227,8 @@ export const adminApi = {
request<ApplicationReportField[]>(withQuery('/admin/report-fields/common', { reportType })),
getApplicationCmppParams: (applicationId: string) =>
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
getApplicationHttpApiConfig: (applicationId: string) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`),
updateApplicationHttpApiConfig: (applicationId: string, body: Partial<HttpApiConfig> & { ipAllowlist?: string[] }) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`, { method: 'PUT', body: JSON.stringify(body) }),
listChannels: () => request<AdminChannel[]>('/admin/channels'),
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
@@ -1442,18 +1504,31 @@ export const clientApi = {
request<ClientSmsApplication[]>('/client/applications', { 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<ApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
request<ClientApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }),
getSignatureWorkspace: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSignatureWorkspace>('/client/signatures-workspace', { tenantId }),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
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<ClientSmsSignature>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
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) =>
@@ -1486,7 +1561,7 @@ export const clientApi = {
request<SmsMessageRecord[]>(`/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<SmsMessageRecord[]>(withQuery('/client/operations/messages', query), { tenantId }),
listUplinkMessages: (query: { channelId?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
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 }),
createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<FileObject>('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }),