fix: harden real backend admin workflows and ui

This commit is contained in:
hectorzhao
2026-07-03 19:29:56 +08:00
parent dd09d91c1e
commit 8cca361441
71 changed files with 5111 additions and 4439 deletions
+181 -8
View File
@@ -89,6 +89,17 @@ export type TenantOption = {
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 CaptchaResponse = {
@@ -184,6 +195,13 @@ export type ClientSmsApplication = {
scene?: string | null;
customerUnitPrice?: number | null;
status: string;
dailyLimit?: number | null;
createdAt?: string;
updatedAt?: string;
sentToday?: number;
deliveryRate?: number;
cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive';
cmppConnections?: CmppConnectionState[];
};
export type ClientSmsSignature = {
@@ -192,11 +210,14 @@ export type ClientSmsSignature = {
applicationId?: string | null;
name: string;
purpose?: string | null;
drainageInfo?: Record<string, unknown> | null;
auditStatus: string;
rejectReason?: string | null;
createdAt: string;
updatedAt: string;
materials?: Array<Record<string, unknown>>;
tenant?: TenantOption;
application?: ClientSmsApplication | null;
};
export type ClientSmsTemplate = {
@@ -214,6 +235,7 @@ export type ClientSmsTemplate = {
variables?: Array<{ name: string; example?: string | null; required?: boolean }>;
application?: { id: string; name: string };
signature?: { id: string; name: string };
tenant?: TenantOption;
};
export type SmsBatchTask = {
@@ -230,12 +252,31 @@ export type SmsBatchTask = {
reviewReason?: string | null;
rejectReason?: string | null;
progressTotal: number;
progressSent: number;
progressDelivered: number;
progressFailed: 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[];
};
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 = {
@@ -252,7 +293,30 @@ export type SmsMessageRecord = {
status: string;
errorMessage?: 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?: Array<Record<string, unknown>>;
receiptRecords?: Array<Record<string, unknown>>;
};
export type SmsUplinkMessage = {
id: string;
tenantId?: string | null;
channelId: string;
messageId?: string | null;
sequenceId?: number | null;
phoneNumber: string;
destId: string;
content: string;
receivedAt: string;
createdAt: string;
tenant?: TenantOption | null;
channel?: AdminChannel | null;
};
export type DictionaryItem = Record<string, unknown> & {
@@ -269,7 +333,19 @@ export type ChannelGroup = DictionaryItem & {
description?: string | null;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
items?: Array<Record<string, unknown>>;
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 & {
@@ -374,6 +450,10 @@ export type EnterpriseApplication = {
scene?: string | null;
status: string;
dailyLimit?: number | null;
customerUnitPrice?: number | null;
maxPhonesPerTask?: number | null;
templateMismatchMode?: string | null;
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
tenant?: TenantOption;
sentToday?: number;
deliveryRate?: number;
@@ -438,9 +518,9 @@ export const adminApi = {
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listTenants: () => request<TenantOption[]>('/admin/tenants'),
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
createTenant: (body: { name: string; code: string; status?: string }) =>
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<TenantOption>('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }),
updateTenant: (id: string, body: { name?: string; code?: string; status?: string }) =>
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<TenantOption>(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeTenantStatus: (id: string, status: string) =>
request<TenantOption>(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
@@ -463,8 +543,12 @@ export const adminApi = {
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/client/applications', { method: 'POST', tenantId: body.tenantId, body: JSON.stringify(body) }),
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
method: 'POST',
@@ -482,6 +566,8 @@ export const adminApi = {
listChannels: () => request<AdminChannel[]>('/admin/channels'),
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string }) =>
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string }) =>
request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
method: 'POST',
body: JSON.stringify(body),
@@ -509,8 +595,20 @@ export const adminApi = {
}),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record<string, unknown> }) =>
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
request<ClientSmsTemplate[]>(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<ClientSmsTemplate>('/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<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsTemplate>(`/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);
@@ -530,10 +628,15 @@ export const adminApi = {
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) =>
request<ChannelGroup>('/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; items?: Array<Record<string, unknown>> }) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
addChannelGroupItem: (body: Record<string, unknown>) =>
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
listChannelRouteRules: () => request<DictionaryItem[]>('/admin/channel-route-rules'),
createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) =>
request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }),
replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) =>
request<DictionaryItem[]>(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }),
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
@@ -542,11 +645,19 @@ export const adminApi = {
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) =>
importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }),
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request<ReportRecord[]>(withQuery('/admin/report-records', query)),
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
request<SmsBatchTask[]>(withQuery('/admin/send/batch-tasks', query)),
terminateAdminBatchTask: (id: string) =>
request<SmsBatchTask>(`/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<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}) =>
request<SmsMessageRecord[]>(withQuery('/admin/operations/messages', query)),
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
@@ -575,6 +686,27 @@ export const adminApi = {
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/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?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
if (tenantId) {
headers.set('x-tenant-id', tenantId);
}
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<FileObject>;
},
};
export const clientApi = {
@@ -595,6 +727,14 @@ export const clientApi = {
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) =>
request<EnterpriseCertification>('/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<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
@@ -607,6 +747,8 @@ export const clientApi = {
request<RechargeOrder>('/client/billing/orders', { method: 'POST', tenantId, body: JSON.stringify(body) }),
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
@@ -621,6 +763,8 @@ export const clientApi = {
request<ClientSmsTemplate[]>(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<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) =>
@@ -631,8 +775,37 @@ export const clientApi = {
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: { 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; 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) =>
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' }) }),
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?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
if (tenantId) {
headers.set('x-tenant-id', tenantId);
}
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<FileObject>;
},
};