fix: harden admin and CMPP delivery workflows
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "CmppDownstreamDelivery"
|
||||||
|
ADD COLUMN "manualRetryCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "lastRetriedAt" TIMESTAMP(3);
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
-- Downstream connection rows represent current live sessions only.
|
||||||
|
-- Remove historical disconnected/timeout rows left by the previous persistence model.
|
||||||
|
DELETE FROM "CmppDownstreamConnection"
|
||||||
|
WHERE "status" <> 'connected';
|
||||||
@@ -1197,6 +1197,8 @@ model CmppDownstreamDelivery {
|
|||||||
payload Json
|
payload Json
|
||||||
retryEnabled Boolean @default(true)
|
retryEnabled Boolean @default(true)
|
||||||
retryCount Int @default(0)
|
retryCount Int @default(0)
|
||||||
|
manualRetryCount Int @default(0)
|
||||||
|
lastRetriedAt DateTime?
|
||||||
nextRetryAt DateTime?
|
nextRetryAt DateTime?
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
acknowledgedAt DateTime?
|
acknowledgedAt DateTime?
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createPhoneSegment(body);
|
return this.dictionaries.createPhoneSegment(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('phone-segments/:id')
|
||||||
|
deletePhoneSegment(@Param('id') id: string) {
|
||||||
|
return this.dictionaries.deletePhoneSegment(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('phone-carrier-rules')
|
@Get('phone-carrier-rules')
|
||||||
listPhoneCarrierRules(@Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listPhoneCarrierRules(@Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||||
return this.dictionaries.listPhoneCarrierRules({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined });
|
return this.dictionaries.listPhoneCarrierRules({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined });
|
||||||
@@ -108,4 +113,9 @@ export class DictionariesController {
|
|||||||
createDrainageField(@Body() body: CreateDrainageFieldDto) {
|
createDrainageField(@Body() body: CreateDrainageFieldDto) {
|
||||||
return this.dictionaries.createDrainageField(body);
|
return this.dictionaries.createDrainageField(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('drainage-fields/:id')
|
||||||
|
deleteDrainageField(@Param('id') id: string) {
|
||||||
|
return this.dictionaries.deleteDrainageField(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ function createPrismaMock() {
|
|||||||
phoneSegment: {
|
phoneSegment: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
count: jest.fn().mockResolvedValue(3),
|
count: jest.fn().mockResolvedValue(3),
|
||||||
|
delete: jest.fn().mockResolvedValue({ id: 'segment-1' }),
|
||||||
},
|
},
|
||||||
phoneCarrierRule: {
|
phoneCarrierRule: {
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
@@ -26,7 +27,12 @@ function createPrismaMock() {
|
|||||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||||
},
|
},
|
||||||
drainageField: {
|
drainageField: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
|
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||||
|
},
|
||||||
|
channelReportField: {
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
smsApplication: {
|
smsApplication: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||||
@@ -38,6 +44,25 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DictionariesService', () => {
|
describe('DictionariesService', () => {
|
||||||
|
it('deletes a phone segment from the real dictionary table', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await service.deletePhoneSegment('segment-1');
|
||||||
|
|
||||||
|
expect(prisma.phoneSegment.delete).toHaveBeenCalledWith({ where: { id: 'segment-1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns drainage field usage counts and blocks deleting fields used by channels', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2 } }]);
|
||||||
|
prisma.channelReportField.count.mockResolvedValue(2);
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2 }]);
|
||||||
|
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除');
|
||||||
|
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
it('paginates phone segments with a real database count', async () => {
|
it('paginates phone segments with a real database count', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ export class DictionariesService {
|
|||||||
return this.prisma.phoneSegment.create({ data });
|
return this.prisma.phoneSegment.create({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deletePhoneSegment(id: string) {
|
||||||
|
return this.prisma.phoneSegment.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
async listPhoneCarrierRules(query: PageQuery = {}) {
|
async listPhoneCarrierRules(query: PageQuery = {}) {
|
||||||
const page = Math.max(1, Number(query.page ?? 1));
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
||||||
@@ -258,8 +262,12 @@ export class DictionariesService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
listDrainageFields() {
|
async listDrainageFields() {
|
||||||
return this.prisma.drainageField.findMany({ orderBy: { createdAt: 'desc' } });
|
const fields = await this.prisma.drainageField.findMany({
|
||||||
|
include: { _count: { select: { channelReportFields: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return fields.map(({ _count, ...field }) => ({ ...field, usageCount: _count.channelReportFields }));
|
||||||
}
|
}
|
||||||
|
|
||||||
createDrainageField(data: CreateDrainageFieldDto) {
|
createDrainageField(data: CreateDrainageFieldDto) {
|
||||||
@@ -282,6 +290,14 @@ export class DictionariesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteDrainageField(id: string) {
|
||||||
|
const usageCount = await this.prisma.channelReportField.count({ where: { drainageFieldId: id } });
|
||||||
|
if (usageCount > 0) {
|
||||||
|
throw new BadRequestException(`该字段已被 ${usageCount} 个通道使用,不能删除`);
|
||||||
|
}
|
||||||
|
return this.prisma.drainageField.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||||
return this.prisma.operationLog.create({
|
return this.prisma.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export class AdminOperationsController {
|
|||||||
@Query('tenantId') tenantId?: string,
|
@Query('tenantId') tenantId?: string,
|
||||||
@Query('applicationId') applicationId?: string,
|
@Query('applicationId') applicationId?: string,
|
||||||
@Query('deliveryType') deliveryType?: string,
|
@Query('deliveryType') deliveryType?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@@ -163,6 +165,8 @@ export class AdminOperationsController {
|
|||||||
tenantId,
|
tenantId,
|
||||||
applicationId,
|
applicationId,
|
||||||
deliveryType,
|
deliveryType,
|
||||||
|
createdAtFrom,
|
||||||
|
createdAtTo,
|
||||||
status,
|
status,
|
||||||
keyword,
|
keyword,
|
||||||
page: Number(page),
|
page: Number(page),
|
||||||
@@ -175,11 +179,15 @@ export class AdminOperationsController {
|
|||||||
@Query('tenantId') tenantId?: string,
|
@Query('tenantId') tenantId?: string,
|
||||||
@Query('applicationId') applicationId?: string,
|
@Query('applicationId') applicationId?: string,
|
||||||
@Query('deliveryType') deliveryType?: string,
|
@Query('deliveryType') deliveryType?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
) {
|
) {
|
||||||
return this.operations.downstreamDeliveryDashboard({
|
return this.operations.downstreamDeliveryDashboard({
|
||||||
tenantId,
|
tenantId,
|
||||||
applicationId,
|
applicationId,
|
||||||
deliveryType,
|
deliveryType,
|
||||||
|
createdAtFrom,
|
||||||
|
createdAtTo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ describe('OperationsService', () => {
|
|||||||
.mockResolvedValueOnce(2)
|
.mockResolvedValueOnce(2)
|
||||||
.mockResolvedValueOnce(8)
|
.mockResolvedValueOnce(8)
|
||||||
.mockResolvedValueOnce(1)
|
.mockResolvedValueOnce(1)
|
||||||
|
.mockResolvedValueOnce(1)
|
||||||
.mockResolvedValueOnce(2);
|
.mockResolvedValueOnce(2);
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
@@ -274,11 +275,25 @@ describe('OperationsService', () => {
|
|||||||
failed: 2,
|
failed: 2,
|
||||||
delivered: 8,
|
delivered: 8,
|
||||||
stalledPending: 1,
|
stalledPending: 1,
|
||||||
|
stalledAck: 1,
|
||||||
recentFailed: 2,
|
recentFailed: 2,
|
||||||
alertCount: 3,
|
alertCount: 4,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(4, {
|
||||||
|
where: { tenantId: 'tenant-1', status: 'pending', createdAt: { lte: expect.any(Date) } },
|
||||||
|
});
|
||||||
|
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(5, {
|
||||||
|
where: { tenantId: 'tenant-1', status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } },
|
||||||
|
});
|
||||||
|
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(6, {
|
||||||
|
where: {
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||||
|
updatedAt: { gte: expect.any(Date) },
|
||||||
|
},
|
||||||
|
});
|
||||||
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
||||||
|
|
||||||
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith({
|
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith({
|
||||||
@@ -399,6 +414,8 @@ describe('OperationsService', () => {
|
|||||||
deliveryType: 'receipt',
|
deliveryType: 'receipt',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
keyword: '1380',
|
keyword: '1380',
|
||||||
|
createdAtFrom: '2026-07-01',
|
||||||
|
createdAtTo: '2026-07-15',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
})).resolves.toEqual({
|
})).resolves.toEqual({
|
||||||
@@ -413,6 +430,10 @@ describe('OperationsService', () => {
|
|||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
deliveryType: 'receipt',
|
deliveryType: 'receipt',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date('2026-07-01T00:00:00.000+08:00'),
|
||||||
|
lte: new Date('2026-07-15T23:59:59.999+08:00'),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
include: { tenant: true, application: true, messageRecord: true },
|
include: { tenant: true, application: true, messageRecord: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -451,6 +472,10 @@ describe('OperationsService', () => {
|
|||||||
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } },
|
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } },
|
||||||
{ applicationId: 'app-2', status: 'pending', _count: { _all: 1 } },
|
{ applicationId: 'app-2', status: 'pending', _count: { _all: 1 } },
|
||||||
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } },
|
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ applicationId: 'app-1', _count: { _all: 1 } },
|
||||||
|
{ applicationId: 'app-2', _count: { _all: 1 } },
|
||||||
]);
|
]);
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
@@ -482,10 +507,27 @@ describe('OperationsService', () => {
|
|||||||
{ label: '4次及以上', count: 0 },
|
{ label: '4次及以上', count: 0 },
|
||||||
],
|
],
|
||||||
topApplications: [
|
topApplications: [
|
||||||
{ applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 3 },
|
{ applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 1 },
|
||||||
{ applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 },
|
{ applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(prisma.cmppDownstreamDelivery.groupBy).toHaveBeenNthCalledWith(3, {
|
||||||
|
by: ['applicationId'],
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ tenantId: 'tenant-1', applicationId: 'app-1', deliveryType: undefined },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ status: 'pending', createdAt: { lte: expect.any(Date) } },
|
||||||
|
{ status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } },
|
||||||
|
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: expect.any(Date) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns paginated downstream recovery statuses', async () => {
|
it('returns paginated downstream recovery statuses', async () => {
|
||||||
|
|||||||
@@ -49,12 +49,16 @@ export interface DownstreamDeliveryQuery {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownstreamDeliveryDashboardQuery {
|
export interface DownstreamDeliveryDashboardQuery {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
applicationId?: string;
|
applicationId?: string;
|
||||||
deliveryType?: string;
|
deliveryType?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownstreamRecoveryStatusQuery {
|
export interface DownstreamRecoveryStatusQuery {
|
||||||
@@ -140,6 +144,7 @@ export class OperationsService {
|
|||||||
|
|
||||||
async dashboard(query: { tenantId?: string }) {
|
async dashboard(query: { tenantId?: string }) {
|
||||||
const sinceToday = startOfToday();
|
const sinceToday = startOfToday();
|
||||||
|
const downstreamAlertWindow = downstreamAlertWindows();
|
||||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||||
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
||||||
const [
|
const [
|
||||||
@@ -158,6 +163,7 @@ export class OperationsService {
|
|||||||
downstreamFailedCount,
|
downstreamFailedCount,
|
||||||
downstreamDeliveredCount,
|
downstreamDeliveredCount,
|
||||||
downstreamStalledPendingCount,
|
downstreamStalledPendingCount,
|
||||||
|
downstreamStalledAckCount,
|
||||||
downstreamRecentFailedCount,
|
downstreamRecentFailedCount,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||||
@@ -225,19 +231,26 @@ export class OperationsService {
|
|||||||
where: {
|
where: {
|
||||||
tenantId: query.tenantId,
|
tenantId: query.tenantId,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: { lte: new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000) },
|
createdAt: { lte: downstreamAlertWindow.stalledPendingAt },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.cmppDownstreamDelivery.count({
|
this.prisma.cmppDownstreamDelivery.count({
|
||||||
where: {
|
where: {
|
||||||
tenantId: query.tenantId,
|
tenantId: query.tenantId,
|
||||||
status: 'failed',
|
status: 'awaiting_ack',
|
||||||
updatedAt: { gte: new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000) },
|
ackDeadlineAt: { lte: downstreamAlertWindow.now },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.cmppDownstreamDelivery.count({
|
||||||
|
where: {
|
||||||
|
tenantId: query.tenantId,
|
||||||
|
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||||
|
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamRecentFailedCount;
|
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
||||||
return {
|
return {
|
||||||
taskCount,
|
taskCount,
|
||||||
messageStatus: messageGroups,
|
messageStatus: messageGroups,
|
||||||
@@ -261,6 +274,7 @@ export class OperationsService {
|
|||||||
failed: downstreamFailedCount,
|
failed: downstreamFailedCount,
|
||||||
delivered: downstreamDeliveredCount,
|
delivered: downstreamDeliveredCount,
|
||||||
stalledPending: downstreamStalledPendingCount,
|
stalledPending: downstreamStalledPendingCount,
|
||||||
|
stalledAck: downstreamStalledAckCount,
|
||||||
recentFailed: downstreamRecentFailedCount,
|
recentFailed: downstreamRecentFailedCount,
|
||||||
alertCount: downstreamAlertCount,
|
alertCount: downstreamAlertCount,
|
||||||
},
|
},
|
||||||
@@ -413,9 +427,8 @@ export class OperationsService {
|
|||||||
|
|
||||||
async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) {
|
async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) {
|
||||||
const scopedWhere = downstreamDeliveryScopedWhere(query);
|
const scopedWhere = downstreamDeliveryScopedWhere(query);
|
||||||
const stalledPendingAt = new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000);
|
const downstreamAlertWindow = downstreamAlertWindows();
|
||||||
const recentFailedAt = new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000);
|
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||||
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
|
||||||
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
|
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
|
||||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
|
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
|
||||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }),
|
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }),
|
||||||
@@ -427,17 +440,17 @@ export class OperationsService {
|
|||||||
where: {
|
where: {
|
||||||
...scopedWhere,
|
...scopedWhere,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: { lte: stalledPendingAt },
|
createdAt: { lte: downstreamAlertWindow.stalledPendingAt },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.cmppDownstreamDelivery.count({
|
this.prisma.cmppDownstreamDelivery.count({
|
||||||
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } },
|
||||||
}),
|
}),
|
||||||
this.prisma.cmppDownstreamDelivery.count({
|
this.prisma.cmppDownstreamDelivery.count({
|
||||||
where: {
|
where: {
|
||||||
...scopedWhere,
|
...scopedWhere,
|
||||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||||
updatedAt: { gte: recentFailedAt },
|
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||||
@@ -450,6 +463,11 @@ export class OperationsService {
|
|||||||
where: scopedWhere,
|
where: scopedWhere,
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
|
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||||
|
by: ['applicationId'],
|
||||||
|
where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow),
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
this.prisma.cmppDownstreamDelivery.count({
|
this.prisma.cmppDownstreamDelivery.count({
|
||||||
where: {
|
where: {
|
||||||
...scopedWhere,
|
...scopedWhere,
|
||||||
@@ -480,8 +498,11 @@ export class OperationsService {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const applicationMap = new Map<string, string>(applications.map((item) => [item.id, item.name]));
|
const applicationMap = new Map<string, string>(applications.map((item) => [item.id, item.name]));
|
||||||
|
const applicationAlertMap = new Map<string, number>(
|
||||||
|
applicationAlertGroups.map((item) => [item.applicationId, item._count._all]),
|
||||||
|
);
|
||||||
const groupedByType = groupDownstreamByType(typeGroups);
|
const groupedByType = groupDownstreamByType(typeGroups);
|
||||||
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap);
|
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: {
|
summary: {
|
||||||
@@ -844,14 +865,49 @@ function downstreamAlertRecentFailedHours() {
|
|||||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downstreamAlertWindows(now = new Date()) {
|
||||||
|
return {
|
||||||
|
now,
|
||||||
|
stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000),
|
||||||
|
recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downstreamAlertWhere(
|
||||||
|
scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput,
|
||||||
|
window: ReturnType<typeof downstreamAlertWindows>,
|
||||||
|
): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
scopedWhere,
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ status: 'pending', createdAt: { lte: window.stalledPendingAt } },
|
||||||
|
{ status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } },
|
||||||
|
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
|
function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||||
|
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
|
||||||
|
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
|
||||||
return {
|
return {
|
||||||
tenantId: query.tenantId,
|
tenantId: query.tenantId,
|
||||||
applicationId: query.applicationId,
|
applicationId: query.applicationId,
|
||||||
deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined,
|
deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined,
|
||||||
|
createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDateBoundary(value?: string, endOfDay = false) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||||
return {
|
return {
|
||||||
tenantId: query.tenantId,
|
tenantId: query.tenantId,
|
||||||
@@ -943,6 +999,7 @@ function groupDownstreamByType(
|
|||||||
function groupDownstreamByApplication(
|
function groupDownstreamByApplication(
|
||||||
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
|
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
|
||||||
applicationMap: Map<string, string>,
|
applicationMap: Map<string, string>,
|
||||||
|
applicationAlertMap: Map<string, number>,
|
||||||
) {
|
) {
|
||||||
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
|
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
|
||||||
groups.forEach((item) => {
|
groups.forEach((item) => {
|
||||||
@@ -970,7 +1027,7 @@ function groupDownstreamByApplication(
|
|||||||
} else if (item.status === 'delivered') {
|
} else if (item.status === 'delivered') {
|
||||||
current.delivered += item._count._all;
|
current.delivered += item._count._all;
|
||||||
}
|
}
|
||||||
current.alertCount = current.pending + current.failed + current.unconfirmed + current.rejected;
|
current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0;
|
||||||
summaryMap.set(item.applicationId, current);
|
summaryMap.set(item.applicationId, current);
|
||||||
});
|
});
|
||||||
return [...summaryMap.values()];
|
return [...summaryMap.values()];
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ function createPrismaMock() {
|
|||||||
auditStatus: 'approved',
|
auditStatus: 'approved',
|
||||||
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
||||||
}),
|
}),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
|
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
|
||||||
@@ -571,6 +572,90 @@ describe('SendChainService', () => {
|
|||||||
expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled();
|
expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches an inbound CMPP message against configured template variables and passes extracted values to risk review', async () => {
|
||||||
|
const { service, prisma, riskReview } = createService();
|
||||||
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||||
|
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.smsTemplate.findMany.mockResolvedValue([{
|
||||||
|
id: 'tpl-code',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
content: '【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
signature: { id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting' },
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await expect(service.submitInboundMessage({
|
||||||
|
account: '100001',
|
||||||
|
phoneNumber: '18821203795',
|
||||||
|
content: '【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。',
|
||||||
|
remoteIp: '127.0.0.1',
|
||||||
|
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
|
||||||
|
|
||||||
|
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { applicationId: 'app-1', content: { contains: '${' } },
|
||||||
|
include: { signature: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ templateId: 'tpl-code', status: 'validating' }),
|
||||||
|
});
|
||||||
|
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
templateId: 'tpl-code',
|
||||||
|
variables: { code: '715021' },
|
||||||
|
}));
|
||||||
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||||
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues template-mismatched CMPP content when the application uses direct send', async () => {
|
||||||
|
const { service, prisma, riskReview } = createService();
|
||||||
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||||
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||||
|
id: 'app-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
cmppAccount: '100001',
|
||||||
|
status: 'active',
|
||||||
|
interfaceEnabled: true,
|
||||||
|
templateMismatchMode: 'direct_send',
|
||||||
|
customerUnitPrice: 3,
|
||||||
|
queuePriority: 'normal',
|
||||||
|
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||||
|
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||||
|
});
|
||||||
|
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.smsTemplate.findMany.mockResolvedValue([]);
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
|
id: 'sig-1',
|
||||||
|
name: '【航天信息信诺网】',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
reportStatus: 'reporting',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.submitInboundMessage({
|
||||||
|
account: '100001',
|
||||||
|
phoneNumber: '18821203795',
|
||||||
|
content: '【航天信息信诺网】未配置模板但允许直接发送',
|
||||||
|
remoteIp: '127.0.0.1',
|
||||||
|
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
|
||||||
|
|
||||||
|
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { applicationId: 'app-1', name: '【航天信息信诺网】', auditStatus: 'approved' },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
applicationId: 'app-1',
|
||||||
|
content: '【航天信息信诺网】未配置模板但允许直接发送',
|
||||||
|
}));
|
||||||
|
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.not.objectContaining({ templateId: expect.any(String) }));
|
||||||
|
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'record-1' },
|
||||||
|
data: { status: 'queued', signatureId: 'sig-1' },
|
||||||
|
});
|
||||||
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||||
|
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('aggregates template-mismatched CMPP messages only when the application uses manual review', async () => {
|
it('aggregates template-mismatched CMPP messages only when the application uses manual review', async () => {
|
||||||
const { service, prisma, riskReview } = createService();
|
const { service, prisma, riskReview } = createService();
|
||||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||||
@@ -1602,6 +1687,29 @@ describe('SendChainService', () => {
|
|||||||
|
|
||||||
it('requeues downstream delivery through real gateway control path', async () => {
|
it('requeues downstream delivery through real gateway control path', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'delivery-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
messageId: 'MSG-1',
|
||||||
|
deliveryType: 'receipt',
|
||||||
|
status: 'failed',
|
||||||
|
retryCount: 3,
|
||||||
|
manualRetryCount: 1,
|
||||||
|
lastError: 'downstream client is not connected',
|
||||||
|
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
||||||
|
application: { cmppAccount: '100001' },
|
||||||
|
});
|
||||||
|
prisma.cmppDownstreamDelivery.update.mockResolvedValueOnce({
|
||||||
|
id: 'delivery-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
messageId: 'MSG-1',
|
||||||
|
deliveryType: 'receipt',
|
||||||
|
status: 'pending',
|
||||||
|
retryCount: 0,
|
||||||
|
manualRetryCount: 2,
|
||||||
|
});
|
||||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||||
|
|
||||||
await service.requeueDownstreamDelivery('delivery-1');
|
await service.requeueDownstreamDelivery('delivery-1');
|
||||||
@@ -1624,12 +1732,41 @@ describe('SendChainService', () => {
|
|||||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
retryCount: 0,
|
||||||
|
manualRetryCount: { increment: 1 },
|
||||||
|
lastRetriedAt: expect.any(Date),
|
||||||
acknowledgedAt: null,
|
acknowledgedAt: null,
|
||||||
ackResult: null,
|
ackResult: null,
|
||||||
ackMessageId: null,
|
ackMessageId: null,
|
||||||
deliveredAt: null,
|
deliveredAt: null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: 'gateway.downstream_delivery_requeue',
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
previousStatus: 'failed',
|
||||||
|
previousRetryCount: 3,
|
||||||
|
manualRetryCount: 2,
|
||||||
|
lastRetriedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects manual requeue while downstream acknowledgement is pending', async () => {
|
||||||
|
const { service, prisma } = createService();
|
||||||
|
service['postGatewayControl'] = jest.fn();
|
||||||
|
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'delivery-1',
|
||||||
|
status: 'awaiting_ack',
|
||||||
|
payload: { account: '100001' },
|
||||||
|
application: { cmppAccount: '100001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
|
||||||
|
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
|
||||||
|
expect(service['postGatewayControl']).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports batch requeue of downstream deliveries', async () => {
|
it('supports batch requeue of downstream deliveries', async () => {
|
||||||
|
|||||||
@@ -1263,6 +1263,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!delivery) {
|
if (!delivery) {
|
||||||
throw new NotFoundException('Downstream delivery not found');
|
throw new NotFoundException('Downstream delivery not found');
|
||||||
}
|
}
|
||||||
|
if (delivery.status === 'awaiting_ack') {
|
||||||
|
throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投');
|
||||||
|
}
|
||||||
const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null;
|
const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null;
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new BadRequestException('下游投递记录缺少可重放 payload');
|
throw new BadRequestException('下游投递记录缺少可重放 payload');
|
||||||
@@ -1277,30 +1280,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`);
|
throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.operationLog.create({
|
|
||||||
data: {
|
|
||||||
tenantId: delivery.tenantId,
|
|
||||||
action: 'gateway.downstream_delivery_requeue',
|
|
||||||
resource: 'cmpp_downstream_delivery',
|
|
||||||
resourceId: delivery.id,
|
|
||||||
detail: {
|
|
||||||
deliveryType: delivery.deliveryType,
|
|
||||||
applicationId: delivery.applicationId,
|
|
||||||
messageId: delivery.messageId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
deliveryId: delivery.id,
|
deliveryId: delivery.id,
|
||||||
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
||||||
...payload,
|
...payload,
|
||||||
};
|
};
|
||||||
await this.prisma.cmppDownstreamDelivery.update({
|
const retriedAt = new Date();
|
||||||
|
const requeued = await this.prisma.cmppDownstreamDelivery.update({
|
||||||
where: { id: delivery.id },
|
where: { id: delivery.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
|
manualRetryCount: { increment: 1 },
|
||||||
|
lastRetriedAt: retriedAt,
|
||||||
nextRetryAt: null,
|
nextRetryAt: null,
|
||||||
sentAt: null,
|
sentAt: null,
|
||||||
acknowledgedAt: null,
|
acknowledgedAt: null,
|
||||||
@@ -1313,6 +1305,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: delivery.tenantId,
|
||||||
|
action: 'gateway.downstream_delivery_requeue',
|
||||||
|
resource: 'cmpp_downstream_delivery',
|
||||||
|
resourceId: delivery.id,
|
||||||
|
detail: {
|
||||||
|
deliveryType: delivery.deliveryType,
|
||||||
|
applicationId: delivery.applicationId,
|
||||||
|
messageId: delivery.messageId,
|
||||||
|
previousStatus: delivery.status,
|
||||||
|
previousRetryCount: delivery.retryCount,
|
||||||
|
manualRetryCount: requeued.manualRetryCount,
|
||||||
|
lastRetriedAt: retriedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||||
if (result.sent || result.delivered) {
|
if (result.sent || result.delivered) {
|
||||||
@@ -1649,6 +1658,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||||
}
|
}
|
||||||
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
||||||
|
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||||
const unitPrice = application.customerUnitPrice ?? 0;
|
const unitPrice = application.customerUnitPrice ?? 0;
|
||||||
const queuePriority = normalizeQueuePriority(application.queuePriority);
|
const queuePriority = normalizeQueuePriority(application.queuePriority);
|
||||||
const billing = this.billing.estimateSmsCost({
|
const billing = this.billing.estimateSmsCost({
|
||||||
@@ -1707,6 +1717,57 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
await this.recordCmppFailureReceipt(message, code, reason);
|
await this.recordCmppFailureReceipt(message, code, reason);
|
||||||
};
|
};
|
||||||
|
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
|
||||||
|
const risk = await this.riskReview.evaluateTask({
|
||||||
|
tenantId: application.tenantId,
|
||||||
|
applicationId: application.id,
|
||||||
|
templateId: options.templateId,
|
||||||
|
content: data.content,
|
||||||
|
variables: options.templateId ? templateVariables : undefined,
|
||||||
|
phones: [data.phoneNumber],
|
||||||
|
});
|
||||||
|
if (risk.status === 'rejected') {
|
||||||
|
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (risk.status === 'pending_review') {
|
||||||
|
await this.prisma.smsMessageRecord.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: { status: 'pending_review', signatureId: options.signatureId },
|
||||||
|
});
|
||||||
|
await this.prisma.smsBatchTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accountCheck = await this.billing.checkAccount({
|
||||||
|
tenantId: application.tenantId,
|
||||||
|
amountCents: billing.amountCents,
|
||||||
|
});
|
||||||
|
if (!accountCheck.canSend) {
|
||||||
|
await reject('BALANCE', '企业账户余额不足');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (billing.amountCents > 0) {
|
||||||
|
await this.billing.freeze({
|
||||||
|
tenantId: application.tenantId,
|
||||||
|
amountCents: billing.amountCents,
|
||||||
|
relatedType: 'sms_batch_task',
|
||||||
|
relatedId: task.id,
|
||||||
|
remark: 'CMPP 入站短信冻结',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.prisma.smsMessageRecord.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: { status: 'queued', signatureId: options.signatureId },
|
||||||
|
});
|
||||||
|
await this.prisma.smsBatchTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
||||||
|
});
|
||||||
|
await this.enqueueBatchTask(task.id);
|
||||||
|
};
|
||||||
if (application.status !== 'active' || application.tenant.status !== 'active') {
|
if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||||
await reject('ACCOUNT', '企业或短信应用已停用');
|
await reject('ACCOUNT', '企业或短信应用已停用');
|
||||||
} else if (!application.interfaceEnabled) {
|
} else if (!application.interfaceEnabled) {
|
||||||
@@ -1765,6 +1826,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!template && application.templateMismatchMode === 'direct_send') {
|
||||||
|
const signature = await this.resolveInboundSignatureCandidate(application.id, data.content);
|
||||||
|
if (!signature) {
|
||||||
|
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||||
|
} else {
|
||||||
|
await queueAfterRiskChecks({ signatureId: signature.id });
|
||||||
|
}
|
||||||
} else if (!template) {
|
} else if (!template) {
|
||||||
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
|
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
|
||||||
} else if (template.auditStatus !== 'approved') {
|
} else if (template.auditStatus !== 'approved') {
|
||||||
@@ -1772,46 +1840,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
|
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||||
await reject('SIGNATURE', '短信签名尚未审核通过');
|
await reject('SIGNATURE', '短信签名尚未审核通过');
|
||||||
} else {
|
} else {
|
||||||
const risk = await this.riskReview.evaluateTask({
|
await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id });
|
||||||
tenantId: application.tenantId,
|
|
||||||
applicationId: application.id,
|
|
||||||
templateId: template.id,
|
|
||||||
content: data.content,
|
|
||||||
phones: [data.phoneNumber],
|
|
||||||
});
|
|
||||||
if (risk.status === 'rejected') {
|
|
||||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
|
||||||
} else if (risk.status === 'pending_review') {
|
|
||||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'pending_review' } });
|
|
||||||
await this.prisma.smsBatchTask.update({
|
|
||||||
where: { id: task.id },
|
|
||||||
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const accountCheck = await this.billing.checkAccount({
|
|
||||||
tenantId: application.tenantId,
|
|
||||||
amountCents: billing.amountCents,
|
|
||||||
});
|
|
||||||
if (!accountCheck.canSend) {
|
|
||||||
await reject('BALANCE', '企业账户余额不足');
|
|
||||||
} else {
|
|
||||||
if (billing.amountCents > 0) {
|
|
||||||
await this.billing.freeze({
|
|
||||||
tenantId: application.tenantId,
|
|
||||||
amountCents: billing.amountCents,
|
|
||||||
relatedType: 'sms_batch_task',
|
|
||||||
relatedId: task.id,
|
|
||||||
remark: 'CMPP 入站短信冻结',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'queued' } });
|
|
||||||
await this.prisma.smsBatchTask.update({
|
|
||||||
where: { id: task.id },
|
|
||||||
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
|
||||||
});
|
|
||||||
await this.enqueueBatchTask(task.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
@@ -2160,8 +2189,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
private async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||||
return this.prisma.smsTemplate.findFirst({
|
const exact = await this.prisma.smsTemplate.findFirst({
|
||||||
where: {
|
where: {
|
||||||
applicationId,
|
applicationId,
|
||||||
content,
|
content,
|
||||||
@@ -2169,6 +2198,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
include: { signature: true },
|
include: { signature: true },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
if (exact) return exact;
|
||||||
|
const variableTemplates = await this.prisma.smsTemplate.findMany({
|
||||||
|
where: {
|
||||||
|
applicationId,
|
||||||
|
content: { contains: '${' },
|
||||||
|
},
|
||||||
|
include: { signature: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
private resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||||
@@ -2902,6 +2941,45 @@ function normalizeRegion(region?: string | null) {
|
|||||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchTemplateContent(templateContent: string, actualContent: string) {
|
||||||
|
if (templateContent === actualContent) {
|
||||||
|
return {} as Record<string, string>;
|
||||||
|
}
|
||||||
|
const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g;
|
||||||
|
const names: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let pattern = '^';
|
||||||
|
for (const match of templateContent.matchAll(tokenPattern)) {
|
||||||
|
const index = match.index ?? 0;
|
||||||
|
pattern += escapeRegularExpression(templateContent.slice(cursor, index));
|
||||||
|
pattern += '([\\s\\S]+?)';
|
||||||
|
names.push(match[1]);
|
||||||
|
cursor = index + match[0].length;
|
||||||
|
}
|
||||||
|
if (names.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`;
|
||||||
|
const matched = new RegExp(pattern, 'u').exec(actualContent);
|
||||||
|
if (!matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const variables: Record<string, string> = {};
|
||||||
|
for (let index = 0; index < names.length; index += 1) {
|
||||||
|
const name = names[index];
|
||||||
|
const value = matched[index + 1];
|
||||||
|
if (variables[name] !== undefined && variables[name] !== value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
variables[name] = value;
|
||||||
|
}
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegularExpression(value: string) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
|
function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
|
||||||
const itemProvince = normalizeRegion(item.province);
|
const itemProvince = normalizeRegion(item.province);
|
||||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ function createPrismaMock() {
|
|||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
|
||||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
|
||||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }),
|
||||||
|
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||||
},
|
},
|
||||||
smsMessageRecord: {
|
smsMessageRecord: {
|
||||||
groupBy: jest.fn().mockResolvedValue([
|
groupBy: jest.fn().mockResolvedValue([
|
||||||
@@ -264,7 +265,7 @@ describe('SmsConfigService', () => {
|
|||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
name: '优先应用',
|
name: '优先应用',
|
||||||
cmppAccount: '123456',
|
cmppAccount: '123456',
|
||||||
cmppEnterpriseCode: 'CUSTOM-EC',
|
cmppEnterpriseCode: '123456',
|
||||||
secretHash: '1234567890abcdef',
|
secretHash: '1234567890abcdef',
|
||||||
cmppMaxConnections: 3,
|
cmppMaxConnections: 3,
|
||||||
cmppWindowSize: 32,
|
cmppWindowSize: 32,
|
||||||
@@ -343,6 +344,7 @@ describe('SmsConfigService', () => {
|
|||||||
where: { id: 'app-1' },
|
where: { id: 'app-1' },
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
name: '新应用',
|
name: '新应用',
|
||||||
|
cmppEnterpriseCode: '100001',
|
||||||
customerUnitPrice: 300,
|
customerUnitPrice: 300,
|
||||||
queuePriority: 'priority',
|
queuePriority: 'priority',
|
||||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||||
@@ -467,6 +469,22 @@ describe('SmsConfigService', () => {
|
|||||||
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes a disconnected downstream session instead of retaining connection history', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-1' });
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.recordDownstreamConnectionEvent({
|
||||||
|
account: '100001',
|
||||||
|
connectionId: 'gateway-1-1',
|
||||||
|
status: 'disconnected',
|
||||||
|
errorMessage: 'client closed',
|
||||||
|
})).resolves.toEqual(expect.objectContaining({ status: 'disconnected', deleted: true }));
|
||||||
|
|
||||||
|
expect(prisma.cmppDownstreamConnection.delete).toHaveBeenCalledWith({ where: { id: 'downstream-1' } });
|
||||||
|
expect(prisma.cmppDownstreamConnection.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('lists enterprise signatures with keyword filters and real relations', async () => {
|
it('lists enterprise signatures with keyword filters and real relations', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new SmsConfigService(prisma as never);
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export class SmsConfigService {
|
|||||||
const applicationIds = applications.map((application) => application.id);
|
const applicationIds = applications.map((application) => application.id);
|
||||||
const [connections, messageStats] = await Promise.all([
|
const [connections, messageStats] = await Promise.all([
|
||||||
this.prisma.cmppDownstreamConnection.findMany({
|
this.prisma.cmppDownstreamConnection.findMany({
|
||||||
where: { applicationId: { in: applicationIds } },
|
where: { applicationId: { in: applicationIds }, status: 'connected' },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
this.prisma.smsMessageRecord.groupBy({
|
this.prisma.smsMessageRecord.groupBy({
|
||||||
@@ -297,7 +297,7 @@ export class SmsConfigService {
|
|||||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||||
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
||||||
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
|
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
|
||||||
const cmppEnterpriseCode = await this.resolveCmppEnterpriseCode(data.cmppEnterpriseCode, data.tenantId);
|
const cmppEnterpriseCode = cmppAccount;
|
||||||
return this.prisma.smsApplication.create({
|
return this.prisma.smsApplication.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
@@ -337,9 +337,7 @@ export class SmsConfigService {
|
|||||||
const cmppAccount = data.cmppAccount === undefined
|
const cmppAccount = data.cmppAccount === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId);
|
: await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId);
|
||||||
const cmppEnterpriseCode = data.cmppEnterpriseCode === undefined
|
const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount;
|
||||||
? undefined
|
|
||||||
: normalizeEnterpriseCode(data.cmppEnterpriseCode);
|
|
||||||
const interfaceType = data.interfaceType === undefined
|
const interfaceType = data.interfaceType === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: normalizeApplicationInterfaceType(data.interfaceType);
|
: normalizeApplicationInterfaceType(data.interfaceType);
|
||||||
@@ -552,17 +550,6 @@ export class SmsConfigService {
|
|||||||
throw new BadRequestException('Unable to generate unique CMPP account');
|
throw new BadRequestException('Unable to generate unique CMPP account');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCmppEnterpriseCode(cmppEnterpriseCode: string | undefined, tenantId: string) {
|
|
||||||
if (cmppEnterpriseCode !== undefined) {
|
|
||||||
return normalizeEnterpriseCode(cmppEnterpriseCode);
|
|
||||||
}
|
|
||||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { code: true } });
|
|
||||||
if (!tenant) {
|
|
||||||
throw new BadRequestException('tenantId does not reference an existing tenant');
|
|
||||||
}
|
|
||||||
return normalizeEnterpriseCode(tenant.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
|
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
|
||||||
const application = await this.prisma.smsApplication.findUnique({
|
const application = await this.prisma.smsApplication.findUnique({
|
||||||
where: { cmppAccount: data.account },
|
where: { cmppAccount: data.account },
|
||||||
@@ -574,7 +561,20 @@ export class SmsConfigService {
|
|||||||
const observedAt = parseGatewayDate(data.observedAt) ?? new Date();
|
const observedAt = parseGatewayDate(data.observedAt) ?? new Date();
|
||||||
const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt;
|
const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt;
|
||||||
const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } });
|
const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } });
|
||||||
const status = data.status === 'disconnected' ? 'disconnected' : 'connected';
|
if (data.status === 'disconnected') {
|
||||||
|
if (existing) {
|
||||||
|
await this.prisma.cmppDownstreamConnection.delete({ where: { id: existing.id } });
|
||||||
|
}
|
||||||
|
await this.writeOperationLog(application.tenantId, undefined, 'cmpp_downstream_connection.disconnected', 'cmpp_downstream_connection', data.connectionId, {
|
||||||
|
applicationId: application.id,
|
||||||
|
account: data.account,
|
||||||
|
remoteIp: data.remoteIp,
|
||||||
|
protocol: data.protocol,
|
||||||
|
status: 'disconnected',
|
||||||
|
errorMessage: data.errorMessage,
|
||||||
|
});
|
||||||
|
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
tenantId: application.tenantId,
|
tenantId: application.tenantId,
|
||||||
applicationId: application.id,
|
applicationId: application.id,
|
||||||
@@ -582,18 +582,18 @@ export class SmsConfigService {
|
|||||||
enterpriseCode: application.cmppEnterpriseCode,
|
enterpriseCode: application.cmppEnterpriseCode,
|
||||||
remoteIp: data.remoteIp,
|
remoteIp: data.remoteIp,
|
||||||
protocol: data.protocol,
|
protocol: data.protocol,
|
||||||
status,
|
status: 'connected',
|
||||||
connectedAt: existing?.connectedAt ?? connectedAt,
|
connectedAt: existing?.connectedAt ?? connectedAt,
|
||||||
lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt,
|
lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt,
|
||||||
lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt,
|
lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt,
|
||||||
lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt,
|
lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt,
|
||||||
disconnectedAt: data.status === 'disconnected' ? observedAt : null,
|
disconnectedAt: null,
|
||||||
lastError: data.status === 'disconnected' ? data.errorMessage ?? existing?.lastError ?? null : null,
|
lastError: null,
|
||||||
};
|
};
|
||||||
const connection = existing
|
const connection = existing
|
||||||
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
|
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
|
||||||
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
|
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
|
||||||
if (data.status === 'connected' || data.status === 'disconnected') {
|
if (data.status === 'connected') {
|
||||||
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
|
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
|
||||||
applicationId: application.id,
|
applicationId: application.id,
|
||||||
account: data.account,
|
account: data.account,
|
||||||
@@ -608,13 +608,8 @@ export class SmsConfigService {
|
|||||||
async markTimedOutDownstreamConnections(now = new Date()) {
|
async markTimedOutDownstreamConnections(now = new Date()) {
|
||||||
const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
|
const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
|
||||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||||
return this.prisma.cmppDownstreamConnection.updateMany({
|
return this.prisma.cmppDownstreamConnection.deleteMany({
|
||||||
where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } },
|
where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } },
|
||||||
data: {
|
|
||||||
status: 'heartbeat_timeout',
|
|
||||||
disconnectedAt: now,
|
|
||||||
lastError: `CMPP heartbeat timeout after ${Math.round(timeoutMs / 1000)} seconds`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,17 +1321,6 @@ interface TemplateVariableInput {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEnterpriseCode(value: string) {
|
|
||||||
const enterpriseCode = value.trim();
|
|
||||||
if (!enterpriseCode) {
|
|
||||||
throw new BadRequestException('cmppEnterpriseCode is required');
|
|
||||||
}
|
|
||||||
if (enterpriseCode.length > 32) {
|
|
||||||
throw new BadRequestException('cmppEnterpriseCode must be at most 32 characters');
|
|
||||||
}
|
|
||||||
return enterpriseCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeApplicationPassword(value: string | undefined) {
|
function normalizeApplicationPassword(value: string | undefined) {
|
||||||
const password = value?.trim() || generateApplicationPassword();
|
const password = value?.trim() || generateApplicationPassword();
|
||||||
if (password.length !== 16) {
|
if (password.length !== 16) {
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
|
|
||||||
当前确认:第一版保留短信业务,排除彩信功能;账户按现金余额和授信额度计费,人工充值和充值记录进入第一版开发范围,套餐、短信余量、账单流水页面和公开交易查询 API 不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。
|
当前确认:第一版保留短信业务,排除彩信功能;账户按现金余额和授信额度计费,人工充值和充值记录进入第一版开发范围,套餐、短信余量、账单流水页面和公开交易查询 API 不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。
|
||||||
|
|
||||||
|
### 0.1 运营端细节要求(2026-07-15)
|
||||||
|
|
||||||
|
- 企业签名的站点字段统一显示为“引流信息”,列表、表单和详情不展示引流信息提交时间。
|
||||||
|
- 企业应用的企业代码必须始终等于 CMPP 6 位账号,由系统同步且不可单独编辑;账号留空时,创建应用时自动生成二者。每任务号码数超过应用上限时拒绝整个任务并提示拆分,不允许静默截断。
|
||||||
|
- 手机号段支持真实删除;报备字段库展示被通道报备配置引用的通道数,引用数大于 0 时前后端均禁止删除,未引用字段才允许真实删除。
|
||||||
|
- 企业应用连接详情只展示当前已连接会话;断开或心跳超时会话从活跃连接表删除,不保留为连接历史。
|
||||||
|
- 短信审核批量通过必须基于明确勾选,只处理已选待审核任务;不得默认通过当前筛选结果或全部数据。
|
||||||
|
- 下游投递记录支持创建日期范围筛选,并将日期条件下推 PostgreSQL 列表与 Dashboard 聚合。
|
||||||
|
- 短信模板变量插入到文本框当前选区或光标位置,插入后光标移动到变量之后。
|
||||||
|
- 运营端短信记录使用自适应信息卡片,发送详情按概览、短信内容、通道回执、状态和分片审计分组,失败原因使用独立警示区域突出;该项只调整前端展示,不改变短信记录后端语义。
|
||||||
|
- 人工充值弹窗不要求填写操作人,操作身份从当前登录会话和后端操作日志取得。
|
||||||
|
|
||||||
## 1. 项目目标
|
## 1. 项目目标
|
||||||
|
|
||||||
建设一个短信平台第一版,支持企业客户在客户端完成短信应用、模板、签名、号码导入、短信发送、批量任务查询、发送明细查询、上行短信查询;支持运营端完成企业管理、企业应用/签名/模板管理、审核、通道配置、通道签名报备、报备任务导出/回执导入、发送监控、任务进度、短信记录、上行记录、安全控制和系统管理。
|
建设一个短信平台第一版,支持企业客户在客户端完成短信应用、模板、签名、号码导入、短信发送、批量任务查询、发送明细查询、上行短信查询;支持运营端完成企业管理、企业应用/签名/模板管理、审核、通道配置、通道签名报备、报备任务导出/回执导入、发送监控、任务进度、短信记录、上行记录、安全控制和系统管理。
|
||||||
@@ -144,7 +156,8 @@
|
|||||||
- 客户端批量任务列表、详情、短信明细和取消操作必须同时校验当前企业和 `sourceType=client`。
|
- 客户端批量任务列表、详情、短信明细和取消操作必须同时校验当前企业和 `sourceType=client`。
|
||||||
10. 所有来源的短信,包括平台批量任务、API 调用、CMPP 对接发送,全部按手机号维度进入短信记录。
|
10. 所有来源的短信,包括平台批量任务、API 调用、CMPP 对接发送,全部按手机号维度进入短信记录。
|
||||||
11. 任务进度、发送详情和短信记录实时或准实时更新。
|
11. 任务进度、发送详情和短信记录实时或准实时更新。
|
||||||
12. 企业应用“不符合模板的短信”配置为 `manual_review` 时,合法的 CMPP Submit 在模板不匹配后进入人工审核;配置为 `reject` 时仍直接拒绝并返回 `REJECTD` Deliver Receipt,其他模式不得被人工审核聚合逻辑误接管。
|
12. 企业应用“不符合模板的短信”配置为 `manual_review` 时,合法的 CMPP Submit 在模板不匹配后进入人工审核;配置为 `reject` 时直接拒绝并返回 `REJECTD` Deliver Receipt;配置为 `direct_send` 时必须识别并绑定已审核通过的完整括号签名,继续执行风控、余额、通道组路由、具体通道签名报备和 Gateway 真实提交,不得因模板未匹配落入 `reject`,也不得绕过其他发送校验。
|
||||||
|
- CMPP 入站模板匹配必须支持模板正文中的 `${variable}` 占位符。固定文本需完整匹配,占位符至少匹配一个字符;同名占位符重复出现时取值必须一致。匹配成功后应绑定真实 `templateId`,并将提取的变量值传入风控,不能只用整段正文数据库精确相等判断。
|
||||||
13. CMPP 模板不匹配审核支持短窗口内容指纹聚合:只有同一企业应用、同一 CMPP 账号、规范化后内容 SHA-256 完全一致且位于同一时间窗口的短信才能合并为一个审核任务。默认窗口 10 秒,可通过 `CMPP_TEMPLATE_REVIEW_WINDOW_MS` 调整。
|
13. CMPP 模板不匹配审核支持短窗口内容指纹聚合:只有同一企业应用、同一 CMPP 账号、规范化后内容 SHA-256 完全一致且位于同一时间窗口的短信才能合并为一个审核任务。默认窗口 10 秒,可通过 `CMPP_TEMPLATE_REVIEW_WINDOW_MS` 调整。
|
||||||
14. 聚合审核不合并短信记录、计费或回执:每个手机号仍有独立 `SmsMessageRecord/messageId/sequenceId`。审核通过后逐条进入真实路由和上游提交;审核驳回后逐条释放冻结并产生客户侧 `REJECTD` 回执。
|
14. 聚合审核不合并短信记录、计费或回执:每个手机号仍有独立 `SmsMessageRecord/messageId/sequenceId`。审核通过后逐条进入真实路由和上游提交;审核驳回后逐条释放冻结并产生客户侧 `REJECTD` 回执。
|
||||||
15. 人工审核只覆盖模板不匹配;签名必须以完整中文中括号前缀 `【签名】` 识别,并使用包含中括号的完整名称匹配签名库。入站候选签名只要求 `auditStatus=approved`,不得以全局 `reportStatus` 提前拒绝;报备通过状态必须在后续路由和最终提交前按具体通道校验。签名不合法、风控直接拒绝或余额不足不得因内容聚合而绕过。
|
15. 人工审核只覆盖模板不匹配;签名必须以完整中文中括号前缀 `【签名】` 识别,并使用包含中括号的完整名称匹配签名库。入站候选签名只要求 `auditStatus=approved`,不得以全局 `reportStatus` 提前拒绝;报备通过状态必须在后续路由和最终提交前按具体通道校验。签名不合法、风控直接拒绝或余额不足不得因内容聚合而绕过。
|
||||||
@@ -263,10 +276,11 @@
|
|||||||
- 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。
|
- 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。
|
||||||
- 已实现 SubmitCommand 死信治理第一版:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表;运营端后端接口可分页查询死信,并支持人工将原始 `SubmitCommand` 重新写回 Redis Stream。
|
- 已实现 SubmitCommand 死信治理第一版:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表;运营端后端接口可分页查询死信,并支持人工将原始 `SubmitCommand` 重新写回 Redis Stream。
|
||||||
- 已实现客户侧下游投递重试第二版:客户系统负责断线后重连;平台在客户离线或投递失败时把 Deliver Receipt/上行 Deliver 保留在 `CmppDownstreamDelivery`,客户 bind 成功后立即拉取 pending,且 Gateway 会对当前在线账号周期补投;超过重试上限后转 `failed` 并写失败审计。
|
- 已实现客户侧下游投递重试第二版:客户系统负责断线后重连;平台在客户离线或投递失败时把 Deliver Receipt/上行 Deliver 保留在 `CmppDownstreamDelivery`,客户 bind 成功后立即拉取 pending,且 Gateway 会对当前在线账号周期补投;超过重试上限后转 `failed` 并写失败审计。
|
||||||
- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/failed/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对单条记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。
|
- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。
|
||||||
- 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。
|
- 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。
|
||||||
- 已实现下游投递告警第一版:运营看板与右上角通知基于真实 `CmppDownstreamDelivery` 聚合显示下游投递告警数,当前告警口径包括“pending 超过阈值仍未投出”和“最近失败记录数”,用于提醒运营及时进入下游投递记录页处理。
|
- 下游投递的 `pending` 展示必须结合真实尝试字段:自动与人工次数均为 0 时显示“待首次投递”,`retryCount > 0` 时显示“等待自动重试”,`manualRetryCount > 0` 时显示“人工重投排队中”。人工重投可重置新一轮自动重试预算,但不得把记录伪装成从未投递。
|
||||||
- 已实现下游投递 Dashboard 第一版:运营端“下游投递记录”页面顶部新增真实聚合总览,直接按 `tenantId/applicationId/deliveryType` 统计投递总量、pending/delivered/failed、积压告警、按类型分布、重试压力分布和应用告警排行,数据源必须来自 `CmppDownstreamDelivery`,不能靠前端本地汇总。
|
- 已实现下游投递告警统一口径:运营看板、侧栏通知、下游投递 Dashboard 和应用告警排行必须基于同一组真实 `CmppDownstreamDelivery` 条件统计:`pending` 超过积压阈值、`awaiting_ack` 超过 `ackDeadlineAt`,以及最近失败窗口内的 `failed/unconfirmed/rejected`。默认积压阈值为 10 分钟,最近失败窗口为 1 小时,可分别通过 `CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES` 和 `CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS` 覆盖。
|
||||||
|
- 已实现下游投递 Dashboard 第一版:运营端“下游投递记录”页面顶部新增真实聚合总览,直接按 `tenantId/applicationId/deliveryType` 统计投递总量、pending/awaiting_ack/delivered/failed/unconfirmed/rejected、积压告警、ACK 超时告警、按类型分布、重试压力分布和应用告警排行,数据源必须来自 `CmppDownstreamDelivery`,不能靠前端本地汇总。应用告警排行只统计满足统一告警时间窗的记录,不得将新创建的 `pending` 或超出最近窗口的历史失败永久累加为告警。
|
||||||
- 已实现下游连接映射持久化第一步:Gateway 在客户 CMPP 账号 bind 成功、下游 submit 建链和回执/上行下发时,会把账号在线状态、实例标识、最近活跃时间写入 Redis presence;该状态不再只保留在 Gateway 进程内存中,为后续“Gateway 重启后的 pending 恢复”提供外部状态基础。
|
- 已实现下游连接映射持久化第一步:Gateway 在客户 CMPP 账号 bind 成功、下游 submit 建链和回执/上行下发时,会把账号在线状态、实例标识、最近活跃时间写入 Redis presence;该状态不再只保留在 Gateway 进程内存中,为后续“Gateway 重启后的 pending 恢复”提供外部状态基础。
|
||||||
- 已实现下游连接映射持久化第二步:Gateway 启动时会读取 Redis presence 与当前内存在线账号,形成“恢复候选视图”,并通过控制面 `GET /downstream/recovery-candidates` 暴露候选账号列表,供后续恢复逻辑与运维排查使用;本阶段仍不等同于自动恢复 pending 投递。
|
- 已实现下游连接映射持久化第二步:Gateway 启动时会读取 Redis presence 与当前内存在线账号,形成“恢复候选视图”,并通过控制面 `GET /downstream/recovery-candidates` 暴露候选账号列表,供后续恢复逻辑与运维排查使用;本阶段仍不等同于自动恢复 pending 投递。
|
||||||
- 已实现下游 pending 恢复执行第一版:Gateway 启动后会立即按恢复候选账号拉取真实 `CmppDownstreamDelivery.pending`,后续每轮补投周期也会继续扫描恢复候选;若账号已有可用下游连接则继续推送回执/上行,若账号尚未重连则保持 `pending` 等待后续恢复,不能因为 Gateway 重启就把未投递记录误标成失败。
|
- 已实现下游 pending 恢复执行第一版:Gateway 启动后会立即按恢复候选账号拉取真实 `CmppDownstreamDelivery.pending`,后续每轮补投周期也会继续扫描恢复候选;若账号已有可用下游连接则继续推送回执/上行,若账号尚未重连则保持 `pending` 等待后续恢复,不能因为 Gateway 重启就把未投递记录误标成失败。
|
||||||
|
|||||||
@@ -23,6 +23,19 @@
|
|||||||
| 号码 | 合法号码、重复号码、非法号码、企业黑名单号码、全局黑名单号码。 |
|
| 号码 | 合法号码、重复号码、非法号码、企业黑名单号码、全局黑名单号码。 |
|
||||||
| 账户 | 现金余额与授信额度组合后的和为正数、0、负数;授信额度覆盖正数、负数和 0;不配置套餐余量。 |
|
| 账户 | 现金余额与授信额度组合后的和为正数、0、负数;授信额度覆盖正数、负数和 0;不配置套餐余量。 |
|
||||||
| 企业认证 | 未认证、待审核、已通过、已驳回四类企业认证资料。 |
|
| 企业认证 | 未认证、待审核、已通过、已驳回四类企业认证资料。 |
|
||||||
|
|
||||||
|
## 2.1 2026-07-15 运营端细节回归
|
||||||
|
|
||||||
|
| 用例编号 | 优先级 | 验证内容 | 预期结果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TC-ADMIN-UI-0715-01 | P1 | 新建/编辑企业应用并留空或修改 CMPP 账号 | 企业代码控件不可编辑且实时跟随账号;API 最终持久化二者相等;超过每任务号码上限时整个任务被拒绝并有拆分说明。 |
|
||||||
|
| TC-ADMIN-DICT-0715-02 | P1 | 删除手机号段;分别删除引用数为 0 和大于 0 的报备字段 | 号段从 PostgreSQL 删除;字段列表显示真实使用通道数,未引用字段删除成功,已引用字段按钮禁用且直接调用 DELETE 也返回 400。 |
|
||||||
|
| TC-ADMIN-CMPP-0715-03 | P1 | 建立连接后主动断开,再制造心跳超时 | 连接详情只显示 active 连接;断开/超时行从 `CmppDownstreamConnection` 删除,操作日志仍保留断开审计。 |
|
||||||
|
| TC-ADMIN-AUDIT-0715-04 | P0 | 不勾选、勾选部分任务分别点击批量操作 | 未勾选时按钮禁用;只通过已选任务,未选任务状态不变;确认弹窗数量等于选择数。 |
|
||||||
|
| TC-ADMIN-DOWNSTREAM-0715-05 | P1 | 选择下游投递创建日期范围 | 列表及页面 Dashboard 使用同一日期范围查询真实数据库,范围外记录不计入。 |
|
||||||
|
| TC-ADMIN-TEMPLATE-0715-06 | P1 | 将光标置于模板中间并插入推荐/自定义变量 | 变量在光标或选区处插入,原选区被替换,光标停在变量后;运营端和客户端一致。 |
|
||||||
|
| TC-ADMIN-RECORD-0715-07 | P1 | 查看桌面/窄屏短信记录及失败详情 | 卡片不产生页面横向滚动,信息分组清晰;失败原因独立突出;详情仍读取真实 submit、receipt 和分片审计 API。 |
|
||||||
|
| TC-ADMIN-MISC-0715-08 | P2 | 查看签名引流信息、零待审核通知和充值弹窗 | 使用“引流信息”标题且无提交时间;0 为黑字灰底;充值弹窗无操作人字段。 |
|
||||||
| 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 |
|
| 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 |
|
||||||
| 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 |
|
| 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 |
|
||||||
| 非法内容 | 控制字符、emoji、换行、不可见字符、超长变量、签名外置内容、敏感词内容。 |
|
| 非法内容 | 控制字符、emoji、换行、不可见字符、超长变量、签名外置内容、敏感词内容。 |
|
||||||
@@ -1167,6 +1180,21 @@
|
|||||||
- 全局 `reportStatus=reporting` 不在入站阶段触发 `SIGNATURE` 拒绝,短信进入真实人工审核聚合链路且不产生签名失败回执。
|
- 全局 `reportStatus=reporting` 不在入站阶段触发 `SIGNATURE` 拒绝,短信进入真实人工审核聚合链路且不产生签名失败回执。
|
||||||
- 审核通过后只允许选择签名任务为 approved 的主通道,不能选择 pending 的备用通道;最终提交前继续执行同一通道级校验。
|
- 审核通过后只允许选择签名任务为 approved 的主通道,不能选择 pending 的备用通道;最终提交前继续执行同一通道级校验。
|
||||||
|
|
||||||
|
### TC-SEND-039B CMPP 变量模板匹配与 direct_send 策略
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:应用 A 存在审核通过模板 `【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。`;应用 B 配置 `templateMismatchMode=direct_send`。两个应用均配置已审核签名、余额、真实通道组及至少一个签名报备通过且在线的通道。
|
||||||
|
- 步骤:
|
||||||
|
1. 应用 A 通过 CMPP 提交 `【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。`。
|
||||||
|
2. 查询 `SmsMessageRecord/SmsBatchTask/SmsSendTask`,并检查进入风险评估的模板和变量。
|
||||||
|
3. 应用 B 提交签名合法但没有任何模板匹配的短信。
|
||||||
|
4. 分别将应用 B 的签名改为未审核、账户改为余额不足、具体通道签名报备改为未通过后重复提交。
|
||||||
|
- 预期结果:
|
||||||
|
- 步骤 1 按固定正文和 `${code}` 占位符匹配模板,保存真实 `templateId`,向风控传入 `code=715021`,不得产生 `TEMPLATE/REJECTD` 失败回执。
|
||||||
|
- 应用 B 的模板不匹配短信按 `direct_send` 继续进入风控、余额、队列和真实通道路由;消息保存识别出的 `signatureId`,不能被模板拒绝分支截断。
|
||||||
|
- `direct_send` 只跳过模板匹配要求,不跳过企业/应用状态、签名审核、风控、余额、具体通道报备、通道在线状态和 Gateway 提交校验;任一校验失败时按真实失败原因拒绝或失败。
|
||||||
|
- `reject` 与 `manual_review` 的既有行为不变;CMPP SubmitResp、失败 Deliver Receipt 和最终上游回执仍按既有异步语义处理。
|
||||||
|
|
||||||
### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环
|
### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环
|
||||||
|
|
||||||
- 优先级:P0
|
- 优先级:P0
|
||||||
@@ -1298,7 +1326,9 @@
|
|||||||
- 页面列表来自真实 `/api/admin/operations/downstream-deliveries`,不是前端静态数组或本地状态拼装。
|
- 页面列表来自真实 `/api/admin/operations/downstream-deliveries`,不是前端静态数组或本地状态拼装。
|
||||||
- 详情展示真实 payload、`retryCount/nextRetryAt/deliveredAt/lastError`。
|
- 详情展示真实 payload、`retryCount/nextRetryAt/deliveredAt/lastError`。
|
||||||
- 人工重投调用真实 `/api/admin/operations/downstream-deliveries/{id}/requeue`,由后端实际触发 Gateway `/downstream/receipt` 或 `/downstream/uplink`。
|
- 人工重投调用真实 `/api/admin/operations/downstream-deliveries/{id}/requeue`,由后端实际触发 Gateway `/downstream/receipt` 或 `/downstream/uplink`。
|
||||||
- 重投后记录状态、失败原因和系统日志都与真实后端处理结果一致。
|
- 人工重投后 `manualRetryCount` 递增、`lastRetriedAt` 更新,新一轮 `retryCount` 从 0 开始;系统日志保留重投前状态、原自动重试次数和新人工重投次数。
|
||||||
|
- 重投后若尚未真正写出,列表显示“人工重投排队中”,不得误显示“待首次投递”;自动失败退避中的 pending 显示“等待自动重试”。
|
||||||
|
- `awaiting_ack` 记录在前端不可选且后端拒绝并发重投,不能仅依赖按钮禁用。
|
||||||
|
|
||||||
### TC-GW-015 下游投递指数退避
|
### TC-GW-015 下游投递指数退避
|
||||||
|
|
||||||
@@ -1326,17 +1356,18 @@
|
|||||||
- 后端逐条执行真实重投,返回 `total/successCount/failedCount/results`。
|
- 后端逐条执行真实重投,返回 `total/successCount/failedCount/results`。
|
||||||
- 成功和失败记录都会保留真实后端状态与错误信息;空选择时接口拒绝执行。
|
- 成功和失败记录都会保留真实后端状态与错误信息;空选择时接口拒绝执行。
|
||||||
|
|
||||||
### TC-GW-017 下游投递告警聚合
|
### TC-GW-017 下游投递告警统一聚合
|
||||||
|
|
||||||
- 优先级:P1
|
- 优先级:P1
|
||||||
- 前置条件:真实 `CmppDownstreamDelivery` 中准备一批 `pending` 记录,其中部分已超过告警阈值;同时准备一批最近失败的 `failed` 记录。
|
- 前置条件:真实 `CmppDownstreamDelivery` 中准备阈值内/外的 `pending`、未超时/已超过 `ackDeadlineAt` 的 `awaiting_ack`、最近窗口内/外的 `failed/unconfirmed/rejected` 及正常 `delivered` 记录,且覆盖多个应用。
|
||||||
- 步骤:
|
- 步骤:
|
||||||
1. 访问运营端 Dashboard 和右上角通知区域。
|
1. 访问运营端 Dashboard 和右上角通知区域。
|
||||||
2. 调用真实 `/api/admin/operations/dashboard/statistics`,核对返回的下游投递告警聚合。
|
2. 调用真实 `/api/admin/operations/dashboard/statistics`,核对返回的下游投递告警聚合。
|
||||||
3. 点击“下游投递告警”通知,跳转到下游投递记录页进一步筛查。
|
3. 点击“下游投递告警”通知,跳转到下游投递记录页进一步筛查。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
- Dashboard 返回真实 `downstreamDeliverySummary`,至少包含 `pending/failed/delivered/stalledPending/recentFailed/alertCount`。
|
- Dashboard 返回真实 `downstreamDeliverySummary`,至少包含 `pending/failed/delivered/stalledPending/stalledAck/recentFailed/alertCount`。
|
||||||
- 右上角通知中的“下游投递告警”数量与真实 Dashboard 聚合一致,不是前端写死值。
|
- `alertCount` 精确等于“超阈值 pending + 超时 awaiting_ack + 最近窗口内 failed/unconfirmed/rejected”,阈值内 pending、未超时 awaiting_ack、历史失败和 delivered 不计入。
|
||||||
|
- 侧栏/首页的“下游投递告警”数量与下游投递 Dashboard 在同一筛选范围下一致,不是前端写死值。
|
||||||
- 点击通知后可以进入真实下游投递记录页继续处理。
|
- 点击通知后可以进入真实下游投递记录页继续处理。
|
||||||
|
|
||||||
### TC-GW-018 下游投递 Dashboard 聚合视图
|
### TC-GW-018 下游投递 Dashboard 聚合视图
|
||||||
@@ -1349,10 +1380,10 @@
|
|||||||
3. 切换应用和类型筛选,确认顶部 Dashboard 与下方记录列表同时切换到同一筛选范围。
|
3. 切换应用和类型筛选,确认顶部 Dashboard 与下方记录列表同时切换到同一筛选范围。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
- 顶部 Dashboard 必须来自真实聚合接口,不能由当前页列表条目在前端临时汇总。
|
- 顶部 Dashboard 必须来自真实聚合接口,不能由当前页列表条目在前端临时汇总。
|
||||||
- `summary` 中 `total/pending/delivered/failed/stalledPending/recentFailed/alertCount` 与数据库真实结果一致。
|
- `summary` 中 `total/pending/awaitingAck/delivered/failed/unconfirmed/rejected/stalledPending/stalledAck/recentFailed/alertCount` 与数据库真实结果一致。
|
||||||
- `typeBreakdown` 能正确区分 `receipt` 和 `uplink` 的状态分布。
|
- `typeBreakdown` 能正确区分 `receipt` 和 `uplink` 的状态分布。
|
||||||
- `retryBuckets` 真实反映 `pending/failed` 记录的重试压力分布。
|
- `retryBuckets` 真实反映 `pending/failed` 记录的重试压力分布。
|
||||||
- `topApplications` 以告警量优先排序,切换筛选后结果实时刷新。
|
- `topApplications` 使用与 `summary.alertCount` 相同的时间窗和状态条件统计,各应用告警数之和与同范围总告警一致,并以告警量优先排序。
|
||||||
|
|
||||||
### TC-GW-019 下游在线账号 Presence 持久化
|
### TC-GW-019 下游在线账号 Presence 持久化
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
# 第一版系统化测试进度
|
# 第一版系统化测试进度
|
||||||
|
|
||||||
|
## 2026-07-15 CMPP 变量模板与 direct_send 修复(未提交、未部署)
|
||||||
|
|
||||||
|
- 生产只读诊断确认:10:52:58 账号 `910887` 向 `18821203795` 提交验证码短信,应用已于 10:52:36 保存 `templateMismatchMode=direct_send`,且存在审核通过的 `${code}` 变量模板;原实现用正文精确相等查询,实际验证码无法匹配占位符,同时模板为空时仅识别 `manual_review`,导致 `direct_send` 错误落入 `TEMPLATE/REJECTD`。
|
||||||
|
- `resolveInboundTemplateCandidate` 先保留精确匹配,再对同应用变量模板执行固定正文全量匹配,提取非空变量值;同名变量重复出现必须取值一致。匹配成功后保存真实 `templateId`,并将变量值传给真实风控评估。
|
||||||
|
- 新增 `direct_send` 分支:模板不匹配时识别并保存已审核完整括号签名,继续执行风控、余额冻结、真实队列、通道组路由和具体通道签名报备校验;只跳过模板要求,不做无条件放行。`reject/manual_review` 行为保持不变。
|
||||||
|
- 新增变量模板验证码和 `direct_send` 两项回归;SendChainService 1 suite/49 项通过,API 全量 17 suites/169 项通过,Prisma validate、API build、前端 build 和 `git diff --check` 通过,前端仅有既有 Vite chunk size warning。按用户要求未提交、未推送、未部署,未重投 10:52:58 的短信。
|
||||||
|
|
||||||
|
## 2026-07-15 运营端细节修复批次(未提交、未部署)
|
||||||
|
|
||||||
|
- 企业签名引流字段统一为“引流信息”并隐藏提交时间;人工充值弹窗移除操作人;待审核通知中的 0 使用黑字灰底。
|
||||||
|
- 企业应用表单补充每任务号码上限的整任务拒绝说明;企业代码控件不可编辑并跟随 CMPP 6 位账号,NestJS 创建/更新也强制持久化两者相等。
|
||||||
|
- 手机号段新增真实 DELETE;报备字段 API 返回真实通道引用数,未引用可删除,引用数大于 0 时前端禁用且后端返回 400。
|
||||||
|
- 客户 CMPP 连接详情只展示已连接会话;Gateway 上报断开时删除活跃连接行,心跳超时扫描也删除,新增 migration 清理旧非 connected 行,断开操作日志仍保留。
|
||||||
|
- 短信审核新增逐行/全选勾选,批量按钮只处理已选任务;下游投递列表与 Dashboard 增加统一创建日期范围参数并下推 Prisma/PostgreSQL。
|
||||||
|
- 运营端和客户端短信模板变量均插入当前光标/选区位置;短信记录前端改为自适应卡片和分组详情,失败原因独立警示,不改变短信记录后端接口与业务语义。
|
||||||
|
- 定向 API 测试 `dictionaries/sms-config/operations` 为 3 suites、49 项通过;API 全量 17 suites、167 项通过,Prisma validate、API build、前端 build、Gateway 全量 Go 测试和 `git diff --check` 均通过,前端仅有既有 chunk size warning。本地真实 PostgreSQL 已应用连接清理 migration,43 条 migration 全部齐全。
|
||||||
|
- 应用内浏览器已验证本地构建的运营端登录路由标题为“聆界短信管理平台”、DOM 非空且无框架错误覆盖;受 HttpOnly 会话和图形验证码限制,未绕过认证进入受保护页面。后续浏览器连接因桌面插件版本热更新失败,未以独立 Playwright 或 mock 页面替代。按用户要求保持未提交、未推送、未部署。
|
||||||
|
|
||||||
|
## 2026-07-15 下游人工重投状态追踪修复(未提交、未部署)
|
||||||
|
|
||||||
|
- 根因确认:人工重投会将 `CmppDownstreamDelivery` 重置为 `pending/retryCount=0`,前端又将所有 pending 固定翻译为“待首次投递”,导致已人工重投的记录被误展示为从未投递。
|
||||||
|
- 新增 `CmppDownstreamDelivery.manualRetryCount/lastRetriedAt` 及真实 Prisma migration;人工重投时递增人工次数、保存时间,并在 `OperationLog` 中记录重投前状态、原自动重试次数和新人工次数。自动重试次数仍可为新一轮重置为 0,但不再丢失人工重投轨迹。
|
||||||
|
- 列表和详情改为基于真实字段显示:初始 pending 为“待首次投递”,仅自动失败为“等待自动重试”,存在人工重投时为“人工重投排队中”;分开展示自动/人工次数和最近人工时间。
|
||||||
|
- 后端新增 `awaiting_ack` 并发重投拦截,避免绕过前端禁用直接调 API 造成重复投递。本地 PostgreSQL 42 条 migration 全部齐全,Prisma validate/status 通过,SendChainService + OperationsService 定向回归 2 suites/62 项通过,前端 build 通过(仅既有 Vite chunk size warning)。API build 曾在本次改动完成后通过;随后工作区并发出现的非本任务修改在 `sms-config.service.ts:300` 引入未定义的 `application`,当前 API 全量回归被该编译错误阻断,未覆盖或回退该并发修改。本修复未提交、未推送、未部署。
|
||||||
|
|
||||||
|
## 2026-07-15 下游投递告警口径统一(未提交、未部署)
|
||||||
|
|
||||||
|
- 修复前存在三套口径:侧栏/首页只统计超阈值 pending 和最近 failed;详情页额外统计超时 awaiting_ack 与最近 unconfirmed/rejected;应用排行则将全部 pending 和所有历史失败累加为告警,造成同一时刻数量不一致。
|
||||||
|
- 统一为“超阈值 pending + 超过 `ackDeadlineAt` 的 awaiting_ack + 最近窗口内 failed/unconfirmed/rejected”;默认积压阈值 10 分钟、最近失败窗口 1 小时,继续支持环境变量覆盖。
|
||||||
|
- `OperationsService.dashboard()` 和 `downstreamDeliveryDashboard()` 复用同一时间窗生成逻辑,首页/侧栏补齐 `stalledAck` 与三种最终异常状态;应用告警排行改为单独按统一告警 where 聚合,不再将普通 pending 和历史失败永久累加。
|
||||||
|
- 已补实 OperationsService 定向单元测试,覆盖首页三类告警条件和应用排行统一条件。API 完整 17 suites、163 项通过,API build 和前端 build 通过;前端仅有既有 Vite chunk size warning。本批按要求保持未提交、未推送、未部署。
|
||||||
|
|
||||||
## 2026-07-14 生产发送 Worker 配置缺失修复
|
## 2026-07-14 生产发送 Worker 配置缺失修复
|
||||||
|
|
||||||
- 生产号码 `18821203795` 的最新短信于 18:24:59 审核通过后恢复为 queued,BullMQ 已生成 job,但一直停留在 `bull:sms.send.queue:prioritized`,无通道、`submitId` 和 `SmsSubmitRecord`。
|
- 生产号码 `18821203795` 的最新短信于 18:24:59 审核通过后恢复为 queued,BullMQ 已生成 job,但一直停留在 `bull:sms.send.queue:prioritized`,无通道、`submitId` 和 `SmsSubmitRecord`。
|
||||||
|
|||||||
+7
-2
@@ -252,6 +252,7 @@ export type DashboardResponse = {
|
|||||||
failed: number;
|
failed: number;
|
||||||
delivered: number;
|
delivered: number;
|
||||||
stalledPending: number;
|
stalledPending: number;
|
||||||
|
stalledAck: number;
|
||||||
recentFailed: number;
|
recentFailed: number;
|
||||||
alertCount: number;
|
alertCount: number;
|
||||||
};
|
};
|
||||||
@@ -801,6 +802,8 @@ export type DownstreamDeliveryRecord = {
|
|||||||
status: string;
|
status: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
|
manualRetryCount: number;
|
||||||
|
lastRetriedAt?: string | null;
|
||||||
retryEnabled: boolean;
|
retryEnabled: boolean;
|
||||||
nextRetryAt?: string | null;
|
nextRetryAt?: string | null;
|
||||||
sentAt?: string | null;
|
sentAt?: string | null;
|
||||||
@@ -1103,7 +1106,7 @@ export const adminApi = {
|
|||||||
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
|
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', 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)),
|
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
||||||
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string } = {}) =>
|
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
||||||
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
||||||
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
|
request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
|
||||||
@@ -1111,7 +1114,7 @@ export const adminApi = {
|
|||||||
request<GatewayDownstreamRecoveryStatus>(`/admin/operations/downstream-recovery-statuses/${id}`),
|
request<GatewayDownstreamRecoveryStatus>(`/admin/operations/downstream-recovery-statuses/${id}`),
|
||||||
exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) =>
|
exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) =>
|
||||||
requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)),
|
requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)),
|
||||||
listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
||||||
request<PagedResponse<DownstreamDeliveryRecord>>(withQuery('/admin/operations/downstream-deliveries', query)),
|
request<PagedResponse<DownstreamDeliveryRecord>>(withQuery('/admin/operations/downstream-deliveries', query)),
|
||||||
requeueDownstreamDelivery: (id: string) =>
|
requeueDownstreamDelivery: (id: string) =>
|
||||||
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }),
|
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }),
|
||||||
@@ -1138,6 +1141,7 @@ export const adminApi = {
|
|||||||
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)),
|
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)),
|
||||||
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
deletePhoneSegment: (id: string) => request<DictionaryItem>(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }),
|
||||||
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)),
|
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)),
|
||||||
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
|
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
|
||||||
@@ -1145,6 +1149,7 @@ export const adminApi = {
|
|||||||
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
||||||
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
|
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
|
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
|
||||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => {
|
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('file', file);
|
form.set('file', file);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ type CustomerRow = TenantManagementRow;
|
|||||||
|
|
||||||
type RechargeForm = {
|
type RechargeForm = {
|
||||||
amount: string;
|
amount: string;
|
||||||
operator: string;
|
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
|||||||
function emptyRechargeForm(): RechargeForm {
|
function emptyRechargeForm(): RechargeForm {
|
||||||
return {
|
return {
|
||||||
amount: '',
|
amount: '',
|
||||||
operator: '运营',
|
|
||||||
remark: '',
|
remark: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -131,7 +129,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
|||||||
await adminApi.createManualRecharge({
|
await adminApi.createManualRecharge({
|
||||||
tenantId: rechargeTarget.id,
|
tenantId: rechargeTarget.id,
|
||||||
amountCents: Math.round(amount * 100),
|
amountCents: Math.round(amount * 100),
|
||||||
remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '),
|
remark: rechargeForm.remark,
|
||||||
});
|
});
|
||||||
setRechargeTarget(null);
|
setRechargeTarget(null);
|
||||||
setRechargeForm(emptyRechargeForm());
|
setRechargeForm(emptyRechargeForm());
|
||||||
@@ -210,7 +208,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
|||||||
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
||||||
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
|
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
|
||||||
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
|
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
|
||||||
<Input label="操作人" onChange={(event) => updateRechargeForm('operator', event.target.value)} value={rechargeForm.operator} />
|
|
||||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
||||||
</div>
|
</div>
|
||||||
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
|
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
|
||||||
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
|
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
|
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||||
@@ -14,7 +14,6 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
pending: '待首次投递',
|
|
||||||
awaiting_ack: '等待客户端确认',
|
awaiting_ack: '等待客户端确认',
|
||||||
delivered: '客户端已确认',
|
delivered: '客户端已确认',
|
||||||
failed: '投递失败',
|
failed: '投递失败',
|
||||||
@@ -22,6 +21,13 @@ const statusLabel: Record<string, string> = {
|
|||||||
rejected: '客户端拒绝',
|
rejected: '客户端拒绝',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function deliveryStatusLabel(record: DownstreamDeliveryRecord) {
|
||||||
|
if (record.status !== 'pending') return statusLabel[record.status] ?? record.status;
|
||||||
|
if (record.manualRetryCount > 0) return '人工重投排队中';
|
||||||
|
if (record.retryCount > 0) return '等待自动重试';
|
||||||
|
return '待首次投递';
|
||||||
|
}
|
||||||
|
|
||||||
const deliveryTypeLabel: Record<string, string> = {
|
const deliveryTypeLabel: Record<string, string> = {
|
||||||
receipt: '状态回执',
|
receipt: '状态回执',
|
||||||
uplink: '上行短信',
|
uplink: '上行短信',
|
||||||
@@ -43,9 +49,11 @@ function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRe
|
|||||||
<div><span>企业</span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
|
<div><span>企业</span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
|
||||||
<div><span>应用</span><strong>{record.application?.name ?? record.applicationId}</strong></div>
|
<div><span>应用</span><strong>{record.application?.name ?? record.applicationId}</strong></div>
|
||||||
<div><span>投递类型</span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
|
<div><span>投递类型</span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
|
||||||
<div><span>当前状态</span><strong>{statusLabel[record.status] ?? record.status}</strong></div>
|
<div><span>当前状态</span><strong>{deliveryStatusLabel(record)}</strong></div>
|
||||||
<div><span>消息 ID</span><strong>{record.messageId ?? '-'}</strong></div>
|
<div><span>消息 ID</span><strong>{record.messageId ?? '-'}</strong></div>
|
||||||
<div><span>重试次数</span><strong>{record.retryCount}</strong></div>
|
<div><span>自动重试次数</span><strong>{record.retryCount}</strong></div>
|
||||||
|
<div><span>人工重投次数</span><strong>{record.manualRetryCount}</strong></div>
|
||||||
|
<div><span>最近人工重投</span><strong>{record.lastRetriedAt ? formatDateTime(record.lastRetriedAt) : '-'}</strong></div>
|
||||||
<div><span>下次重试</span><strong>{record.nextRetryAt ?? '-'}</strong></div>
|
<div><span>下次重试</span><strong>{record.nextRetryAt ?? '-'}</strong></div>
|
||||||
<div><span>自动重试</span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
|
<div><span>自动重试</span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
|
||||||
<div><span>写出时间</span><strong>{record.sentAt ?? '-'}</strong></div>
|
<div><span>写出时间</span><strong>{record.sentAt ?? '-'}</strong></div>
|
||||||
@@ -80,6 +88,7 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
|
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -87,6 +96,8 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
adminApi.getDownstreamDeliveryDashboard({
|
adminApi.getDownstreamDeliveryDashboard({
|
||||||
applicationId,
|
applicationId,
|
||||||
deliveryType,
|
deliveryType,
|
||||||
|
createdAtFrom: dateRange.start,
|
||||||
|
createdAtTo: dateRange.end,
|
||||||
}),
|
}),
|
||||||
adminApi.listDownstreamDeliveries({
|
adminApi.listDownstreamDeliveries({
|
||||||
keyword,
|
keyword,
|
||||||
@@ -95,6 +106,8 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
applicationId,
|
applicationId,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
createdAtFrom: dateRange.start,
|
||||||
|
createdAtTo: dateRange.end,
|
||||||
}),
|
}),
|
||||||
adminApi.listEnterpriseApplications(),
|
adminApi.listEnterpriseApplications(),
|
||||||
])
|
])
|
||||||
@@ -108,7 +121,7 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
|
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [applicationId, deliveryType, keyword, page, pageSize, status]);
|
}, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -182,6 +195,7 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
|
|
||||||
<div className="surface admin-task-filter">
|
<div className="surface admin-task-filter">
|
||||||
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||||
|
<DateRangeInput label="创建日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||||||
<Select
|
<Select
|
||||||
label="状态"
|
label="状态"
|
||||||
options={[
|
options={[
|
||||||
@@ -232,6 +246,7 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
setStatus('all');
|
setStatus('all');
|
||||||
setDeliveryType('all');
|
setDeliveryType('all');
|
||||||
setApplicationId('all');
|
setApplicationId('all');
|
||||||
|
setDateRange({});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -379,11 +394,11 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
<strong>{record.messageId ?? '-'}</strong>
|
<strong>{record.messageId ?? '-'}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div role="cell">
|
<div role="cell">
|
||||||
<Tag tone={statusTone[record.status] ?? 'info'}>{statusLabel[record.status] ?? record.status}</Tag>
|
<Tag tone={statusTone[record.status] ?? 'info'}>{deliveryStatusLabel(record)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="downstream-delivery-list__retry" role="cell">
|
<div className="downstream-delivery-list__retry" role="cell">
|
||||||
<strong>{record.retryCount}</strong>
|
<strong>{record.retryCount}</strong>
|
||||||
<span>次</span>
|
<span>自动 / {record.manualRetryCount} 人工</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`downstream-delivery-list__error${record.lastError ? '' : ' is-empty'}`} role="cell" title={record.lastError ?? undefined}>
|
<div className={`downstream-delivery-list__error${record.lastError ? '' : ' is-empty'}`} role="cell" title={record.lastError ?? undefined}>
|
||||||
{record.lastError ?? '无'}
|
{record.lastError ?? '无'}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Search } from 'lucide-react';
|
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ type DrainageField = DictionaryItem & {
|
|||||||
fieldType?: string;
|
fieldType?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
usageCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReportFieldType = 'string' | 'image' | 'file';
|
type ReportFieldType = 'string' | 'image' | 'file';
|
||||||
@@ -34,6 +35,7 @@ export function AdminDrainageFieldsPage() {
|
|||||||
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
|
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<DrainageField | null>(null);
|
||||||
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
@@ -71,12 +73,24 @@ export function AdminDrainageFieldsPage() {
|
|||||||
.catch((failure: Error) => setError(failure.message || '报备字段新增失败'));
|
.catch((failure: Error) => setError(failure.message || '报备字段新增失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteField() {
|
||||||
|
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0) return;
|
||||||
|
adminApi.deleteDrainageField(deleteTarget.id)
|
||||||
|
.then(() => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
loadData();
|
||||||
|
})
|
||||||
|
.catch((failure: Error) => setError(failure.message || '报备字段删除失败'));
|
||||||
|
}
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
|
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
|
||||||
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
|
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
|
||||||
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
|
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
|
||||||
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{typeLabels[record.fieldType ?? ''] ?? record.fieldType}</span> },
|
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{typeLabels[record.fieldType ?? ''] ?? record.fieldType}</span> },
|
||||||
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' },
|
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' },
|
||||||
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
|
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
|
||||||
|
{ key: 'usageCount', title: '使用通道数', width: '130px', render: (record) => <Tag tone={(record.usageCount ?? 0) > 0 ? 'warning' : 'neutral'}>{record.usageCount ?? 0}</Tag> },
|
||||||
|
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button disabled={(record.usageCount ?? 0) > 0} icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,6 +146,16 @@ export function AdminDrainageFieldsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{deleteTarget ? (
|
||||||
|
<Modal
|
||||||
|
footer={<><Button onClick={() => setDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteField} variant="danger">确认删除</Button></>}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
open
|
||||||
|
title="删除报备字段"
|
||||||
|
>
|
||||||
|
<p>确认删除“{deleteTarget.name}”吗?未被通道使用的字段将从数据库中永久删除。</p>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ function CmppConnectionModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
|
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
|
||||||
|
const activeConnectionItems = app.cmppConnections.filter((item) => item.state === 'open');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -230,8 +231,8 @@ function CmppConnectionModal({
|
|||||||
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
|
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
|
||||||
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
|
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
|
||||||
]}
|
]}
|
||||||
data={app.cmppConnections}
|
data={activeConnectionItems}
|
||||||
emptyText="暂无CMPP连接"
|
emptyText="当前暂无已连接的 CMPP 会话"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
|
|||||||
<section>
|
<section>
|
||||||
<h3>基本信息</h3>
|
<h3>基本信息</h3>
|
||||||
<Input
|
<Input
|
||||||
label="* 引流信息"
|
label="* 引流网址"
|
||||||
onChange={(event) => update('url', event.target.value)}
|
onChange={(event) => update('url', event.target.value)}
|
||||||
placeholder="请输入引流网址"
|
placeholder="请输入引流网址"
|
||||||
required
|
required
|
||||||
@@ -538,8 +538,7 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div className="signature-form-grid">
|
<div className="signature-form-grid">
|
||||||
<Input label="* 站名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站名称" value={form.siteName} />
|
<Input label="* 引流信息" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入引流信息" value={form.siteName} />
|
||||||
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
|
|
||||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||||
</div>
|
</div>
|
||||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道引流信息报备资料" values={form.reportValues} />
|
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道引流信息报备资料" values={form.reportValues} />
|
||||||
@@ -605,12 +604,11 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo;
|
|||||||
return (
|
return (
|
||||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
|
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
<div><span>引流信息</span><strong>{item.siteName}</strong></div>
|
||||||
<div><span>引流信息</span><strong>{item.url}</strong></div>
|
<div><span>引流信息</span><strong>{item.url}</strong></div>
|
||||||
<div><span>移动</span><CarrierReportTag summary={summary?.mobile} /></div>
|
<div><span>移动</span><CarrierReportTag summary={summary?.mobile} /></div>
|
||||||
<div><span>联通</span><CarrierReportTag summary={summary?.unicom} /></div>
|
<div><span>联通</span><CarrierReportTag summary={summary?.unicom} /></div>
|
||||||
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
||||||
<div><span>提交时间</span><strong>{item.submittedAt}</strong></div>
|
|
||||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({target.channel.carrier})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({target.channel.carrier})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||||
@@ -816,13 +814,12 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
{visibleDrainageLinks.length ? (
|
{visibleDrainageLinks.length ? (
|
||||||
<div className="drainage-table">
|
<div className="drainage-table">
|
||||||
<div className="drainage-table__head">
|
<div className="drainage-table__head">
|
||||||
<span>站名称</span>
|
|
||||||
<span>引流信息</span>
|
<span>引流信息</span>
|
||||||
|
<span>URL</span>
|
||||||
<span>审核状态</span>
|
<span>审核状态</span>
|
||||||
<span>移动</span>
|
<span>移动</span>
|
||||||
<span>联通</span>
|
<span>联通</span>
|
||||||
<span>电信</span>
|
<span>电信</span>
|
||||||
<span>提交时间</span>
|
|
||||||
<span>操作</span>
|
<span>操作</span>
|
||||||
</div>
|
</div>
|
||||||
{visibleDrainageLinks.map((item) => {
|
{visibleDrainageLinks.map((item) => {
|
||||||
@@ -835,7 +832,6 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
<CarrierReportTag summary={summary?.mobile} />
|
<CarrierReportTag summary={summary?.mobile} />
|
||||||
<CarrierReportTag summary={summary?.unicom} />
|
<CarrierReportTag summary={summary?.unicom} />
|
||||||
<CarrierReportTag summary={summary?.telecom} />
|
<CarrierReportTag summary={summary?.telecom} />
|
||||||
<span className="muted">{item.submittedAt}</span>
|
|
||||||
<span className="drainage-row-actions">
|
<span className="drainage-row-actions">
|
||||||
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost">报备详情</Button>
|
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost">报备详情</Button>
|
||||||
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost">报备状态</Button>
|
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost">报备状态</Button>
|
||||||
@@ -884,7 +880,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
|
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
|
||||||
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
|
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
|
||||||
<Input label="签名名称" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名名称或用途" prefix={<Search size={16} />} value={signatureKeyword} />
|
<Input label="签名名称" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名名称或用途" prefix={<Search size={16} />} value={signatureKeyword} />
|
||||||
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入站名称、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
|
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
|
||||||
<div className="admin-split-filter__actions">
|
<div className="admin-split-filter__actions">
|
||||||
<Button icon={<Search size={16} />} onClick={() => {
|
<Button icon={<Search size={16} />} onClick={() => {
|
||||||
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() };
|
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
@@ -84,6 +84,7 @@ function TemplateFormModal({
|
|||||||
}) {
|
}) {
|
||||||
const [customVariable, setCustomVariable] = useState('');
|
const [customVariable, setCustomVariable] = useState('');
|
||||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||||
|
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [form, setForm] = useState<TemplateFormState>({
|
const [form, setForm] = useState<TemplateFormState>({
|
||||||
tenantId: item?.tenantId ?? '',
|
tenantId: item?.tenantId ?? '',
|
||||||
applicationId: item?.applicationId ?? '',
|
applicationId: item?.applicationId ?? '',
|
||||||
@@ -110,7 +111,15 @@ function TemplateFormModal({
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setContent(`${form.content}\${${normalized}}`);
|
const token = `\${${normalized}}`;
|
||||||
|
const textarea = contentRef.current;
|
||||||
|
const start = textarea?.selectionStart ?? form.content.length;
|
||||||
|
const end = textarea?.selectionEnd ?? start;
|
||||||
|
setContent(`${form.content.slice(0, start)}${token}${form.content.slice(end)}`);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
contentRef.current?.focus();
|
||||||
|
contentRef.current?.setSelectionRange(start + token.length, start + token.length);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVariableExample(name: string, example: string) {
|
function updateVariableExample(name: string, example: string) {
|
||||||
@@ -170,6 +179,7 @@ function TemplateFormModal({
|
|||||||
placeholder="例如:尊敬的${name},您的验证码为${code}。"
|
placeholder="例如:尊敬的${name},您的验证码为${code}。"
|
||||||
required
|
required
|
||||||
rows={8}
|
rows={8}
|
||||||
|
ref={contentRef}
|
||||||
value={form.content}
|
value={form.content}
|
||||||
/>
|
/>
|
||||||
<div className="template-form-meta">
|
<div className="template-form-meta">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Database, ListFilter, Plus, RotateCcw, Search } from 'lucide-react';
|
import { Database, ListFilter, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
@@ -42,6 +42,7 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [rulePage, setRulePage] = useState(1);
|
const [rulePage, setRulePage] = useState(1);
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<PhoneSegment | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -109,12 +110,23 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
setRulePage(1);
|
setRulePage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteSegment() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
adminApi.deletePhoneSegment(deleteTarget.id)
|
||||||
|
.then(() => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setReloadKey((current) => current + 1);
|
||||||
|
})
|
||||||
|
.catch((failure: Error) => setError(failure.message || '手机号段删除失败'));
|
||||||
|
}
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
|
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
|
||||||
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
|
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
|
||||||
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
|
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
|
||||||
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' },
|
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' },
|
||||||
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' },
|
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' },
|
||||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||||
|
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
|
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
|
||||||
@@ -261,6 +273,16 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
<Input label="备注" onChange={(event) => setRuleRemark(event.target.value)} value={ruleRemark} />
|
<Input label="备注" onChange={(event) => setRuleRemark(event.target.value)} value={ruleRemark} />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{deleteTarget ? (
|
||||||
|
<Modal
|
||||||
|
footer={<><Button onClick={() => setDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteSegment} variant="danger">确认删除</Button></>}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
open
|
||||||
|
title="删除手机号段"
|
||||||
|
>
|
||||||
|
<p>确认删除手机号段“{deleteTarget.prefix}”吗?删除后号码归属识别将不再使用该记录。</p>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { formatAmount } from '@/utils/currency';
|
|||||||
type ManualRechargeForm = {
|
type ManualRechargeForm = {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
operator: string;
|
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ export function AdminRechargeRecordsPage() {
|
|||||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||||
const [manualOpen, setManualOpen] = useState(false);
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', operator: '运营', remark: '' });
|
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', remark: '' });
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -102,11 +101,11 @@ export function AdminRechargeRecordsPage() {
|
|||||||
await adminApi.createManualRecharge({
|
await adminApi.createManualRecharge({
|
||||||
tenantId: form.tenantId,
|
tenantId: form.tenantId,
|
||||||
amountCents: Math.round(amount * 100),
|
amountCents: Math.round(amount * 100),
|
||||||
remark: [form.operator, form.remark].filter(Boolean).join(' / '),
|
remark: form.remark,
|
||||||
});
|
});
|
||||||
await loadData();
|
await loadData();
|
||||||
setManualOpen(false);
|
setManualOpen(false);
|
||||||
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', operator: '运营', remark: '' });
|
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', remark: '' });
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
|
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -204,7 +203,6 @@ export function AdminRechargeRecordsPage() {
|
|||||||
value={form.tenantId}
|
value={form.tenantId}
|
||||||
/>
|
/>
|
||||||
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} />
|
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} />
|
||||||
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} required value={form.operator} />
|
|
||||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
||||||
</div>
|
</div>
|
||||||
{manualError ? <p className="form-error">{manualError}</p> : null}
|
{manualError ? <p className="form-error">{manualError}</p> : null}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
|
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
|
||||||
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
|
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
|
||||||
const [cmppAccount, setCmppAccount] = useState('');
|
const [cmppAccount, setCmppAccount] = useState('');
|
||||||
const [cmppEnterpriseCode, setCmppEnterpriseCode] = useState('');
|
|
||||||
const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword());
|
const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword());
|
||||||
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
|
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
|
||||||
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
|
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
|
||||||
@@ -85,7 +84,6 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
|
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
|
||||||
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
|
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
|
||||||
setCmppAccount(application.cmppAccount ?? '');
|
setCmppAccount(application.cmppAccount ?? '');
|
||||||
setCmppEnterpriseCode(application.cmppEnterpriseCode ?? application.tenant?.code ?? '');
|
|
||||||
setPasswordCipher('');
|
setPasswordCipher('');
|
||||||
setInterfaceEnabled(application.interfaceEnabled !== false);
|
setInterfaceEnabled(application.interfaceEnabled !== false);
|
||||||
setInterfaceType('cmpp20');
|
setInterfaceType('cmpp20');
|
||||||
@@ -128,7 +126,6 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
|
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
|
||||||
queuePriority,
|
queuePriority,
|
||||||
cmppAccount: cmppAccount.trim() || undefined,
|
cmppAccount: cmppAccount.trim() || undefined,
|
||||||
cmppEnterpriseCode: cmppEnterpriseCode.trim() || undefined,
|
|
||||||
passwordCipher: passwordCipher.trim() || undefined,
|
passwordCipher: passwordCipher.trim() || undefined,
|
||||||
interfaceEnabled,
|
interfaceEnabled,
|
||||||
interfaceType,
|
interfaceType,
|
||||||
@@ -209,7 +206,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
<span>优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。</span>
|
<span>优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||||
<Select
|
<Select
|
||||||
label="不符合模板的短信"
|
label="不符合模板的短信"
|
||||||
onChange={(event) => setMismatchPolicy(event.target.value)}
|
onChange={(event) => setMismatchPolicy(event.target.value)}
|
||||||
@@ -251,7 +248,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
||||||
<Input label="企业代码" onChange={(event) => setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} />
|
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
|
||||||
<Input
|
<Input
|
||||||
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
||||||
label="接口密码"
|
label="接口密码"
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ export function AdminSmsAuditPage() {
|
|||||||
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
|
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
|
||||||
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | null>(null);
|
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | null>(null);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
|
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
setRecords(items);
|
setRecords(items);
|
||||||
|
setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')));
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
|
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
|
||||||
@@ -58,8 +60,9 @@ export function AdminSmsAuditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function approveBatch() {
|
async function approveBatch() {
|
||||||
await Promise.all(filteredRecords.filter((item) => item.status === 'pending_review').map((item) => adminApi.approveRiskReviewTask(item.id, '运营批量审核通过')));
|
await Promise.all(selectedIds.map((id) => adminApi.approveRiskReviewTask(id, '运营批量审核通过')));
|
||||||
setApproveTarget(null);
|
setApproveTarget(null);
|
||||||
|
setSelectedIds([]);
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +74,15 @@ export function AdminSmsAuditPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id);
|
||||||
|
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
|
||||||
const columns: Array<TableColumn<RiskReviewTask>> = [
|
const columns: Array<TableColumn<RiskReviewTask>> = [
|
||||||
|
{
|
||||||
|
key: 'selection',
|
||||||
|
title: <input aria-label="全选当前筛选结果" checked={allSelected} disabled={selectableIds.length === 0} onChange={(event) => setSelectedIds(event.target.checked ? selectableIds : [])} type="checkbox" />,
|
||||||
|
width: '54px',
|
||||||
|
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
||||||
|
},
|
||||||
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
|
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
|
||||||
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
||||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||||
@@ -124,8 +135,8 @@ export function AdminSmsAuditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sms-bulk-actions">
|
<div className="sms-bulk-actions">
|
||||||
<span>批量操作:</span>
|
<span>已选择 {selectedIds.length} 条待审核任务</span>
|
||||||
<Button disabled={filteredRecords.every((item) => item.status !== 'pending_review')} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success">批量通过</Button>
|
<Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success">通过已选</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,7 +155,7 @@ export function AdminSmsAuditPage() {
|
|||||||
open={Boolean(approveTarget)}
|
open={Boolean(approveTarget)}
|
||||||
title="确认通过"
|
title="确认通过"
|
||||||
>
|
>
|
||||||
<p>{approveTarget === 'batch' ? `确认通过 ${filteredRecords.filter((item) => item.status === 'pending_review').length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
|
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
|
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
@@ -251,8 +251,13 @@ function SendDetailModal({
|
|||||||
<div><span>发送状态</span><strong>{getStatusLabel(record.status)}</strong></div>
|
<div><span>发送状态</span><strong>{getStatusLabel(record.status)}</strong></div>
|
||||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||||
<div><span>失败原因</span><strong>{record.errorMessage ?? record.errorCode ?? '-'}</strong></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{['failed', 'rejected'].includes(record.status) || record.errorMessage || record.errorCode ? (
|
||||||
|
<div className="admin-sms-detail-failure" role="alert">
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
<div><span>失败原因</span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -415,53 +420,26 @@ export function AdminSmsRecordsPage() {
|
|||||||
<div className="admin-sms-record-toolbar">
|
<div className="admin-sms-record-toolbar">
|
||||||
<Button icon={<Download size={16} />} onClick={() => downloadCsv(filteredRows)} variant="ghost">导出CSV</Button>
|
<Button icon={<Download size={16} />} onClick={() => downloadCsv(filteredRows)} variant="ghost">导出CSV</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ui-table-wrap">
|
<div className="admin-sms-record-list">
|
||||||
<table className="ui-table admin-sms-record-table">
|
{filteredRows.length === 0 ? <div className="ui-table__empty">暂无短信记录</div> : visibleRows.map((record) => (
|
||||||
<thead>
|
<article className="admin-sms-record-card" key={record.id}>
|
||||||
<tr>
|
<header>
|
||||||
<th style={{ width: '170px' }}>发送者</th>
|
|
||||||
<th>短信内容</th>
|
|
||||||
<th style={{ width: '170px' }}>手机号码</th>
|
|
||||||
<th style={{ width: '300px' }}>通道与发送状态</th>
|
|
||||||
<th style={{ width: '120px' }}>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredRows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="ui-table__empty" colSpan={5}>暂无短信记录</td>
|
|
||||||
</tr>
|
|
||||||
) : visibleRows.map((record) => (
|
|
||||||
<tr key={record.id}>
|
|
||||||
<td>
|
|
||||||
<div className="admin-sms-record-sender">
|
<div className="admin-sms-record-sender">
|
||||||
<strong>{record.tenant?.name ?? record.tenantId}</strong>
|
<strong>{record.tenant?.name ?? record.tenantId}</strong>
|
||||||
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
|
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
|
||||||
<small>{getDate(record.queuedAt)}<br />{getClock(record.queuedAt)}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td><p className="admin-sms-record-content">{record.content}</p></td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-sms-record-phone">
|
|
||||||
<strong>{record.phoneNumber}</strong>
|
|
||||||
<span>{record.province ?? '-'} {getCarrierLabel(record.carrier)}</span>
|
|
||||||
<small>{record.content.length}字/{record.billingUnits}条</small>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-sms-record-channel">
|
|
||||||
<strong>{record.channel?.name ?? record.channelId ?? '-'}</strong>
|
|
||||||
<StatusLine status={record.status} />
|
<StatusLine status={record.status} />
|
||||||
<span>{getTime(record.deliveredAt)}</span>
|
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
|
||||||
|
</header>
|
||||||
|
<p className="admin-sms-record-content">{record.content}</p>
|
||||||
|
<div className="admin-sms-record-card__meta">
|
||||||
|
<div><span>接收号码</span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
|
||||||
|
<div><span>计费</span><strong>{record.billingUnits} 条 / ¥{(record.amountCents / 100).toFixed(3)}</strong><small>{record.content.length} 字</small></div>
|
||||||
|
<div><span>发送通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small>回执 {getTime(record.deliveredAt)}</small></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<footer><button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button">查看发送详情</button></footer>
|
||||||
<td style={{ textAlign: 'right' }}>
|
</article>
|
||||||
<button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button">发送详情</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
nextDisabled={currentPage >= totalPages}
|
nextDisabled={currentPage >= totalPages}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: Cli
|
|||||||
const missingRequired = fields.some((field) => field.required && !values[field.code]);
|
const missingRequired = fields.some((field) => field.required && !values[field.code]);
|
||||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} onClose={onClose} open size="xl" title={item ? '修改引流信息' : '新增引流信息'}>
|
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} onClose={onClose} open size="xl" title={item ? '修改引流信息' : '新增引流信息'}>
|
||||||
<div className="signature-form drainage-edit-form">
|
<div className="signature-form drainage-edit-form">
|
||||||
<Input label="站名称" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
|
<Input label="引流信息" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
|
||||||
<Input label="引流地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
|
<Input label="引流地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
|
||||||
<Textarea label="备注" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark} />
|
<Textarea label="备注" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark} />
|
||||||
<section className="surface" style={{ padding: 16 }}><h3>应用通道引流信息报备资料</h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
|
<section className="surface" style={{ padding: 16 }}><h3>应用通道引流信息报备资料</h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
||||||
@@ -72,6 +72,7 @@ function TemplateModal({
|
|||||||
}) {
|
}) {
|
||||||
const [customVariable, setCustomVariable] = useState('');
|
const [customVariable, setCustomVariable] = useState('');
|
||||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||||
|
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [form, setForm] = useState<TemplateFormState>({
|
const [form, setForm] = useState<TemplateFormState>({
|
||||||
applicationId: item?.applicationId ?? '',
|
applicationId: item?.applicationId ?? '',
|
||||||
signatureId: item?.signatureId ?? '',
|
signatureId: item?.signatureId ?? '',
|
||||||
@@ -99,7 +100,15 @@ function TemplateModal({
|
|||||||
function insertVariable(name: string) {
|
function insertVariable(name: string) {
|
||||||
const normalized = name.trim();
|
const normalized = name.trim();
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
setContent(`${form.content}\${${normalized}}`);
|
const token = `\${${normalized}}`;
|
||||||
|
const textarea = contentRef.current;
|
||||||
|
const start = textarea?.selectionStart ?? form.content.length;
|
||||||
|
const end = textarea?.selectionEnd ?? start;
|
||||||
|
setContent(`${form.content.slice(0, start)}${token}${form.content.slice(end)}`);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
contentRef.current?.focus();
|
||||||
|
contentRef.current?.setSelectionRange(start + token.length, start + token.length);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVariableExample(name: string, example: string) {
|
function updateVariableExample(name: string, example: string) {
|
||||||
@@ -134,7 +143,7 @@ function TemplateModal({
|
|||||||
/>
|
/>
|
||||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
|
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
|
||||||
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
||||||
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={6} value={form.content} />
|
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" ref={contentRef} rows={6} value={form.content} />
|
||||||
<div className="template-form-meta">
|
<div className="template-form-meta">
|
||||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Pagination } from './PagePrimitives';
|
|||||||
|
|
||||||
export type TableColumn<T> = {
|
export type TableColumn<T> = {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: ReactNode;
|
||||||
width?: string;
|
width?: string;
|
||||||
align?: 'left' | 'center' | 'right';
|
align?: 'left' | 'center' | 'right';
|
||||||
render: (record: T, index: number) => ReactNode;
|
render: (record: T, index: number) => ReactNode;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TextareaHTMLAttributes } from 'react';
|
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||||
|
|
||||||
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -6,7 +6,7 @@ type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Textarea({ className = '', label, hint, error, id, ...props }: TextareaProps) {
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className = '', label, hint, error, id, ...props }, ref) {
|
||||||
const textareaId = id ?? props.name;
|
const textareaId = id ?? props.name;
|
||||||
const required = Boolean(props.required);
|
const required = Boolean(props.required);
|
||||||
|
|
||||||
@@ -21,10 +21,11 @@ export function Textarea({ className = '', label, hint, error, id, ...props }: T
|
|||||||
<textarea
|
<textarea
|
||||||
className={['ui-textarea', error ? 'ui-textarea--error' : ''].filter(Boolean).join(' ')}
|
className={['ui-textarea', error ? 'ui-textarea--error' : ''].filter(Boolean).join(' ')}
|
||||||
id={textareaId}
|
id={textareaId}
|
||||||
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error ? <span className="ui-field__error">{error}</span> : null}
|
{error ? <span className="ui-field__error">{error}</span> : null}
|
||||||
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
|
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -349,12 +349,12 @@ export function AppShell({
|
|||||||
<div className="notice-popover" role="menu">
|
<div className="notice-popover" role="menu">
|
||||||
<div className="notice-popover__header">
|
<div className="notice-popover__header">
|
||||||
<strong>待审核任务</strong>
|
<strong>待审核任务</strong>
|
||||||
<span>{auditTotal} 条</span>
|
<span className={auditTotal === 0 ? 'is-zero' : ''}>{auditTotal} 条</span>
|
||||||
</div>
|
</div>
|
||||||
{auditNotifications.length ? auditNotifications.map((item) => (
|
{auditNotifications.length ? auditNotifications.map((item) => (
|
||||||
<NavLink key={item.to} onClick={() => setNoticeOpen(false)} role="menuitem" to={item.to}>
|
<NavLink key={item.to} onClick={() => setNoticeOpen(false)} role="menuitem" to={item.to}>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<strong>{item.count}</strong>
|
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)) : <p>暂无待审核任务</p>}
|
)) : <p>暂无待审核任务</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+128
-1
@@ -577,6 +577,17 @@ h3 {
|
|||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notice-popover__header span.is-zero,
|
||||||
|
.notice-popover a strong.is-zero {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-popover__header span.is-zero {
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.notice-popover a,
|
.notice-popover a,
|
||||||
.notice-popover p {
|
.notice-popover p {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2788,7 +2799,7 @@ h3 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(128px, 1fr) minmax(196px, auto);
|
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(196px, auto);
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7791,6 +7802,14 @@ h3 {
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-record-page .admin-task-filter {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-record-page .admin-task-filter__actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-task-table-card {
|
.admin-task-table-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -8579,6 +8598,73 @@ h3 {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card > header {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: minmax(180px, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card > header time {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card .admin-sms-record-content {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-width: none;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card__meta > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card__meta span,
|
||||||
|
.admin-sms-record-card__meta small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card__meta strong {
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card > footer {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sms-record-table {
|
.admin-sms-record-table {
|
||||||
min-width: 1180px;
|
min-width: 1180px;
|
||||||
}
|
}
|
||||||
@@ -8738,6 +8824,47 @@ h3 {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-failure {
|
||||||
|
align-items: flex-start;
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 28%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-danger);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-failure div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-failure span {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-failure strong {
|
||||||
|
color: var(--color-danger);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.admin-sms-record-card > header,
|
||||||
|
.admin-sms-record-card__meta,
|
||||||
|
.admin-sms-detail-overview,
|
||||||
|
.admin-sms-detail-status-grid,
|
||||||
|
.admin-sms-route-list dl {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-card > header time {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sms-route-list {
|
.admin-sms-route-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user