feat: add HTTP signature tools and fix independent HTTP send validation
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
@@ -12,6 +12,30 @@ const auth = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('HTTP API remediation boundaries', () => {
|
describe('HTTP API remediation boundaries', () => {
|
||||||
|
it.each([
|
||||||
|
{ mobile: 'abc' },
|
||||||
|
{ mobile: '1' },
|
||||||
|
{ mobile: '' },
|
||||||
|
{ mobile: '138001380001' },
|
||||||
|
{ accessNumber: '<script>' },
|
||||||
|
{ accessNumber: '' },
|
||||||
|
{ accessNumber: '1'.repeat(22) },
|
||||||
|
])('rejects malformed uplink number filters before querying: %o', async (query) => {
|
||||||
|
const findMany = jest.fn();
|
||||||
|
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
|
||||||
|
await expect(service.listUplinks(auth as never, query)).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('accepts numeric uplink filter boundaries without changing exact matches', async () => {
|
||||||
|
const findMany = jest.fn().mockResolvedValue([]);
|
||||||
|
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
|
||||||
|
await service.listUplinks(auth as never, { mobile: '13800138000', accessNumber: '1'.repeat(21) });
|
||||||
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ phoneNumber: '13800138000', destId: '1'.repeat(21) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
it('matches the published fixed GET signature vector', () => {
|
it('matches the published fixed GET signature vector', () => {
|
||||||
expect(
|
expect(
|
||||||
openApiSignature(
|
openApiSignature(
|
||||||
|
|||||||
@@ -449,6 +449,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (value !== undefined && typeof value !== 'string')
|
if (value !== undefined && typeof value !== 'string')
|
||||||
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
|
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
|
||||||
}
|
}
|
||||||
|
if (query.mobile !== undefined && !/^1\d{10}$/.test(query.mobile))
|
||||||
|
throw new BadRequestException({ code: 'MOBILE_INVALID', message: 'mobile必须为1开头的11位手机号' });
|
||||||
|
if (query.accessNumber !== undefined && !/^\d{1,21}$/.test(query.accessNumber))
|
||||||
|
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'accessNumber必须为1至21位数字接入号' });
|
||||||
const endTime = query.endTime !== undefined ? parseOpenApiDate(query.endTime) : new Date();
|
const endTime = query.endTime !== undefined ? parseOpenApiDate(query.endTime) : new Date();
|
||||||
const startTime =
|
const startTime =
|
||||||
query.startTime !== undefined ? parseOpenApiDate(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
|
query.startTime !== undefined ? parseOpenApiDate(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { SendBatchEntryService } from './send-batch-entry.service';
|
||||||
|
import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const application = {
|
||||||
|
id: 'app',
|
||||||
|
tenantId: 'tenant',
|
||||||
|
status: 'active',
|
||||||
|
interfaceEnabled: false,
|
||||||
|
httpConfig: { enabled: true, sendEnabled: true },
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
tenant: { findUnique: jest.fn().mockResolvedValue({ status: 'active', certificationStatus: 'approved' }) },
|
||||||
|
smsApplication: { findUnique: jest.fn().mockImplementation(async () => application) },
|
||||||
|
};
|
||||||
|
const facade = { validateSendResources: jest.fn().mockRejectedValue(new Error('stop after validation')) };
|
||||||
|
const service = new SendBatchEntryService(
|
||||||
|
prisma as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
facade as never,
|
||||||
|
undefined as never,
|
||||||
|
);
|
||||||
|
return { application, service, facade };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('independent HTTP send gate', () => {
|
||||||
|
it('accepts HTTP-only applications and still rejects ordinary sends with CMPP disabled', async () => {
|
||||||
|
const { service } = fixture();
|
||||||
|
await expect(
|
||||||
|
service.validateSendResources('tenant', 'app', undefined, { httpRequest: true }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(service.validateSendResources('tenant', 'app')).rejects.toThrow('短信应用接口未开通');
|
||||||
|
});
|
||||||
|
it.each(['enabled', 'sendEnabled'] as const)('rejects disabled HTTP %s even if CMPP is enabled', async (key) => {
|
||||||
|
const { service, application } = fixture();
|
||||||
|
application.interfaceEnabled = true;
|
||||||
|
application.httpConfig[key] = false;
|
||||||
|
await expect(service.validateSendResources('tenant', 'app', undefined, { httpRequest: true })).rejects.toThrow(
|
||||||
|
'HTTP发送未开通',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('uses only the internal request symbol, not a caller supplied sourceType', async () => {
|
||||||
|
const { service, facade } = fixture();
|
||||||
|
const data = {
|
||||||
|
tenantId: 'tenant',
|
||||||
|
applicationId: 'app',
|
||||||
|
content: 'test',
|
||||||
|
phones: ['13800138000'],
|
||||||
|
sourceType: 'api' as const,
|
||||||
|
};
|
||||||
|
await expect(service.createBatchTask(data)).rejects.toThrow('stop after validation');
|
||||||
|
expect(facade.validateSendResources).toHaveBeenLastCalledWith('tenant', 'app', undefined, { httpRequest: false });
|
||||||
|
await expect(
|
||||||
|
service.createBatchTask({ ...data, [HTTP_REQUEST_CONTEXT]: { id: 'req', requestId: 'req_id' } }),
|
||||||
|
).rejects.toThrow('stop after validation');
|
||||||
|
expect(facade.validateSendResources).toHaveBeenLastCalledWith('tenant', 'app', undefined, { httpRequest: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,7 +87,9 @@ export class SendBatchEntryService {
|
|||||||
const httpRequest = data[HTTP_REQUEST_CONTEXT];
|
const httpRequest = data[HTTP_REQUEST_CONTEXT];
|
||||||
const phones = [...new Set(data.phones ?? [])];
|
const phones = [...new Set(data.phones ?? [])];
|
||||||
const schedule = parseSchedule(data);
|
const schedule = parseSchedule(data);
|
||||||
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId, {
|
||||||
|
httpRequest: Boolean(httpRequest),
|
||||||
|
});
|
||||||
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
|
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
|
||||||
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
|
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
|
||||||
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
|
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
|
||||||
@@ -568,11 +570,17 @@ export class SendBatchEntryService {
|
|||||||
if (!applicationId) {
|
if (!applicationId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
const application = await this.prisma.smsApplication.findUnique({
|
||||||
|
where: { id: applicationId },
|
||||||
|
include: { httpConfig: true },
|
||||||
|
});
|
||||||
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
|
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
|
||||||
throw new BadRequestException('短信应用不存在或已停用');
|
throw new BadRequestException('短信应用不存在或已停用');
|
||||||
}
|
}
|
||||||
if (!application.interfaceEnabled) {
|
if (options.httpRequest && (!application.httpConfig?.enabled || !application.httpConfig.sendEnabled)) {
|
||||||
|
throw new BadRequestException('短信应用HTTP发送未开通,不能发送短信');
|
||||||
|
}
|
||||||
|
if (!options.httpRequest && !application.interfaceEnabled) {
|
||||||
throw new BadRequestException('短信应用接口未开通,不能发送短信');
|
throw new BadRequestException('短信应用接口未开通,不能发送短信');
|
||||||
}
|
}
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
|
|||||||
@@ -5,17 +5,32 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||||
import { MetricsService } from '../metrics/metrics.service';
|
import { MetricsService } from '../metrics/metrics.service';
|
||||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
import type {
|
||||||
|
CreateBatchTaskDto,
|
||||||
|
CreateHttpBatchTaskDto,
|
||||||
|
GatewayInboundAuthDto,
|
||||||
|
GatewayInboundSubmitDto,
|
||||||
|
ImportPreviewDto,
|
||||||
|
ConfirmImportDto,
|
||||||
|
SendJob,
|
||||||
|
QueuePriority,
|
||||||
|
RoutedChannel,
|
||||||
|
} from './send-chain.contracts';
|
||||||
import { SendBatchEntryService } from './send-batch-entry.service';
|
import { SendBatchEntryService } from './send-batch-entry.service';
|
||||||
import { SendGatewaySubmitService } from './send-gateway-submit.service';
|
import { SendGatewaySubmitService } from './send-gateway-submit.service';
|
||||||
import { SendInboundEntryService } from './send-inbound-entry.service';
|
import { SendInboundEntryService } from './send-inbound-entry.service';
|
||||||
import { SendReviewContinuationService } from './send-review-continuation.service';
|
import { SendReviewContinuationService } from './send-review-continuation.service';
|
||||||
import { SendScheduledDispatchService } from './send-scheduled-dispatch.service';
|
import { SendScheduledDispatchService } from './send-scheduled-dispatch.service';
|
||||||
|
|
||||||
|
|
||||||
export type SendSubmissionCallbacks = {
|
export type SendSubmissionCallbacks = {
|
||||||
releaseMessageReservation: (
|
releaseMessageReservation: (
|
||||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
message: {
|
||||||
|
tenantId: string;
|
||||||
|
batchTaskId: string;
|
||||||
|
messageId: string;
|
||||||
|
amountCents: number | bigint;
|
||||||
|
billingUnits: number;
|
||||||
|
},
|
||||||
remark: string,
|
remark: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
recordCmppFailureReceipt: (
|
recordCmppFailureReceipt: (
|
||||||
@@ -37,6 +52,7 @@ export type SendSubmissionCallbacks = {
|
|||||||
|
|
||||||
export type SendResourceValidationOptions = {
|
export type SendResourceValidationOptions = {
|
||||||
usePersistedTemplateSnapshot?: boolean;
|
usePersistedTemplateSnapshot?: boolean;
|
||||||
|
httpRequest?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,18 +75,57 @@ export class SendSubmissionService {
|
|||||||
callbacks: SendSubmissionCallbacks,
|
callbacks: SendSubmissionCallbacks,
|
||||||
metrics?: MetricsService,
|
metrics?: MetricsService,
|
||||||
) {
|
) {
|
||||||
this.batchEntry = new SendBatchEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
this.batchEntry = new SendBatchEntryService(
|
||||||
this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
|
prisma,
|
||||||
this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
billing,
|
||||||
this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
riskReview,
|
||||||
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
|
phoneFrequency,
|
||||||
|
phoneRouting,
|
||||||
|
facade,
|
||||||
|
callbacks,
|
||||||
|
);
|
||||||
|
this.inboundEntry = new SendInboundEntryService(
|
||||||
|
prisma,
|
||||||
|
billing,
|
||||||
|
riskReview,
|
||||||
|
phoneFrequency,
|
||||||
|
phoneRouting,
|
||||||
|
facade,
|
||||||
|
callbacks,
|
||||||
|
metrics,
|
||||||
|
);
|
||||||
|
this.reviewContinuation = new SendReviewContinuationService(
|
||||||
|
prisma,
|
||||||
|
billing,
|
||||||
|
riskReview,
|
||||||
|
phoneFrequency,
|
||||||
|
phoneRouting,
|
||||||
|
facade,
|
||||||
|
callbacks,
|
||||||
|
);
|
||||||
|
this.scheduledDispatch = new SendScheduledDispatchService(
|
||||||
|
prisma,
|
||||||
|
billing,
|
||||||
|
riskReview,
|
||||||
|
phoneFrequency,
|
||||||
|
phoneRouting,
|
||||||
|
facade,
|
||||||
|
callbacks,
|
||||||
|
);
|
||||||
|
this.gatewaySubmit = new SendGatewaySubmitService(
|
||||||
|
prisma,
|
||||||
|
billing,
|
||||||
|
riskReview,
|
||||||
|
phoneFrequency,
|
||||||
|
phoneRouting,
|
||||||
|
facade,
|
||||||
|
callbacks,
|
||||||
|
metrics,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleDestroy() {
|
onModuleDestroy() {
|
||||||
return Promise.all([
|
return Promise.all([this.gatewaySubmit.onModuleDestroy(), this.inboundEntry.stopInboundWorkflowWorker()]);
|
||||||
this.gatewaySubmit.onModuleDestroy(),
|
|
||||||
this.inboundEntry.stopInboundWorkflowWorker(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBatchTask(data: CreateBatchTaskDto) {
|
async createBatchTask(data: CreateBatchTaskDto) {
|
||||||
@@ -118,7 +173,12 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
|
|||||||
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
|
async validateSendResources(
|
||||||
|
tenantId: string,
|
||||||
|
applicationId?: string,
|
||||||
|
templateId?: string,
|
||||||
|
options?: SendResourceValidationOptions,
|
||||||
|
) {
|
||||||
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options);
|
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +210,14 @@ async submitCompleteInboundMessage(
|
|||||||
requestedMessageIds?: string[],
|
requestedMessageIds?: string[],
|
||||||
workflowKey?: string,
|
workflowKey?: string,
|
||||||
) {
|
) {
|
||||||
return this.inboundEntry.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey);
|
return this.inboundEntry.submitCompleteInboundMessage(
|
||||||
|
data,
|
||||||
|
phoneNumbers,
|
||||||
|
application,
|
||||||
|
requestedGroupMessageId,
|
||||||
|
requestedMessageIds,
|
||||||
|
workflowKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectInboundLongMessageFragment(
|
async collectInboundLongMessageFragment(
|
||||||
@@ -174,10 +241,19 @@ async submitInboundSingleMessage(
|
|||||||
receiptRejection?: { code: string; reason: string },
|
receiptRejection?: { code: string; reason: string },
|
||||||
workflowItemKey?: string,
|
workflowItemKey?: string,
|
||||||
) {
|
) {
|
||||||
return this.inboundEntry.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey);
|
return this.inboundEntry.submitInboundSingleMessage(
|
||||||
|
data,
|
||||||
|
messageId,
|
||||||
|
submitGroupMessageId,
|
||||||
|
application,
|
||||||
|
synchronousRejection,
|
||||||
|
receiptRejection,
|
||||||
|
workflowItemKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateRiskWithPhoneFrequency(input: {
|
async evaluateRiskWithPhoneFrequency(
|
||||||
|
input: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
@@ -185,7 +261,9 @@ async evaluateRiskWithPhoneFrequency(input: {
|
|||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
sourceType: 'cmpp';
|
sourceType: 'cmpp';
|
||||||
}, reservationKey?: string) {
|
},
|
||||||
|
reservationKey?: string,
|
||||||
|
) {
|
||||||
return this.inboundEntry.evaluateRiskWithPhoneFrequency(input, reservationKey);
|
return this.inboundEntry.evaluateRiskWithPhoneFrequency(input, reservationKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +283,12 @@ async resolveDrainageInfoMatch(signatureId: string | null | undefined, content:
|
|||||||
return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content);
|
return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
|
async attachMessageToReviewTask(
|
||||||
|
reviewTaskId: string,
|
||||||
|
messageRecordId: string,
|
||||||
|
signatureId: string,
|
||||||
|
drainageInfoId?: string,
|
||||||
|
) {
|
||||||
return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
|
return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,13 +356,29 @@ async submitMessageToGateway(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectChannelForMessage(
|
async selectChannelForMessage(
|
||||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
message: {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
applicationId?: string | null;
|
||||||
|
templateId?: string | null;
|
||||||
|
signatureId?: string | null;
|
||||||
|
phoneNumber: string;
|
||||||
|
carrier?: string | null;
|
||||||
|
province?: string | null;
|
||||||
|
template?: { signature?: { id?: string | null } | null } | null;
|
||||||
|
signature?: { id?: string | null } | null;
|
||||||
|
},
|
||||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||||
): Promise<RoutedChannel> {
|
): Promise<RoutedChannel> {
|
||||||
return this.gatewaySubmit.selectChannelForMessage(message, options);
|
return this.gatewaySubmit.selectChannelForMessage(message, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
|
async findApplicationRoute(
|
||||||
|
tenantId: string,
|
||||||
|
applicationId: string | undefined,
|
||||||
|
carrier: string,
|
||||||
|
signatureId?: string,
|
||||||
|
) {
|
||||||
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
|
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +403,12 @@ async ensureSignatureReportedForChannel(
|
|||||||
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId, carrier);
|
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId, carrier);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
async resolveMessageSignatureId(message: {
|
||||||
|
templateId?: string | null;
|
||||||
|
signatureId?: string | null;
|
||||||
|
template?: { signature?: { id?: string | null } | null } | null;
|
||||||
|
signature?: { id?: string | null } | null;
|
||||||
|
}) {
|
||||||
return this.gatewaySubmit.resolveMessageSignatureId(message);
|
return this.gatewaySubmit.resolveMessageSignatureId(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -495,8 +495,8 @@ Content-Type: application/problem+json; charset=utf-8
|
|||||||
|
|
||||||
| 参数 | 类型 | 必填 | 默认值 / 说明 |
|
| 参数 | 类型 | 必填 | 默认值 / 说明 |
|
||||||
| ------------ | ------ | ---- | ------------------------------------------------- |
|
| ------------ | ------ | ---- | ------------------------------------------------- |
|
||||||
| mobile | string | 否 | 精确匹配回复者手机号 |
|
| mobile | string | 否 | 1 开头的 11 位手机号,精确匹配 |
|
||||||
| accessNumber | string | 否 | 精确匹配短信接入号,对应返回字段 destId |
|
| accessNumber | string | 否 | 1–21 位数字接入号,精确匹配 destId |
|
||||||
| keyword | string | 否 | 筛选正文包含指定关键词的回复 |
|
| keyword | string | 否 | 筛选正文包含指定关键词的回复 |
|
||||||
| limit | 正整数 | 否 | 默认 50;按应用最大分页配置裁剪,系统默认上限 100 |
|
| limit | 正整数 | 否 | 默认 50;按应用最大分页配置裁剪,系统默认上限 100 |
|
||||||
| cursor | string | 否 | 首次不传;下一页使用上一响应的 nextCursor |
|
| cursor | string | 否 | 首次不传;下一页使用上一响应的 nextCursor |
|
||||||
|
|||||||
@@ -2312,3 +2312,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
|||||||
## 2026-09-15 HTTP 客户手册与签名简化
|
## 2026-09-15 HTTP 客户手册与签名简化
|
||||||
|
|
||||||
按用户确认的 B6 手册实施,原2.4移为1.4。POST直接签原始UTF-8正文,GET四项LF分隔且末尾无LF;不兼容尝试旧摘要签名。保持凭据、权限、时间窗、nonce去重、内部发送幂等及Webhook规则。公开页、客户端独立文档页与MD下载共用权威手册,Node示例和完整HTTP报文按新规则验证。新规则替代此前三步正文摘要签名需求,适用本次代码版本;仅授权测试部署,预生产不变。设计及兼容影响见[HTTP整改方案](http-api-assessment-20260910.md)的2026-09-15章节。
|
按用户确认的 B6 手册实施,原2.4移为1.4。POST直接签原始UTF-8正文,GET四项LF分隔且末尾无LF;不兼容尝试旧摘要签名。保持凭据、权限、时间窗、nonce去重、内部发送幂等及Webhook规则。公开页、客户端独立文档页与MD下载共用权威手册,Node示例和完整HTTP报文按新规则验证。新规则替代此前三步正文摘要签名需求,适用本次代码版本;仅授权测试部署,预生产不变。设计及兼容影响见[HTTP整改方案](http-api-assessment-20260910.md)的2026-09-15章节。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-15 HTTP调试工具与合并整改
|
||||||
|
|
||||||
|
继续前述新签名规则,客户端与运营端新增HTTP签名计算页:输入方法、路径、秒时间戳、nonce、AccessSecret和POST原始正文,本地生成HMAC-SHA256并复制,不上传/存储密钥、不发短信。修改输入使旧结果失效。运营端签名与引流报备统一三运营商列组件;客户端企业认证仅隐藏新申请入口,保留状态/进度。HTTP发送按独立HTTP总开关/发送能力校验,不能依赖CMPP开关。上行手机号过滤须1开头11位,接入号1–21位数字,非法返回400。设计及真实验收边界见http-api-assessment-20260910.md的2026-09-15补充。
|
||||||
|
|||||||
@@ -317,3 +317,22 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
|||||||
- 时间戳窗口、nonce Redis 原子去重、租户/应用隔离、白名单、QPS 和日志脱敏保持。发送幂等使用的内部 bodyHash 保持,已有请求快照/计费/消息/队列均不迁移、不补投。Webhook 验签不变。
|
- 时间戳窗口、nonce Redis 原子去重、租户/应用隔离、白名单、QPS 和日志脱敏保持。发送幂等使用的内部 bodyHash 保持,已有请求快照/计费/消息/队列均不迁移、不补投。Webhook 验签不变。
|
||||||
- 数据模型、基础设施配置和 Gateway 无改动。历史查询 startTime/endTime 能力保留;手册简化介绍不代表删除后台兼容参数。
|
- 数据模型、基础设施配置和 Gateway 无改动。历史查询 startTime/endTime 能力保留;手册简化介绍不代表删除后台兼容参数。
|
||||||
- 验收:固定 POST/GET 向量、旧签名拒绝、末尾 LF/CRLF/字面反斜杠/正文空格与字段顺序篡改、缺失 rawBody、非空 GET、重放、权限与隔离;真实 HTTP/PG/Redis 验证以隔离数据和无发送请求完成,线上不创建短信或更改客户配置。两段 Node 示例从权威文档提取运行并与全部 HTTP 报文复算;公开页面三尺寸、目录/检索/下载及客户端共享入口验收。按精确提交标准 validate/preflight/prepare/deploy/verify。
|
- 验收:固定 POST/GET 向量、旧签名拒绝、末尾 LF/CRLF/字面反斜杠/正文空格与字段顺序篡改、缺失 rawBody、非空 GET、重放、权限与隔离;真实 HTTP/PG/Redis 验证以隔离数据和无发送请求完成,线上不创建短信或更改客户配置。两段 Node 示例从权威文档提取运行并与全部 HTTP 报文复算;公开页面三尺寸、目录/检索/下载及客户端共享入口验收。按精确提交标准 validate/preflight/prepare/deploy/verify。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-15 接口签名工具与完整链路复验(实施前设计)
|
||||||
|
|
||||||
|
本轮继续18ecf80的新签名规则,暂停旧候选发布,四项完成后统一测试部署。两端新增独立HTTP签名计算页,复用同一实现和既有登录边界。输入方法GET/POST、请求路径(不含query)、秒时间戳、nonce、AccessSecret及原始JSON正文;浏览器本地HMAC-SHA256计算,不调用后台计算接口,不存储秘密,不自动发送请求。支持HTTP测试站点,使用独立小型HMAC库,不依赖仅安全上下文可用的crypto.subtle。显示签名原文、UTF8字节数与签名,支持复制和清空;修改任一输入立即使旧结果失效。GET无正文及末尾LF;POST保留空白、换行和原始文本,不重新序列化。提供更新时间戳/nonce操作及明确失败提示。页面路由/client/http-signature、/admin/http-signature,菜单分别在短信基础配置和客户管理。无数据库迁移、无新增服务端权限/API。
|
||||||
|
|
||||||
|
运营端签名和引流报备弹窗统一使用同一三运营商列组件,保留通道、独立状态、原因、历史继承及失败反馈。当前源码已具有三列结构,先核对真实页面和部署资源;不将用户反馈直接认定为数据模型缺陷。客户端只隐藏新申请/重新申请企业认证按钮,审核进度与现有资料仍可查看,后台认证能力保留。
|
||||||
|
|
||||||
|
授权测试仅专用企业/应用、模拟通道和号码,必要配置限专用对象;覆盖四公开接口的缺失/类型/长度/时间/签名/nonce/权限/租户/幂等/分页错误,以及发送后的黑名单、验证码、频次、模板、签名和引流报备拦截。少量串行发送,核对真实PG、Redis、Gateway、回执、Webhook和计费;任何意外真实通道或持续积压立即停止。复用9月14日矩阵但按新规则重新执行,不沿用旧通过结论。验收后停用新测试应用/凭据并保留证据。用户已授权本轮代码提交、推送及测试部署;预生产不在范围。
|
||||||
|
|
||||||
|
|
||||||
|
### HTTP-0915-B01 上行查询号码格式校验
|
||||||
|
|
||||||
|
测试4665079真实API返回mobile=abc/1及accessNumber=<script>的200空集;补手机号1开头11位、接入号1至21位数字校验,非法400,复用MOBILE_INVALID/PARAMETER_INVALID。仅约束明确提供的过滤条件,省略不筛选,合法值继续精确匹配;无数据迁移和查询范围变化。
|
||||||
|
|
||||||
|
|
||||||
|
### HTTP-0915-B02 HTTP发送误依赖CMPP开关
|
||||||
|
|
||||||
|
真实测试企业HTTP enabled/sendEnabled=true、CMPP interfaceEnabled=false时,合法模板请求422,未创建短信。根因SendBatchEntryService.validateSendResources无条件读取interfaceEnabled,与需求HTTP客户接口第一版的独立开通矛盾。仅由内部HTTP_REQUEST_CONTEXT符号确认的HTTP请求传递httpRequest校验选项,再读取真实httpConfig.enabled/sendEnabled;普通客户端及CMPP路径保留原检查,企业认证、租户、应用状态及模板校验不变。没有数据库迁移和计费规则变化。须真实复验CMPP关闭HTTP送达、HTTP关闭拒绝及原CMPP拒绝回归。
|
||||||
|
|||||||
@@ -5541,3 +5541,12 @@ CLIENT-0914-01~07 的模板样式/顺序、文档归属与检索、中文状
|
|||||||
| HTTP-SIGN-0915-05 | 同幂等键/同正文仍读取既有requires_review;改正文409冲突;无消息、批次或队列新增,不恢复未知发送。 |
|
| HTTP-SIGN-0915-05 | 同幂等键/同正文仍读取既有requires_review;改正文409冲突;无消息、批次或队列新增,不恢复未知发送。 |
|
||||||
| HTTP-SIGN-0915-06 | 原2.4移到1.4并修正引用;MD下载与源一致,三尺寸目录/刷新/检索/空结果/复制正常,加粗标题成为具名示例;不执行示例请求。 |
|
| HTTP-SIGN-0915-06 | 原2.4移到1.4并修正引用;MD下载与源一致,三尺寸目录/刷新/检索/空结果/复制正常,加粗标题成为具名示例;不执行示例请求。 |
|
||||||
| HTTP-SIGN-0915-07 | 精确提交测试发布后核对真实文档及新签名查询;旧算法拒绝。账号/凭据缺失时不恢复或新建线上配置,明确未执行范围。 |
|
| HTTP-SIGN-0915-07 | 精确提交测试发布后核对真实文档及新签名查询;旧算法拒绝。账号/凭据缺失时不恢复或新建线上配置,明确未执行范围。 |
|
||||||
|
|
||||||
|
|
||||||
|
## HTTP及页面合并整改(2026-09-15)
|
||||||
|
|
||||||
|
- HTTP-0915-UI01:两端登录内菜单进入签名工具,POST/GET与文档固定向量及真实API一致,GET末尾无LF、POST空白/中文保持;改参数清除旧结果,空/非法输入反馈,清空密钥,复制降级、刷新、路由切换及三尺寸。
|
||||||
|
- HTTP-0915-UI02:签名/引流报备三列同构、三网分别保存并刷新回读、空目标/失败不假成功;认证未认证/驳回无申请按钮,审核中保留进度。
|
||||||
|
- HTTP-0915-B01:上行mobile=abc/1/空/超长,accessNumber=非数字/空/22位返回400;合法11位手机号和1–21位接入号精确匹配。
|
||||||
|
- HTTP-0915-B02:仅HTTP开通、CMPP关闭时合法请求正常送达;HTTP总开关或sendEnabled关闭仍拒绝,客户端/CMPP原关闭检查保留;外部sourceType不能模拟内部HTTP上下文。
|
||||||
|
- HTTP-0915-E2E:四公开接口鉴权/nonce/过期/签名篡改/参数类型及边界/幂等/重复/隔离/分页;专用模拟通道正常与失败回执、黑名单/号码频次/模板/签名/引流/敏感词拦截,核对真实PG、Redis、Gateway、Webhook签名/成功/重试/终止及扣退费;测试后排空并停用本轮对象。每项执行结论写进度和证据矩阵,不把旧版本通过当新版本通过。
|
||||||
|
|||||||
@@ -5029,3 +5029,16 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
|||||||
定向31项、初轮API全量75套817项及构建通过。独立本地PG16424/cmpp_qa_http_signature(104迁移)、Redis16425、真实Nest16426验收16组通过,包含查询、隔离、篡改、旧算法、重放、幂等及MD下载,短信/批次数均0。仅安全辅助日志隔离为空实现,不将日志持久化标为验收。未启动发送或回调Worker;Redis5.0.14提示建议6.2+,实际nonce命令通过。样本初轮漏carriers、参数错误码断言与真实DTO不一致、幂等样本漏credentialId,修正并保留失败日志;不视为业务缺陷。另一本地PG16安装缺dict_snowball,使用既有完整pgsql安装启动本轮隔离实例。
|
定向31项、初轮API全量75套817项及构建通过。独立本地PG16424/cmpp_qa_http_signature(104迁移)、Redis16425、真实Nest16426验收16组通过,包含查询、隔离、篡改、旧算法、重放、幂等及MD下载,短信/批次数均0。仅安全辅助日志隔离为空实现,不将日志持久化标为验收。未启动发送或回调Worker;Redis5.0.14提示建议6.2+,实际nonce命令通过。样本初轮漏carriers、参数错误码断言与真实DTO不一致、幂等样本漏credentialId,修正并保留失败日志;不视为业务缺陷。另一本地PG16安装缺dict_snowball,使用既有完整pgsql安装启动本轮隔离实例。
|
||||||
|
|
||||||
CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页面横向溢出;1.4/2.2/2.3顺序、36个具名示例、目录跳转、复制、刷新、错误码检索和空结果通过,控制台无warn/error。当前仅本地页面,线上验收另记。证据目录.local-data/http-signature-20260915;最终精确候选门禁/提交/推送/发布后补。无预生产或业务配置修改、无短信发送。
|
CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页面横向溢出;1.4/2.2/2.3顺序、36个具名示例、目录跳转、复制、刷新、错误码检索和空结果通过,控制台无warn/error。当前仅本地页面,线上验收另记。证据目录.local-data/http-signature-20260915;最终精确候选门禁/提交/推送/发布后补。无预生产或业务配置修改、无短信发送。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-15 HTTP改造与追加四项合并实施(进行中)
|
||||||
|
|
||||||
|
前一接口协议提交18ecf8045f488af349812938d513753aa25ec237已推送;用户暂停发布时原计划仅preflight成功,prepare停在密码提示,已停止本轮终端,测试保持4665079。后续四项与此前协议一并交付,不取消前述改造。main/远端18ecf80、暂存空;已有metrics、tools/release及文档保护。
|
||||||
|
|
||||||
|
本轮统一签名/引流三网列组件、隐藏认证申请入口,新增两端共用浏览器签名页;@noble/hashes小模块用于HTTP站点本地计算,不上传秘密。33文件158前端测试、类型/生产构建、lint/style/CSS15门禁通过;组件测试曾标签/断言匹配失败,修正后全量通过,保留初始日志。生产构建浏览器POST/GET文档向量、复制、清空、1600/1366/390尺寸通过;Vite dev导航超时,使用同源码production验收入口。完整在线登录页另验。
|
||||||
|
|
||||||
|
真实测试旧版本72条参数请求发现上行号码过滤未校验(B01),HTTP独立应用被CMPP开关拒绝(B02),均已最小修复并补回归。首轮GET误用空字节摘要导致测试401,按旧协议应为{}摘要,修正后69/72通过,3条为B01真实缺陷;没有短信创建。一次正常发送因B02返回422,未创建短信。API上一轮75套825项通过,新增B02后全量及真实发布候选门禁待完成。
|
||||||
|
|
||||||
|
用户明确授权仅专用企业、应用、模拟通道及号码发送/回执/Webhook和必要测试配置。已新建HTTP隔离0915企业、2应用、3条远端loopback模拟通道、测试余额及对应流水;原客户/通道无修改。模拟器仅接受指定测试账号与号码、最多40个Submit,不连接运营商。当前准备上线新代码后完成真实链路复验及测试收尾。证据.local-data/http-complete-20260915。
|
||||||
|
|
||||||
|
用户要求安全复用sudo密码:已存当前Windows用户DPAPI加密文件(仓库外),不写明文;本机受保护未跟踪release.py仅新增test专用--sudo-credential及测试,47项工具测试含5项平台跳过。发现PowerShell7模块路径影响PowerShell5解密,调用时移除继承PSModulePath后真实只读SSH通过。工具变更后新计划绑定新摘要,不复用旧preflight。工具仍未提交,不能以应用SHA冒充工具版本。后续发布/推送及完成证据另行追加;预生产不在范围。
|
||||||
|
|||||||
Generated
+13
@@ -8,6 +8,7 @@
|
|||||||
"name": "cmpp-platform-frontend",
|
"name": "cmpp-platform-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.4.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"bullmq": "^5.79.2",
|
"bullmq": "^5.79.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
@@ -1194,6 +1195,18 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@emnapi/runtime": "^1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"verify:quality": "npm run lint && npm run format:check && npm run test:frontend:coverage && npm run build && npm run bundle:verify && npm --prefix api run test:incremental-coverage && npm --prefix api run test:coverage"
|
"verify:quality": "npm run lint && npm run format:check && npm run test:frontend:coverage && npm run build && npm run bundle:verify && npm --prefix api run test:incremental-coverage && npm --prefix api run test:coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.4.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"bullmq": "^5.79.2",
|
"bullmq": "^5.79.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function ChannelReportStatusModal({
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const targets = item.reportTargets ?? [];
|
const targets = item.reportTargets ?? [];
|
||||||
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
|
||||||
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
||||||
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
||||||
);
|
);
|
||||||
@@ -80,45 +79,11 @@ export function ChannelReportStatusModal({
|
|||||||
<span>修改具体通道的报备状态;保存后同步通道详情、报备任务和企业签名三网状态。</span>
|
<span>修改具体通道的报备状态;保存后同步通道详情、报备任务和企业签名三网状态。</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
{targets.length ? (
|
<CarrierReportColumns
|
||||||
<div className="signature-report-status__carriers">
|
targets={targets}
|
||||||
{carriers.map((carrier) => {
|
statuses={statuses}
|
||||||
const carrierTargets = targets.filter((target) => target.carrier === carrier);
|
onChange={(key, status) => setStatuses((current) => ({ ...current, [key]: status }))}
|
||||||
return (
|
|
||||||
<section className="signature-report-status__carrier" key={carrier}>
|
|
||||||
<header>
|
|
||||||
<CarrierTag carrier={carrier} />
|
|
||||||
<span>{carrierTargets.length} 个通道</span>
|
|
||||||
</header>
|
|
||||||
<div className="signature-report-status__list">
|
|
||||||
{carrierTargets.length ? (
|
|
||||||
carrierTargets.map((target) => {
|
|
||||||
const key = `${target.channelId}:${target.carrier}`;
|
|
||||||
return (
|
|
||||||
<div className="signature-report-status__row" key={key}>
|
|
||||||
<strong title={target.channel.name}>{target.channel.name}</strong>
|
|
||||||
<Select
|
|
||||||
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
|
|
||||||
onChange={(event) =>
|
|
||||||
setStatuses((current) => ({ ...current, [key]: event.target.value }))
|
|
||||||
}
|
|
||||||
options={reportStatusOptions}
|
|
||||||
value={statuses[key] ?? target.status}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">该企业应用当前没有配置目标通道。</div>
|
|
||||||
)}
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="修改原因"
|
label="修改原因"
|
||||||
onChange={(event) => setReason(event.target.value)}
|
onChange={(event) => setReason(event.target.value)}
|
||||||
@@ -143,7 +108,6 @@ export function DrainageReportStatusModal({
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||||
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
|
||||||
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
||||||
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
||||||
);
|
);
|
||||||
@@ -201,48 +165,11 @@ export function DrainageReportStatusModal({
|
|||||||
<span>分别修改各通道的移动、联通、电信报备状态;历史通道级状态在保存后按运营商独立管理。</span>
|
<span>分别修改各通道的移动、联通、电信报备状态;历史通道级状态在保存后按运营商独立管理。</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
{targets.length ? (
|
<CarrierReportColumns
|
||||||
<div className="signature-report-status__carriers">
|
targets={targets}
|
||||||
{carriers.map((carrier) => {
|
statuses={statuses}
|
||||||
const carrierTargets = targets.filter((target) => target.carrier === carrier);
|
onChange={(key, status) => setStatuses((current) => ({ ...current, [key]: status }))}
|
||||||
return (
|
|
||||||
<section className="signature-report-status__carrier" key={carrier}>
|
|
||||||
<header>
|
|
||||||
<CarrierTag carrier={carrier} />
|
|
||||||
<span>{carrierTargets.length} 个通道</span>
|
|
||||||
</header>
|
|
||||||
<div className="signature-report-status__list">
|
|
||||||
{carrierTargets.length ? (
|
|
||||||
carrierTargets.map((target) => {
|
|
||||||
const key = `${target.channelId}:${target.carrier}`;
|
|
||||||
return (
|
|
||||||
<div className="signature-report-status__row" key={key}>
|
|
||||||
<strong title={target.channel.name}>
|
|
||||||
{target.channel.name}
|
|
||||||
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
|
|
||||||
</strong>
|
|
||||||
<Select
|
|
||||||
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
|
|
||||||
onChange={(event) =>
|
|
||||||
setStatuses((current) => ({ ...current, [key]: event.target.value }))
|
|
||||||
}
|
|
||||||
options={reportStatusOptions}
|
|
||||||
value={statuses[key] ?? target.status}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">该企业应用当前没有配置目标通道。</div>
|
|
||||||
)}
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="修改原因"
|
label="修改原因"
|
||||||
onChange={(event) => setReason(event.target.value)}
|
onChange={(event) => setReason(event.target.value)}
|
||||||
@@ -284,3 +211,65 @@ export function ConfirmModal({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CarrierReportColumns({
|
||||||
|
targets,
|
||||||
|
statuses,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
targets: Array<{
|
||||||
|
channelId: string;
|
||||||
|
carrier: 'mobile' | 'unicom' | 'telecom';
|
||||||
|
status: string;
|
||||||
|
channel: { name: string };
|
||||||
|
approvalScope?: string;
|
||||||
|
}>;
|
||||||
|
statuses: Record<string, string>;
|
||||||
|
onChange: (key: string, status: string) => void;
|
||||||
|
}) {
|
||||||
|
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{targets.length ? (
|
||||||
|
<div className="signature-report-status__carriers">
|
||||||
|
{carriers.map((carrier) => {
|
||||||
|
const carrierTargets = targets.filter((target) => target.carrier === carrier);
|
||||||
|
return (
|
||||||
|
<section className="signature-report-status__carrier" key={carrier}>
|
||||||
|
<header>
|
||||||
|
<CarrierTag carrier={carrier} />
|
||||||
|
<span>{carrierTargets.length} 个通道</span>
|
||||||
|
</header>
|
||||||
|
<div className="signature-report-status__list">
|
||||||
|
{carrierTargets.length ? (
|
||||||
|
carrierTargets.map((target) => {
|
||||||
|
const key = `${target.channelId}:${target.carrier}`;
|
||||||
|
return (
|
||||||
|
<div className="signature-report-status__row" key={key}>
|
||||||
|
<strong title={target.channel.name}>
|
||||||
|
{target.channel.name}
|
||||||
|
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
|
||||||
|
</strong>
|
||||||
|
<Select
|
||||||
|
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
|
||||||
|
onChange={(event) => onChange(key, event.target.value)}
|
||||||
|
options={reportStatusOptions}
|
||||||
|
value={statuses[key] ?? target.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">该企业应用当前没有配置目标通道。</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import { AlertCircle, Check, ChevronRight, Landmark, ShieldCheck, Upload, UserCheck } from 'lucide-react';
|
||||||
AlertCircle,
|
|
||||||
Check,
|
|
||||||
ChevronRight,
|
|
||||||
Landmark,
|
|
||||||
ShieldCheck,
|
|
||||||
Upload,
|
|
||||||
UserCheck,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
||||||
import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
|
import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
|
||||||
import { displayFileName } from '@/utils/fileName';
|
import { displayFileName } from '@/utils/fileName';
|
||||||
@@ -52,7 +44,15 @@ const emptyCertificationForm: CertificationForm = {
|
|||||||
legalPersonIdCard: '',
|
legalPersonIdCard: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; uploading: boolean; onFile: (file: File | undefined) => void }) {
|
function UploadPanel({
|
||||||
|
file,
|
||||||
|
uploading,
|
||||||
|
onFile,
|
||||||
|
}: {
|
||||||
|
file: FileObject | null;
|
||||||
|
uploading: boolean;
|
||||||
|
onFile: (file: File | undefined) => void;
|
||||||
|
}) {
|
||||||
const fileRef: FileRef | null = file
|
const fileRef: FileRef | null = file
|
||||||
? { contentType: file.contentType, fileName: file.fileName, fileObjectId: file.id }
|
? { contentType: file.contentType, fileName: file.fileName, fileObjectId: file.id }
|
||||||
: null;
|
: null;
|
||||||
@@ -85,7 +85,11 @@ function EnterpriseStepper({ current }: { current: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="enterprise-stepper__item" key={label}>
|
<div className="enterprise-stepper__item" key={label}>
|
||||||
<span className={['enterprise-stepper__dot', complete ? 'is-complete' : '', active ? 'is-active' : ''].filter(Boolean).join(' ')}>
|
<span
|
||||||
|
className={['enterprise-stepper__dot', complete ? 'is-complete' : '', active ? 'is-active' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
{complete ? <Check size={20} /> : step}
|
{complete ? <Check size={20} /> : step}
|
||||||
</span>
|
</span>
|
||||||
<strong className={active || complete ? 'is-active' : ''}>{label}</strong>
|
<strong className={active || complete ? 'is-active' : ''}>{label}</strong>
|
||||||
@@ -108,7 +112,9 @@ function AuthHeader({ status }: { status: CertificationStatus }) {
|
|||||||
return (
|
return (
|
||||||
<div className="system-page-toolbar">
|
<div className="system-page-toolbar">
|
||||||
<div className="sms-send-title">
|
<div className="sms-send-title">
|
||||||
<span className="sms-send-title__icon"><ShieldCheck size={22} /></span>
|
<span className="sms-send-title__icon">
|
||||||
|
<ShieldCheck size={22} />
|
||||||
|
</span>
|
||||||
<h1>企业认证</h1>
|
<h1>企业认证</h1>
|
||||||
</div>
|
</div>
|
||||||
<span className={`enterprise-review-status enterprise-review-status--${status}`}>{statusText[status]}</span>
|
<span className={`enterprise-review-status enterprise-review-status--${status}`}>{statusText[status]}</span>
|
||||||
@@ -140,15 +146,27 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const currentStep = step === 'profile' ? 1 : step === 'method' ? 2 : step === 'recharge' || step === 'face' || step === 'faceScan' ? 3 : step === 'pending' || step === 'success' || step === 'failed' ? 4 : 1;
|
const currentStep =
|
||||||
|
step === 'profile'
|
||||||
|
? 1
|
||||||
|
: step === 'method'
|
||||||
|
? 2
|
||||||
|
: step === 'recharge' || step === 'face' || step === 'faceScan'
|
||||||
|
? 3
|
||||||
|
: step === 'pending' || step === 'success' || step === 'failed'
|
||||||
|
? 4
|
||||||
|
: 1;
|
||||||
const certificationMaterials = latestCertification?.materials ?? {};
|
const certificationMaterials = latestCertification?.materials ?? {};
|
||||||
const displayCompany = latestCertification?.companyName || form.companyName || companyInfo.name;
|
const displayCompany = latestCertification?.companyName || form.companyName || companyInfo.name;
|
||||||
const displayLicenseNo = latestCertification?.licenseNo || form.licenseNo || companyInfo.code;
|
const displayLicenseNo = latestCertification?.licenseNo || form.licenseNo || companyInfo.code;
|
||||||
const displayAddress = String(certificationMaterials.address ?? (form.address || companyInfo.address));
|
const displayAddress = String(certificationMaterials.address ?? (form.address || companyInfo.address));
|
||||||
const displayLegalPerson = String(certificationMaterials.legalPerson ?? (form.legalPerson || companyInfo.legalPerson));
|
const displayLegalPerson = String(
|
||||||
|
certificationMaterials.legalPerson ?? (form.legalPerson || companyInfo.legalPerson),
|
||||||
|
);
|
||||||
|
|
||||||
function loadCertification() {
|
function loadCertification() {
|
||||||
clientApi.listEnterpriseCertifications()
|
clientApi
|
||||||
|
.listEnterpriseCertifications()
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
const latest = items[0] ?? null;
|
const latest = items[0] ?? null;
|
||||||
setLatestCertification(latest);
|
setLatestCertification(latest);
|
||||||
@@ -185,7 +203,8 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
function uploadLicense(file: File | undefined) {
|
function uploadLicense(file: File | undefined) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
clientApi.uploadFileObject(file, { purpose: 'enterprise_certification', prefix: 'enterprise-certifications/license' })
|
clientApi
|
||||||
|
.uploadFileObject(file, { purpose: 'enterprise_certification', prefix: 'enterprise-certifications/license' })
|
||||||
.then((fileObject) => {
|
.then((fileObject) => {
|
||||||
setLicenseFile(fileObject);
|
setLicenseFile(fileObject);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -201,7 +220,8 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
clientApi.submitEnterpriseCertification({
|
clientApi
|
||||||
|
.submitEnterpriseCertification({
|
||||||
companyName: form.companyName.trim(),
|
companyName: form.companyName.trim(),
|
||||||
licenseNo: form.licenseNo.trim(),
|
licenseNo: form.licenseNo.trim(),
|
||||||
contactName: form.contactName.trim(),
|
contactName: form.contactName.trim(),
|
||||||
@@ -231,7 +251,8 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (step === 'overview') {
|
if (step === 'overview') {
|
||||||
const overviewCopy = status === 'approved'
|
const overviewCopy =
|
||||||
|
status === 'approved'
|
||||||
? '企业认证已审核通过,可正常使用发送、签名报备等能力。'
|
? '企业认证已审核通过,可正常使用发送、签名报备等能力。'
|
||||||
: status === 'pending'
|
: status === 'pending'
|
||||||
? '企业认证资料已提交,平台运营将在 1 个工作日内完成审核。'
|
? '企业认证资料已提交,平台运营将在 1 个工作日内完成审核。'
|
||||||
@@ -246,20 +267,35 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
|
|
||||||
<div className={`surface enterprise-status-card enterprise-status-card--${status}`}>
|
<div className={`surface enterprise-status-card enterprise-status-card--${status}`}>
|
||||||
<strong>{overviewCopy}</strong>
|
<strong>{overviewCopy}</strong>
|
||||||
{status === 'approved' ? null : (
|
{status === 'pending' ? (
|
||||||
<button type="button" onClick={() => setStep(status === 'pending' ? 'pending' : 'profile')}>
|
<button type="button" onClick={() => setStep('pending')}>
|
||||||
{status === 'pending' ? '查看审核进度 >' : '企业认证 >'}
|
查看审核进度 >
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface enterprise-info-card">
|
<div className="surface enterprise-info-card">
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>企业名称:</dt><dd>{latestCertification ? displayCompany : '待认证'}</dd></div>
|
<div>
|
||||||
<div><dt>认证时间:</dt><dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '待审核完成'}</dd></div>
|
<dt>企业名称:</dt>
|
||||||
<div><dt>统一社会信用代码:</dt><dd>{latestCertification ? displayLicenseNo : '待认证'}</dd></div>
|
<dd>{latestCertification ? displayCompany : '待认证'}</dd>
|
||||||
<div><dt>通讯地址:</dt><dd>{latestCertification ? displayAddress : '待认证'}</dd></div>
|
</div>
|
||||||
<div><dt>法定代表人:</dt><dd>{latestCertification ? displayLegalPerson : '待认证'}</dd></div>
|
<div>
|
||||||
|
<dt>认证时间:</dt>
|
||||||
|
<dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '待审核完成'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>统一社会信用代码:</dt>
|
||||||
|
<dd>{latestCertification ? displayLicenseNo : '待认证'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>通讯地址:</dt>
|
||||||
|
<dd>{latestCertification ? displayAddress : '待认证'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>法定代表人:</dt>
|
||||||
|
<dd>{latestCertification ? displayLegalPerson : '待认证'}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -280,8 +316,19 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
<UploadPanel file={licenseFile} onFile={uploadLicense} uploading={uploading} />
|
<UploadPanel file={licenseFile} onFile={uploadLicense} uploading={uploading} />
|
||||||
<p className="enterprise-help">请上传电子版营业执照,JPG或PNG格式,大小不超过5M</p>
|
<p className="enterprise-help">请上传电子版营业执照,JPG或PNG格式,大小不超过5M</p>
|
||||||
|
|
||||||
<Input label="* 企业名称" onChange={(event) => updateForm('companyName', event.target.value)} placeholder="请填写企业全称" hint="请严格按照营业执照上的企业名称进行填写" value={form.companyName} />
|
<Input
|
||||||
<Input label="* 统一社会信用代码/其他组织机构代码" onChange={(event) => updateForm('licenseNo', event.target.value)} placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)" value={form.licenseNo} />
|
label="* 企业名称"
|
||||||
|
onChange={(event) => updateForm('companyName', event.target.value)}
|
||||||
|
placeholder="请填写企业全称"
|
||||||
|
hint="请严格按照营业执照上的企业名称进行填写"
|
||||||
|
value={form.companyName}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 统一社会信用代码/其他组织机构代码"
|
||||||
|
onChange={(event) => updateForm('licenseNo', event.target.value)}
|
||||||
|
placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)"
|
||||||
|
value={form.licenseNo}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="enterprise-address-selects">
|
<div className="enterprise-address-selects">
|
||||||
<span>* 通讯地址</span>
|
<span>* 通讯地址</span>
|
||||||
@@ -310,16 +357,43 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Textarea onChange={(event) => updateForm('address', event.target.value)} placeholder="请填写详细的通讯地址,可与证件上的地址不一致" rows={5} value={form.address} />
|
<Textarea
|
||||||
|
onChange={(event) => updateForm('address', event.target.value)}
|
||||||
|
placeholder="请填写详细的通讯地址,可与证件上的地址不一致"
|
||||||
|
rows={5}
|
||||||
|
value={form.address}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="enterprise-form-note">为方便沟通企业进展情况,需补充联系人(法人或员工都可)信息</p>
|
<p className="enterprise-form-note">为方便沟通企业进展情况,需补充联系人(法人或员工都可)信息</p>
|
||||||
<Input label="* 企业联系人姓名" onChange={(event) => updateForm('contactName', event.target.value)} placeholder="请填写企业联系人姓名" value={form.contactName} />
|
<Input
|
||||||
<Input label="* 企业联系人身份证号" onChange={(event) => updateForm('contactIdCard', event.target.value)} placeholder="请填写企业联系人身份证号" value={form.contactIdCard} />
|
label="* 企业联系人姓名"
|
||||||
<Input label="* 企业联系人手机号" onChange={(event) => updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" value={form.contactPhone} />
|
onChange={(event) => updateForm('contactName', event.target.value)}
|
||||||
<Input label="企业联系人邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" value={form.contactEmail} />
|
placeholder="请填写企业联系人姓名"
|
||||||
|
value={form.contactName}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 企业联系人身份证号"
|
||||||
|
onChange={(event) => updateForm('contactIdCard', event.target.value)}
|
||||||
|
placeholder="请填写企业联系人身份证号"
|
||||||
|
value={form.contactIdCard}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 企业联系人手机号"
|
||||||
|
onChange={(event) => updateForm('contactPhone', event.target.value)}
|
||||||
|
placeholder="请填写企业联系人手机号"
|
||||||
|
value={form.contactPhone}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="企业联系人邮箱"
|
||||||
|
onChange={(event) => updateForm('contactEmail', event.target.value)}
|
||||||
|
placeholder="请填写企业联系人邮箱"
|
||||||
|
value={form.contactEmail}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="enterprise-actions">
|
<div className="enterprise-actions">
|
||||||
<Button onClick={() => setStep('overview')} variant="secondary">取消</Button>
|
<Button onClick={() => setStep('overview')} variant="secondary">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setStep('method')}>下一步</Button>
|
<Button onClick={() => setStep('method')}>下一步</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,20 +408,26 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
>
|
>
|
||||||
<UserCheck size={56} />
|
<UserCheck size={56} />
|
||||||
<div>
|
<div>
|
||||||
<strong>企业法人人脸识别认证 <span>即时完成</span></strong>
|
<strong>
|
||||||
|
企业法人人脸识别认证 <span>即时完成</span>
|
||||||
|
</strong>
|
||||||
<p>填写法人姓名与身份证号码</p>
|
<p>填写法人姓名与身份证号码</p>
|
||||||
<p>企业法人亲自进行校验</p>
|
<p>企业法人亲自进行校验</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={['enterprise-method-card', method === 'recharge' ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
className={['enterprise-method-card', method === 'recharge' ? 'is-selected' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
onClick={() => setMethod('recharge')}
|
onClick={() => setMethod('recharge')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Landmark size={56} />
|
<Landmark size={56} />
|
||||||
<div>
|
<div>
|
||||||
<strong>聆界平台充值认证 <span>1个工作日完成</span></strong>
|
<strong>
|
||||||
|
聆界平台充值认证 <span>1个工作日完成</span>
|
||||||
|
</strong>
|
||||||
<p>使用企业对公账户向聆界平台进行验证充值小于1元</p>
|
<p>使用企业对公账户向聆界平台进行验证充值小于1元</p>
|
||||||
<p>认证成功后,验证金将自动打入企业的聆界平台账户中,可随时使用</p>
|
<p>认证成功后,验证金将自动打入企业的聆界平台账户中,可随时使用</p>
|
||||||
<p>若打款错误或失败,验证金将自动退回</p>
|
<p>若打款错误或失败,验证金将自动退回</p>
|
||||||
@@ -355,7 +435,9 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="enterprise-actions">
|
<div className="enterprise-actions">
|
||||||
<Button onClick={() => setStep('profile')} variant="secondary">返回修改企业信息</Button>
|
<Button onClick={() => setStep('profile')} variant="secondary">
|
||||||
|
返回修改企业信息
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setStep(method === 'face' ? 'face' : 'recharge')}>下一步</Button>
|
<Button onClick={() => setStep(method === 'face' ? 'face' : 'recharge')}>下一步</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,36 +445,68 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
|
|
||||||
{step === 'recharge' ? (
|
{step === 'recharge' ? (
|
||||||
<div className="enterprise-verify-panel">
|
<div className="enterprise-verify-panel">
|
||||||
<p><span>认证方式</span><strong>聆界平台充值认证</strong></p>
|
<p>
|
||||||
|
<span>认证方式</span>
|
||||||
|
<strong>聆界平台充值认证</strong>
|
||||||
|
</p>
|
||||||
<p className="enterprise-verify-copy">
|
<p className="enterprise-verify-copy">
|
||||||
请使用 <em>企业银行对公账户</em> 向聆界平台账户打款 <em>(小于1元)</em> 至迅联天下平台账户,认证成功后,验证金将自动打入企业的平台账户中。
|
请使用 <em>企业银行对公账户</em> 向聆界平台账户打款 <em>(小于1元)</em>{' '}
|
||||||
|
至迅联天下平台账户,认证成功后,验证金将自动打入企业的平台账户中。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>付款方信息</h2>
|
<h2>付款方信息</h2>
|
||||||
<p><span>付款企业</span><strong>{form.companyName || '待填写企业名称'}</strong></p>
|
<p>
|
||||||
<small>请使用与企业营业执照名称一致的对公账户进行转账,以便系统进行识别,若填报错误企业信息,转账将无效暨审核不通过</small>
|
<span>付款企业</span>
|
||||||
|
<strong>{form.companyName || '待填写企业名称'}</strong>
|
||||||
|
</p>
|
||||||
|
<small>
|
||||||
|
请使用与企业营业执照名称一致的对公账户进行转账,以便系统进行识别,若填报错误企业信息,转账将无效暨审核不通过
|
||||||
|
</small>
|
||||||
|
|
||||||
<div className="enterprise-info-alert">
|
<div className="enterprise-info-alert">
|
||||||
<AlertCircle size={20} />
|
<AlertCircle size={20} />
|
||||||
<span>为避免充值失败的情况,请在点击 <strong>确认并充值</strong> 后,根据页面提示的金额进行转账,验证、充值金额有效期:<em>安全充值有效期为7个工作日</em>,验证次数为2次。</span>
|
<span>
|
||||||
|
为避免充值失败的情况,请在点击 <strong>确认并充值</strong>{' '}
|
||||||
|
后,根据页面提示的金额进行转账,验证、充值金额有效期:<em>安全充值有效期为7个工作日</em>
|
||||||
|
,验证次数为2次。
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="enterprise-actions enterprise-actions--center">
|
<div className="enterprise-actions enterprise-actions--center">
|
||||||
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '确认并充值'}</Button>
|
<Button disabled={submitting} onClick={submitCertification}>
|
||||||
<Button onClick={() => setStep('method')} variant="secondary">返回选择认证方式</Button>
|
{submitting ? '提交中...' : '确认并充值'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setStep('method')} variant="secondary">
|
||||||
|
返回选择认证方式
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 'face' ? (
|
{step === 'face' ? (
|
||||||
<div className="enterprise-face-panel">
|
<div className="enterprise-face-panel">
|
||||||
<p><span>认证方式</span><strong>企业法人人脸识别认证</strong></p>
|
<p>
|
||||||
|
<span>认证方式</span>
|
||||||
|
<strong>企业法人人脸识别认证</strong>
|
||||||
|
</p>
|
||||||
<h2>企业法人基本信息</h2>
|
<h2>企业法人基本信息</h2>
|
||||||
<Input label="* 企业法人姓名" onChange={(event) => updateForm('legalPerson', event.target.value)} placeholder="请填写企业法人姓名" value={form.legalPerson} />
|
<Input
|
||||||
<Input label="* 企业法人身份证号" onChange={(event) => updateForm('legalPersonIdCard', event.target.value)} placeholder="请填写企业法人身份证号" value={form.legalPersonIdCard} />
|
label="* 企业法人姓名"
|
||||||
|
onChange={(event) => updateForm('legalPerson', event.target.value)}
|
||||||
|
placeholder="请填写企业法人姓名"
|
||||||
|
value={form.legalPerson}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 企业法人身份证号"
|
||||||
|
onChange={(event) => updateForm('legalPersonIdCard', event.target.value)}
|
||||||
|
placeholder="请填写企业法人身份证号"
|
||||||
|
value={form.legalPersonIdCard}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="enterprise-actions">
|
<div className="enterprise-actions">
|
||||||
<Button onClick={() => setStep('method')} variant="secondary">返回选择认证方式</Button>
|
<Button onClick={() => setStep('method')} variant="secondary">
|
||||||
|
返回选择认证方式
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setStep('faceScan')}>完成填写</Button>
|
<Button onClick={() => setStep('faceScan')}>完成填写</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,21 +514,36 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
|
|
||||||
{step === 'faceScan' ? (
|
{step === 'faceScan' ? (
|
||||||
<div className="enterprise-face-panel">
|
<div className="enterprise-face-panel">
|
||||||
<p><span>认证方式</span><strong>企业法人人脸识别认证</strong></p>
|
<p>
|
||||||
|
<span>认证方式</span>
|
||||||
|
<strong>企业法人人脸识别认证</strong>
|
||||||
|
</p>
|
||||||
<h2>企业法人基本信息</h2>
|
<h2>企业法人基本信息</h2>
|
||||||
<dl className="enterprise-legal-summary">
|
<dl className="enterprise-legal-summary">
|
||||||
<div><dt>企业法人姓名</dt><dd>{form.legalPerson || '-'}</dd></div>
|
<div>
|
||||||
<div><dt>企业法人身份证号</dt><dd>{form.legalPersonIdCard || '-'}</dd></div>
|
<dt>企业法人姓名</dt>
|
||||||
|
<dd>{form.legalPerson || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>企业法人身份证号</dt>
|
||||||
|
<dd>{form.legalPersonIdCard || '-'}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="enterprise-qr-section">
|
<div className="enterprise-qr-section">
|
||||||
<h2>扫码认证</h2>
|
<h2>扫码认证</h2>
|
||||||
<p>为了验证您的身份,请使用支付宝扫码进行人脸识别,剩余有效时间:<em>59分57秒</em></p>
|
<p>
|
||||||
|
为了验证您的身份,请使用支付宝扫码进行人脸识别,剩余有效时间:<em>59分57秒</em>
|
||||||
|
</p>
|
||||||
<div className="enterprise-qr">二维码</div>
|
<div className="enterprise-qr">二维码</div>
|
||||||
<span>完成扫描操作后,资料将提交至运营端审核。</span>
|
<span>完成扫描操作后,资料将提交至运营端审核。</span>
|
||||||
<div className="enterprise-actions enterprise-actions--center">
|
<div className="enterprise-actions enterprise-actions--center">
|
||||||
<Button onClick={() => setStep('face')} variant="secondary">重新填写法人信息</Button>
|
<Button onClick={() => setStep('face')} variant="secondary">
|
||||||
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '提交审核'}</Button>
|
重新填写法人信息
|
||||||
|
</Button>
|
||||||
|
<Button disabled={submitting} onClick={submitCertification}>
|
||||||
|
{submitting ? '提交中...' : '提交审核'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,33 +551,70 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
|
|
||||||
{step === 'pending' ? (
|
{step === 'pending' ? (
|
||||||
<div className="enterprise-result enterprise-result--pending">
|
<div className="enterprise-result enterprise-result--pending">
|
||||||
<span><ShieldCheck size={70} /></span>
|
<span>
|
||||||
|
<ShieldCheck size={70} />
|
||||||
|
</span>
|
||||||
<h2>认证资料已提交,等待运营审核</h2>
|
<h2>认证资料已提交,等待运营审核</h2>
|
||||||
<p>当前状态:运营端审核中。审核通过后将自动解锁短信发送、签名报备等能力;未通过时可根据驳回原因重新提交。</p>
|
<p>
|
||||||
|
当前状态:运营端审核中。审核通过后将自动解锁短信发送、签名报备等能力;未通过时可根据驳回原因重新提交。
|
||||||
|
</p>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>企业名称:</dt><dd>{displayCompany}</dd></div>
|
<div>
|
||||||
<div><dt>提交时间:</dt><dd>{latestCertification?.submittedAt ?? '-'}</dd></div>
|
<dt>企业名称:</dt>
|
||||||
<div><dt>审核时效:</dt><dd>1 个工作日内</dd></div>
|
<dd>{displayCompany}</dd>
|
||||||
<div><dt>当前节点:</dt><dd>运营端资料审核</dd></div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>提交时间:</dt>
|
||||||
|
<dd>{latestCertification?.submittedAt ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>审核时效:</dt>
|
||||||
|
<dd>1 个工作日内</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>当前节点:</dt>
|
||||||
|
<dd>运营端资料审核</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="enterprise-actions enterprise-actions--center">
|
<div className="enterprise-actions enterprise-actions--center">
|
||||||
<Button onClick={() => setStep('overview')} variant="secondary">返回概览</Button>
|
<Button onClick={() => setStep('overview')} variant="secondary">
|
||||||
|
返回概览
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 'success' ? (
|
{step === 'success' ? (
|
||||||
<div className="enterprise-result enterprise-result--success">
|
<div className="enterprise-result enterprise-result--success">
|
||||||
<span><Check size={70} /></span>
|
<span>
|
||||||
|
<Check size={70} />
|
||||||
|
</span>
|
||||||
<h2>认证审核通过</h2>
|
<h2>认证审核通过</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>企业名称:</dt><dd>{displayCompany}</dd></div>
|
<div>
|
||||||
<div><dt>统一社会信用代码:</dt><dd>{displayLicenseNo}</dd></div>
|
<dt>企业名称:</dt>
|
||||||
<div><dt>法定代表人:</dt><dd>{displayLegalPerson}</dd></div>
|
<dd>{displayCompany}</dd>
|
||||||
<div><dt>认证时间:</dt><dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '-'}</dd></div>
|
</div>
|
||||||
<div><dt>通讯地址:</dt><dd>{displayAddress}</dd></div>
|
<div>
|
||||||
|
<dt>统一社会信用代码:</dt>
|
||||||
|
<dd>{displayLicenseNo}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>法定代表人:</dt>
|
||||||
|
<dd>{displayLegalPerson}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>认证时间:</dt>
|
||||||
|
<dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>通讯地址:</dt>
|
||||||
|
<dd>{displayAddress}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<Button onClick={() => setStep('overview')} variant="secondary">返回概览</Button>
|
<Button onClick={() => setStep('overview')} variant="secondary">
|
||||||
|
返回概览
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -457,10 +623,14 @@ export function ClientEnterpriseAuthPage() {
|
|||||||
<span>!</span>
|
<span>!</span>
|
||||||
<h2>认证审核未通过</h2>
|
<h2>认证审核未通过</h2>
|
||||||
<p>驳回原因:{latestCertification?.rejectReason ?? '请根据运营端审核意见修改资料后重新提交。'}</p>
|
<p>驳回原因:{latestCertification?.rejectReason ?? '请根据运营端审核意见修改资料后重新提交。'}</p>
|
||||||
<button type="button" onClick={() => setStep('profile')}>重新提交资料 <ChevronRight size={18} /></button>
|
<button type="button" onClick={() => setStep('profile')}>
|
||||||
|
重新提交资料 <ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
<div className="enterprise-actions enterprise-actions--center">
|
<div className="enterprise-actions enterprise-actions--center">
|
||||||
<Button onClick={() => setStep('profile')}>重新提交</Button>
|
<Button onClick={() => setStep('profile')}>重新提交</Button>
|
||||||
<Button onClick={() => setStep('overview')} variant="secondary">关闭页面</Button>
|
<Button onClick={() => setStep('overview')} variant="secondary">
|
||||||
|
关闭页面
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.http-signature-page .http-signature-page__form,
|
||||||
|
.http-signature-page .http-signature-page__result {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.http-signature-page .http-signature-page__fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.http-signature-page .http-signature-page__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.http-signature-page .ui-textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.http-signature-page .ui-button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.http-signature-page .http-signature-page__fields {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { HttpSignaturePage } from './HttpSignaturePage';
|
||||||
|
|
||||||
|
describe('HTTP signature page', () => {
|
||||||
|
it('invalidates results after editing and clears the secret', () => {
|
||||||
|
render(<HttpSignaturePage />);
|
||||||
|
fireEvent.change(screen.getByLabelText('AccessSecret'), { target: { value: 'test-secret' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/原始请求正文/), { target: { value: '{"mobile":"13800138000"}' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '生成签名' }));
|
||||||
|
expect((screen.getByLabelText('X-Signature') as HTMLTextAreaElement).value).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
fireEvent.change(screen.getByLabelText('时间戳(秒)'), { target: { value: '1789344000' } });
|
||||||
|
expect(screen.queryByLabelText('X-Signature')).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||||
|
expect(screen.getByLabelText('AccessSecret')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
|
||||||
|
import { calculateSignature, createNonce, type SignatureInput } from './signature';
|
||||||
|
import './HttpSignaturePage.css';
|
||||||
|
|
||||||
|
function emptyInput(): SignatureInput {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/openapi/v1/sms/messages',
|
||||||
|
timestamp: String(Math.floor(Date.now() / 1000)),
|
||||||
|
nonce: createNonce(),
|
||||||
|
secret: '',
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HttpSignaturePage() {
|
||||||
|
const [input, setInput] = useState(emptyInput);
|
||||||
|
const [result, setResult] = useState<ReturnType<typeof calculateSignature> | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const signatureRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
function change<K extends keyof SignatureInput>(key: K, value: SignatureInput[K]) {
|
||||||
|
setInput((current) => ({ ...current, [key]: value }));
|
||||||
|
setResult(null);
|
||||||
|
setError('');
|
||||||
|
setNotice('');
|
||||||
|
}
|
||||||
|
function generate() {
|
||||||
|
setNotice('');
|
||||||
|
try {
|
||||||
|
setResult(calculateSignature(input));
|
||||||
|
setError('');
|
||||||
|
} catch (failure) {
|
||||||
|
setResult(null);
|
||||||
|
setError(failure instanceof Error ? failure.message : '签名计算失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function copy() {
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard) throw new Error('Clipboard unavailable');
|
||||||
|
await navigator.clipboard.writeText(result.signature);
|
||||||
|
setNotice('已复制签名');
|
||||||
|
} catch {
|
||||||
|
signatureRef.current?.focus();
|
||||||
|
signatureRef.current?.select();
|
||||||
|
setNotice('已选中签名,请按 Ctrl+C 或长按复制');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="page-stack http-signature-page">
|
||||||
|
<Breadcrumb items={['接口工具', 'HTTP签名计算']} />
|
||||||
|
<p className="muted">仅在当前浏览器计算,不上传或保存密钥。生成结果不会发送请求。</p>
|
||||||
|
<form
|
||||||
|
className="surface http-signature-page__form"
|
||||||
|
autoComplete="off"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
generate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="http-signature-page__fields">
|
||||||
|
<Select
|
||||||
|
label="请求方法"
|
||||||
|
value={input.method}
|
||||||
|
options={[
|
||||||
|
{ label: 'POST', value: 'POST' },
|
||||||
|
{ label: 'GET', value: 'GET' },
|
||||||
|
]}
|
||||||
|
onChange={(event) => {
|
||||||
|
change('method', event.target.value as 'GET' | 'POST');
|
||||||
|
if (event.target.value === 'GET') change('body', '');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="请求路径"
|
||||||
|
value={input.path}
|
||||||
|
onChange={(event) => change('path', event.target.value)}
|
||||||
|
hint="不含域名和 ? 后的查询参数"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="时间戳(秒)"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={input.timestamp}
|
||||||
|
onChange={(event) => change('timestamp', event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input label="nonce" value={input.nonce} onChange={(event) => change('nonce', event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
change('timestamp', String(Math.floor(Date.now() / 1000)));
|
||||||
|
change('nonce', createNonce());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
更新时间与nonce
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
label="AccessSecret"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={input.secret}
|
||||||
|
onChange={(event) => change('secret', event.target.value)}
|
||||||
|
/>
|
||||||
|
{input.method === 'POST' ? (
|
||||||
|
<Textarea
|
||||||
|
label="原始请求正文"
|
||||||
|
rows={8}
|
||||||
|
spellCheck={false}
|
||||||
|
value={input.body}
|
||||||
|
onChange={(event) => change('body', event.target.value)}
|
||||||
|
hint="粘贴实际发送的 JSON 文本。空格和换行参与签名,不会自动格式化。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="muted">GET 不包含正文,签名原文末尾没有换行符。</p>
|
||||||
|
)}
|
||||||
|
{error ? (
|
||||||
|
<p className="form-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="http-signature-page__actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setInput(emptyInput());
|
||||||
|
setResult(null);
|
||||||
|
setError('');
|
||||||
|
setNotice('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">生成签名</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{result ? (
|
||||||
|
<div className="surface http-signature-page__result">
|
||||||
|
<Textarea label="X-Signature" ref={signatureRef} rows={2} readOnly value={result.signature} />
|
||||||
|
<Button onClick={() => void copy()} variant="secondary">
|
||||||
|
复制签名
|
||||||
|
</Button>
|
||||||
|
<Textarea
|
||||||
|
label="签名原文"
|
||||||
|
rows={8}
|
||||||
|
readOnly
|
||||||
|
value={result.original}
|
||||||
|
hint={`UTF-8 共 ${result.byteLength} 字节;字段之间的换行为 LF(0x0A)`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p role="status">{notice}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { calculateSignature, type SignatureInput } from './signature';
|
||||||
|
|
||||||
|
const base: SignatureInput = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/openapi/v1/sms/uplinks',
|
||||||
|
timestamp: '1789344000',
|
||||||
|
nonce: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
secret: 'doc-example-secret',
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
describe('HTTP signature calculator', () => {
|
||||||
|
it('matches the published GET vector without trailing LF', () => {
|
||||||
|
const result = calculateSignature(base);
|
||||||
|
expect(result.signature).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
|
||||||
|
expect(result.original.endsWith('\n')).toBe(false);
|
||||||
|
});
|
||||||
|
it.each(['{"content":"中文🙂"}', '{ "content": "中文🙂" }\n', '{\r\n"content":"中文🙂"\r\n}'])(
|
||||||
|
'signs exact POST bytes: %s',
|
||||||
|
(body) => {
|
||||||
|
const input = { ...base, method: 'POST' as const, body };
|
||||||
|
const expected = ['POST', base.path, base.timestamp, base.nonce, body].join('\n');
|
||||||
|
const result = calculateSignature(input);
|
||||||
|
expect(result.signature).toBe(createHmac('sha256', base.secret).update(expected).digest('hex'));
|
||||||
|
expect(result.byteLength).toBe(Buffer.byteLength(expected));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each([
|
||||||
|
{ path: '/api/openapi/v1/sms/uplinks?limit=1' },
|
||||||
|
{ timestamp: '1.5' },
|
||||||
|
{ nonce: 'short' },
|
||||||
|
{ secret: '' },
|
||||||
|
{ body: '{}' },
|
||||||
|
{ method: 'POST', body: '[]' },
|
||||||
|
{ method: 'POST', body: '{' },
|
||||||
|
])('rejects invalid inputs %o', (change) => {
|
||||||
|
expect(() => calculateSignature({ ...base, ...change } as SignatureInput)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { hmac } from '@noble/hashes/hmac.js';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
|
export type SignatureInput = {
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
path: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
secret: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculateSignature(input: SignatureInput) {
|
||||||
|
if (!['GET', 'POST'].includes(input.method)) throw new Error('请选择 GET 或 POST');
|
||||||
|
if (!/^\/api\/openapi\/v1\/sms\/[^?#\s]+$/.test(input.path)) {
|
||||||
|
throw new Error('请输入 /api/openapi/v1/sms/ 开头的请求路径,不含域名、查询参数或空格');
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(input.timestamp) || !Number.isSafeInteger(Number(input.timestamp))) {
|
||||||
|
throw new Error('时间戳必须是整数秒');
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9_-]{8,128}$/.test(input.nonce)) throw new Error('nonce 须为 8–128 位字母、数字、下划线或连字符');
|
||||||
|
if (!input.secret) throw new Error('请输入 AccessSecret');
|
||||||
|
if (input.method === 'GET' && input.body) throw new Error('GET 请求不能包含正文');
|
||||||
|
if (input.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(input.body);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
|
||||||
|
} catch {
|
||||||
|
throw new Error('POST 正文必须是 JSON 对象;计算时保留输入的原始文本');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const original =
|
||||||
|
[input.method, input.path, input.timestamp, input.nonce].join('\n') +
|
||||||
|
(input.method === 'POST' ? `\n${input.body}` : '');
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(original);
|
||||||
|
return {
|
||||||
|
original,
|
||||||
|
byteLength: bytes.length,
|
||||||
|
signature: bytesToHex(hmac(sha256, encoder.encode(input.secret), bytes)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNonce() {
|
||||||
|
return bytesToHex(crypto.getRandomValues(new Uint8Array(16)));
|
||||||
|
}
|
||||||
@@ -229,6 +229,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
{ label: '企业应用管理', to: '/admin/enterprise-applications', icon: Layers3 },
|
{ label: '企业应用管理', to: '/admin/enterprise-applications', icon: Layers3 },
|
||||||
{ label: '企业签名管理', to: '/admin/enterprise-signatures', icon: FilePenLine },
|
{ label: '企业签名管理', to: '/admin/enterprise-signatures', icon: FilePenLine },
|
||||||
{ label: '企业模板管理', to: '/admin/enterprise-templates', icon: FileCheck2 },
|
{ label: '企业模板管理', to: '/admin/enterprise-templates', icon: FileCheck2 },
|
||||||
|
{ label: 'HTTP签名计算', to: '/admin/http-signature', icon: Hash },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
|
|||||||
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
||||||
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
||||||
{ label: '接口文档', to: '/client/http-docs', icon: FileText },
|
{ label: '接口文档', to: '/client/http-docs', icon: FileText },
|
||||||
|
{ label: 'HTTP签名计算', to: '/client/http-signature', icon: Cable },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ const ClientEnterpriseAuthPage = lazyNamed(
|
|||||||
'ClientEnterpriseAuthPage',
|
'ClientEnterpriseAuthPage',
|
||||||
);
|
);
|
||||||
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
|
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
|
||||||
|
const HttpSignaturePage = lazyNamed(
|
||||||
|
() => import('@/apps/shared/http-signature/HttpSignaturePage'),
|
||||||
|
'HttpSignaturePage',
|
||||||
|
);
|
||||||
const ClientHttpDocsPage = lazyNamed(() => import('@/apps/client/http-docs/ClientHttpDocsPage'), 'ClientHttpDocsPage');
|
const ClientHttpDocsPage = lazyNamed(() => import('@/apps/client/http-docs/ClientHttpDocsPage'), 'ClientHttpDocsPage');
|
||||||
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
|
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
|
||||||
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
|
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
|
||||||
@@ -187,6 +191,7 @@ export function AppRoutes() {
|
|||||||
<Route path="applications" element={<ClientApplicationsPage />} />
|
<Route path="applications" element={<ClientApplicationsPage />} />
|
||||||
<Route path="http-api" element={<ClientHttpApiPage />} />
|
<Route path="http-api" element={<ClientHttpApiPage />} />
|
||||||
<Route path="http-docs" element={<ClientHttpDocsPage />} />
|
<Route path="http-docs" element={<ClientHttpDocsPage />} />
|
||||||
|
<Route path="http-signature" element={<HttpSignaturePage />} />
|
||||||
<Route path="templates" element={<ClientTemplatesPage />} />
|
<Route path="templates" element={<ClientTemplatesPage />} />
|
||||||
<Route path="signatures" element={<ClientSignaturesPage />} />
|
<Route path="signatures" element={<ClientSignaturesPage />} />
|
||||||
<Route path="mms-signatures" element={<PagePlaceholder />} />
|
<Route path="mms-signatures" element={<PagePlaceholder />} />
|
||||||
@@ -203,6 +208,7 @@ export function AppRoutes() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route index element={<AdminHome />} />
|
<Route index element={<AdminHome />} />
|
||||||
|
<Route path="http-signature" element={<HttpSignaturePage />} />
|
||||||
<Route path="monitor" element={<AdminMonitorPage />} />
|
<Route path="monitor" element={<AdminMonitorPage />} />
|
||||||
<Route path="gateway-submit-exceptions" element={<AdminGatewaySubmitExceptionsPage />} />
|
<Route path="gateway-submit-exceptions" element={<AdminGatewaySubmitExceptionsPage />} />
|
||||||
<Route path="analytics" element={<AdminAnalyticsPage />} />
|
<Route path="analytics" element={<AdminAnalyticsPage />} />
|
||||||
|
|||||||
@@ -299,6 +299,11 @@
|
|||||||
"owners": ["src/apps/client/ClientTemplatesPage.tsx"],
|
"owners": ["src/apps/client/ClientTemplatesPage.tsx"],
|
||||||
"stylelintLegacy": false,
|
"stylelintLegacy": false,
|
||||||
"roots": ["client-templates-page"]
|
"roots": ["client-templates-page"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/shared/http-signature/HttpSignaturePage.css",
|
||||||
|
"owners": ["src/apps/shared/http-signature/HttpSignaturePage.tsx"],
|
||||||
|
"roots": ["http-signature-page"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user