fix: harden admin and CMPP delivery workflows

This commit is contained in:
hectorzhao
2026-07-15 11:21:02 +08:00
parent 00b6d95752
commit e47432bc9d
34 changed files with 878 additions and 226 deletions
@@ -0,0 +1,3 @@
ALTER TABLE "CmppDownstreamDelivery"
ADD COLUMN "manualRetryCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "lastRetriedAt" TIMESTAMP(3);
@@ -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';
+2
View File
@@ -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([
+18 -2
View File
@@ -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,
}); });
} }
+44 -2
View File
@@ -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 () => {
+69 -12
View File
@@ -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 () => {
+135 -57
View File
@@ -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);
+20 -2
View File
@@ -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);
+22 -38
View File
@@ -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) {
+18 -4
View File
@@ -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 重启就把未投递记录误标成失败。
+38 -7
View File
@@ -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 持久化
+32
View File
@@ -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 已应用连接清理 migration43 条 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 审核通过后恢复为 queuedBullMQ 已生成 job,但一直停留在 `bull:sms.send.queue:prioritized`,无通道、`submitId``SmsSubmitRecord` - 生产号码 `18821203795` 的最新短信于 18:24:59 审核通过后恢复为 queuedBullMQ 已生成 job,但一直停留在 `bull:sms.send.queue:prioritized`,无通道、`submitId``SmsSubmitRecord`
+7 -2
View File
@@ -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);
+1 -4
View 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 ?? '无'}
+25 -1
View File
@@ -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">
+23 -1
View File
@@ -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>
); );
} }
+3 -5
View File
@@ -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="接口密码"
+15 -4
View File
@@ -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
+20 -42
View File
@@ -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}
+1 -1
View File
@@ -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 }}>
+12 -3
View File
@@ -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 ? '收起变量面板' : '插入变量'}
+1 -1
View File
@@ -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;
+4 -3
View File
@@ -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>
); );
} });
+2 -2
View File
@@ -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
View File
@@ -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;
} }