feat: add report material workflows and gateway safeguards
This commit is contained in:
Generated
+921
-9
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"bullmq": "^5.79.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"minio": "^8.0.7",
|
||||
"pg": "^8.22.0",
|
||||
@@ -41,5 +42,13 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13"
|
||||
},
|
||||
"overrides": {
|
||||
"exceljs": {
|
||||
"uuid": "11.1.1"
|
||||
},
|
||||
"@prisma/dev": {
|
||||
"@hono/node-server": "1.19.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
ALTER TABLE "SmsSignature"
|
||||
ADD COLUMN "materialVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "pendingReport" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "reportChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
ALTER TABLE "SmsDrainageInfo"
|
||||
ADD COLUMN "materialVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "pendingReport" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "reportChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
ALTER TABLE "ChannelReportField"
|
||||
ADD COLUMN "exportName" TEXT,
|
||||
ADD COLUMN "columnWidth" INTEGER NOT NULL DEFAULT 18,
|
||||
ADD COLUMN "imageWidth" INTEGER NOT NULL DEFAULT 120,
|
||||
ADD COLUMN "imageHeight" INTEGER NOT NULL DEFAULT 80,
|
||||
ADD COLUMN "defaultValue" TEXT,
|
||||
ADD COLUMN "transform" TEXT;
|
||||
|
||||
ALTER TABLE "ReportExportFile"
|
||||
ALTER COLUMN "taskId" DROP NOT NULL,
|
||||
ADD COLUMN "batchId" TEXT,
|
||||
ADD COLUMN "channelId" TEXT;
|
||||
|
||||
CREATE TABLE "ReportMaterialBatch" (
|
||||
"id" TEXT NOT NULL,
|
||||
"batchNo" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'generating',
|
||||
"createdById" TEXT,
|
||||
"selectedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"channelCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"fileCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"errorMessage" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
CONSTRAINT "ReportMaterialBatch_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ReportMaterialBatchItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"batchId" TEXT NOT NULL,
|
||||
"signatureId" TEXT NOT NULL,
|
||||
"drainageItemId" TEXT,
|
||||
"reportType" TEXT NOT NULL,
|
||||
"materialVersion" INTEGER NOT NULL,
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ReportMaterialBatchItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ReportExportFileItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"exportFileId" TEXT NOT NULL,
|
||||
"batchItemId" TEXT NOT NULL,
|
||||
"taskId" TEXT NOT NULL,
|
||||
"rowNumber" INTEGER NOT NULL,
|
||||
CONSTRAINT "ReportExportFileItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ReportMaterialImportProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"reportType" TEXT NOT NULL,
|
||||
"tenantId" TEXT,
|
||||
"applicationId" TEXT,
|
||||
"sheetName" TEXT,
|
||||
"headerRowCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"dataStartRow" INTEGER NOT NULL DEFAULT 2,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "ReportMaterialImportProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ReportMaterialImportProfileColumn" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"sourceHeader" TEXT NOT NULL,
|
||||
"sourceHeaderPath" TEXT,
|
||||
"sourceColumnIndex" INTEGER NOT NULL,
|
||||
"targetFieldCode" TEXT NOT NULL,
|
||||
"targetKind" TEXT NOT NULL DEFAULT 'dynamic',
|
||||
"fieldType" TEXT NOT NULL DEFAULT 'string',
|
||||
"required" BOOLEAN NOT NULL DEFAULT false,
|
||||
"transform" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 100,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ReportMaterialImportProfileColumn_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ReportMaterialImportBatch" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT,
|
||||
"profileId" TEXT,
|
||||
"fileObjectId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"reportType" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'analyzed',
|
||||
"sheetName" TEXT NOT NULL,
|
||||
"headerRowCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"dataStartRow" INTEGER NOT NULL DEFAULT 2,
|
||||
"mapping" JSONB NOT NULL,
|
||||
"preview" JSONB,
|
||||
"result" JSONB,
|
||||
"rowCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"successCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"failedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
CONSTRAINT "ReportMaterialImportBatch_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ReportMaterialBatch_batchNo_key" ON "ReportMaterialBatch"("batchNo");
|
||||
CREATE INDEX "ReportMaterialBatch_status_createdAt_idx" ON "ReportMaterialBatch"("status", "createdAt");
|
||||
CREATE INDEX "ReportMaterialBatchItem_batchId_reportType_idx" ON "ReportMaterialBatchItem"("batchId", "reportType");
|
||||
CREATE INDEX "ReportMaterialBatchItem_signatureId_drainageItemId_idx" ON "ReportMaterialBatchItem"("signatureId", "drainageItemId");
|
||||
CREATE UNIQUE INDEX "ReportExportFileItem_exportFileId_batchItemId_key" ON "ReportExportFileItem"("exportFileId", "batchItemId");
|
||||
CREATE INDEX "ReportExportFileItem_taskId_idx" ON "ReportExportFileItem"("taskId");
|
||||
CREATE INDEX "ReportExportFile_batchId_channelId_idx" ON "ReportExportFile"("batchId", "channelId");
|
||||
CREATE INDEX "ReportMaterialImportProfile_reportType_status_idx" ON "ReportMaterialImportProfile"("reportType", "status");
|
||||
CREATE INDEX "ReportMaterialImportProfile_tenantId_applicationId_idx" ON "ReportMaterialImportProfile"("tenantId", "applicationId");
|
||||
CREATE UNIQUE INDEX "ReportMaterialImportProfileColumn_profileId_sourceColumnIndex_key" ON "ReportMaterialImportProfileColumn"("profileId", "sourceColumnIndex");
|
||||
CREATE INDEX "ReportMaterialImportProfileColumn_profileId_sortOrder_idx" ON "ReportMaterialImportProfileColumn"("profileId", "sortOrder");
|
||||
CREATE INDEX "ReportMaterialImportBatch_tenantId_createdAt_idx" ON "ReportMaterialImportBatch"("tenantId", "createdAt");
|
||||
CREATE INDEX "ReportMaterialImportBatch_status_createdAt_idx" ON "ReportMaterialImportBatch"("status", "createdAt");
|
||||
|
||||
ALTER TABLE "ReportExportFile" ADD CONSTRAINT "ReportExportFile_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "ReportMaterialBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ReportMaterialBatchItem" ADD CONSTRAINT "ReportMaterialBatchItem_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "ReportMaterialBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ReportMaterialBatchItem" ADD CONSTRAINT "ReportMaterialBatchItem_signatureId_fkey" FOREIGN KEY ("signatureId") REFERENCES "SmsSignature"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "ReportMaterialBatchItem" ADD CONSTRAINT "ReportMaterialBatchItem_drainageItemId_fkey" FOREIGN KEY ("drainageItemId") REFERENCES "SmsDrainageInfo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ReportExportFileItem" ADD CONSTRAINT "ReportExportFileItem_exportFileId_fkey" FOREIGN KEY ("exportFileId") REFERENCES "ReportExportFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ReportExportFileItem" ADD CONSTRAINT "ReportExportFileItem_batchItemId_fkey" FOREIGN KEY ("batchItemId") REFERENCES "ReportMaterialBatchItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ReportExportFileItem" ADD CONSTRAINT "ReportExportFileItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "ChannelSignatureReportTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ReportMaterialImportProfileColumn" ADD CONSTRAINT "ReportMaterialImportProfileColumn_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "ReportMaterialImportProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
DROP INDEX IF EXISTS "ChannelReportField_channelId_code_key";
|
||||
|
||||
INSERT INTO "ChannelReportField" (
|
||||
"id", "channelId", "drainageFieldId", "reportType", "code", "name", "exportName", "fieldType",
|
||||
"required", "description", "sortOrder", "columnWidth", "imageWidth", "imageHeight", "defaultValue",
|
||||
"transform", "status", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
"id" || '_drainage', "channelId", "drainageFieldId", 'drainage', "code", "name", "exportName", "fieldType",
|
||||
"required", "description", "sortOrder", "columnWidth", "imageWidth", "imageHeight", "defaultValue",
|
||||
"transform", "status", "createdAt", "updatedAt"
|
||||
FROM "ChannelReportField"
|
||||
WHERE "reportType" = 'both';
|
||||
|
||||
UPDATE "ChannelReportField" SET "reportType" = 'signature' WHERE "reportType" = 'both';
|
||||
|
||||
CREATE UNIQUE INDEX "ChannelReportField_channelId_code_reportType_key"
|
||||
ON "ChannelReportField"("channelId", "code", "reportType");
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- Existing rows predate the pending-material workflow and must not be treated as newly changed.
|
||||
-- New rows continue to use the schema default (true), while later edits explicitly set true.
|
||||
UPDATE "SmsSignature" SET "pendingReport" = false;
|
||||
UPDATE "SmsDrainageInfo" SET "pendingReport" = false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "SmsChannelGroupItem" DROP COLUMN "rateLimitPerSecond";
|
||||
+140
-4
@@ -417,6 +417,9 @@ model SmsSignature {
|
||||
auditStatus String @default("draft")
|
||||
reportStatus String @default("waiting_material")
|
||||
rejectReason String?
|
||||
materialVersion Int @default(1)
|
||||
pendingReport Boolean @default(true)
|
||||
reportChangedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -429,6 +432,7 @@ model SmsSignature {
|
||||
drainageItems SmsDrainageInfo[]
|
||||
reportTasks ChannelSignatureReportTask[]
|
||||
messageRecords SmsMessageRecord[]
|
||||
reportBatchItems ReportMaterialBatchItem[]
|
||||
|
||||
@@index([tenantId, auditStatus])
|
||||
@@index([tenantId, reportStatus])
|
||||
@@ -445,6 +449,9 @@ model SmsDrainageInfo {
|
||||
reportValues Json?
|
||||
auditStatus String @default("pending")
|
||||
rejectReason String?
|
||||
materialVersion Int @default(1)
|
||||
pendingReport Boolean @default(true)
|
||||
reportChangedAt DateTime @default(now())
|
||||
submittedAt DateTime @default(now())
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -455,6 +462,7 @@ model SmsDrainageInfo {
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||
reportTasks ChannelSignatureReportTask[]
|
||||
messageRecords SmsMessageRecord[]
|
||||
reportBatchItems ReportMaterialBatchItem[]
|
||||
|
||||
@@index([tenantId, auditStatus, updatedAt])
|
||||
@@index([signatureId, auditStatus])
|
||||
@@ -651,7 +659,6 @@ model SmsChannelGroupItem {
|
||||
priority Int @default(100)
|
||||
weight Int @default(1)
|
||||
isBackup Boolean @default(false)
|
||||
rateLimitPerSecond Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
group SmsChannelGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
@@ -707,10 +714,16 @@ model ChannelReportField {
|
||||
reportType String @default("both")
|
||||
code String
|
||||
name String
|
||||
exportName String?
|
||||
fieldType String
|
||||
required Boolean @default(false)
|
||||
description String?
|
||||
sortOrder Int @default(100)
|
||||
columnWidth Int @default(18)
|
||||
imageWidth Int @default(120)
|
||||
imageHeight Int @default(80)
|
||||
defaultValue String?
|
||||
transform String?
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -718,7 +731,7 @@ model ChannelReportField {
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
drainageField DrainageField? @relation(fields: [drainageFieldId], references: [id])
|
||||
|
||||
@@unique([channelId, code])
|
||||
@@unique([channelId, code, reportType])
|
||||
@@index([drainageFieldId, reportType])
|
||||
}
|
||||
|
||||
@@ -791,6 +804,7 @@ model ChannelSignatureReportTask {
|
||||
records ChannelSignatureReportRecord[]
|
||||
exportFiles ReportExportFile[]
|
||||
receiptImports ReportReceiptImport[]
|
||||
exportItems ReportExportFileItem[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([signatureId, channelId])
|
||||
@@ -819,14 +833,136 @@ model ChannelSignatureReportRecord {
|
||||
|
||||
model ReportExportFile {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
taskId String?
|
||||
batchId String?
|
||||
channelId String?
|
||||
fileObjectId String?
|
||||
fileName String
|
||||
rowCount Int @default(0)
|
||||
status String @default("generated")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
task ChannelSignatureReportTask? @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
batch ReportMaterialBatch? @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
items ReportExportFileItem[]
|
||||
|
||||
@@index([batchId, channelId])
|
||||
}
|
||||
|
||||
model ReportMaterialBatch {
|
||||
id String @id @default(cuid())
|
||||
batchNo String @unique
|
||||
status String @default("generating")
|
||||
createdById String?
|
||||
selectedCount Int @default(0)
|
||||
channelCount Int @default(0)
|
||||
fileCount Int @default(0)
|
||||
errorMessage String?
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
items ReportMaterialBatchItem[]
|
||||
exportFiles ReportExportFile[]
|
||||
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model ReportMaterialBatchItem {
|
||||
id String @id @default(cuid())
|
||||
batchId String
|
||||
signatureId String
|
||||
drainageItemId String?
|
||||
reportType String
|
||||
materialVersion Int
|
||||
snapshot Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
batch ReportMaterialBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
signature SmsSignature @relation(fields: [signatureId], references: [id])
|
||||
drainageInfo SmsDrainageInfo? @relation(fields: [drainageItemId], references: [id])
|
||||
exportItems ReportExportFileItem[]
|
||||
|
||||
@@index([batchId, reportType])
|
||||
@@index([signatureId, drainageItemId])
|
||||
}
|
||||
|
||||
model ReportExportFileItem {
|
||||
id String @id @default(cuid())
|
||||
exportFileId String
|
||||
batchItemId String
|
||||
taskId String
|
||||
rowNumber Int
|
||||
|
||||
exportFile ReportExportFile @relation(fields: [exportFileId], references: [id], onDelete: Cascade)
|
||||
batchItem ReportMaterialBatchItem @relation(fields: [batchItemId], references: [id], onDelete: Cascade)
|
||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([exportFileId, batchItemId])
|
||||
@@index([taskId])
|
||||
}
|
||||
|
||||
model ReportMaterialImportProfile {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
reportType String
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
sheetName String?
|
||||
headerRowCount Int @default(1)
|
||||
dataStartRow Int @default(2)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
columns ReportMaterialImportProfileColumn[]
|
||||
|
||||
@@index([reportType, status])
|
||||
@@index([tenantId, applicationId])
|
||||
}
|
||||
|
||||
model ReportMaterialImportProfileColumn {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
sourceHeader String
|
||||
sourceHeaderPath String?
|
||||
sourceColumnIndex Int
|
||||
targetFieldCode String
|
||||
targetKind String @default("dynamic")
|
||||
fieldType String @default("string")
|
||||
required Boolean @default(false)
|
||||
transform String?
|
||||
sortOrder Int @default(100)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
profile ReportMaterialImportProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([profileId, sourceColumnIndex])
|
||||
@@index([profileId, sortOrder])
|
||||
}
|
||||
|
||||
model ReportMaterialImportBatch {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String?
|
||||
profileId String?
|
||||
fileObjectId String
|
||||
fileName String
|
||||
reportType String
|
||||
status String @default("analyzed")
|
||||
sheetName String
|
||||
headerRowCount Int @default(1)
|
||||
dataStartRow Int @default(2)
|
||||
mapping Json
|
||||
preview Json?
|
||||
result Json?
|
||||
rowCount Int @default(0)
|
||||
successCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model ReportReceiptImport {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { OperationsModule } from './operations/operations.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RiskReviewModule } from './risk-review/risk-review.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { ReportMaterialsModule } from './report-materials/report-materials.module';
|
||||
import { SendChainModule } from './send-chain/send-chain.module';
|
||||
import { SmsConfigModule } from './sms-config/sms-config.module';
|
||||
import { TenantsModule } from './tenants/tenants.module';
|
||||
@@ -38,6 +39,7 @@ import { UsersModule } from './users/users.module';
|
||||
ChannelsModule,
|
||||
RiskReviewModule,
|
||||
ReportsModule,
|
||||
ReportMaterialsModule,
|
||||
SendChainModule,
|
||||
OperationsModule,
|
||||
],
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CreateReportFieldDto,
|
||||
CreateReportMaterialDto,
|
||||
CreateReportTaskDto,
|
||||
ReplaceReportFieldsDto,
|
||||
ChangeReportTaskStatusesDto,
|
||||
CreateRouteRuleDto,
|
||||
TestChannelDto,
|
||||
@@ -147,6 +148,12 @@ export class ChannelsController {
|
||||
return this.channels.createReportField(body);
|
||||
}
|
||||
|
||||
@Put('channels/:channelId/report-fields/:reportType')
|
||||
@RequireRecentAuthentication()
|
||||
replaceReportFields(@Param('channelId') channelId: string, @Param('reportType') reportType: 'signature' | 'drainage', @Body() body: ReplaceReportFieldsDto) {
|
||||
return this.channels.replaceReportFields(channelId, reportType, body);
|
||||
}
|
||||
|
||||
@Get('signature-report-materials')
|
||||
listReportMaterials(@Query('signatureId') signatureId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.channels.listReportMaterials(signatureId, channelId);
|
||||
|
||||
@@ -96,6 +96,7 @@ function createPrismaMock() {
|
||||
},
|
||||
drainageField: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]),
|
||||
},
|
||||
signatureReportMaterial: {
|
||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||
@@ -178,6 +179,26 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces one report type while preserving legacy both fields for the opposite type', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const legacyBoth = { id: 'legacy-1', channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'both', code: 'license', name: '营业执照', exportName: '旧表头', fieldType: 'file', required: true, description: null, sortOrder: 10, columnWidth: 18, imageWidth: 120, imageHeight: 80, defaultValue: null, transform: null, status: 'active', createdAt: new Date(), updatedAt: new Date() };
|
||||
const tx = {
|
||||
channelReportField: {
|
||||
findMany: jest.fn().mockResolvedValueOnce([legacyBoth]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'signature-field' }]),
|
||||
deleteMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `created-${data.reportType}`, ...data })),
|
||||
},
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.replaceReportFields('channel-1', 'signature', { fields: [{ drainageFieldId: 'library-1', exportName: '新签名表头', required: true }] });
|
||||
|
||||
expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({ where: { channelId: 'channel-1', reportType: { in: ['signature', 'both'] } } });
|
||||
expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'drainage', exportName: '旧表头' }) });
|
||||
expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'signature', exportName: '新签名表头' }) });
|
||||
});
|
||||
|
||||
it('lists report tasks for one real channel', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
|
||||
@@ -460,6 +481,7 @@ describe('ChannelsService', () => {
|
||||
expect(prisma.smsChannelGroupItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东' }),
|
||||
});
|
||||
expect(prisma.smsChannelGroupItem.create.mock.calls[0][0].data).not.toHaveProperty('rateLimitPerSecond');
|
||||
|
||||
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' }))
|
||||
.rejects.toThrow('Channel group items must use the same carrier');
|
||||
@@ -545,6 +567,9 @@ describe('ChannelsService', () => {
|
||||
where: { id: 'group-1' },
|
||||
data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
|
||||
});
|
||||
for (const item of tx.smsChannelGroupItem.createMany.mock.calls[0][0].data) {
|
||||
expect(item).not.toHaveProperty('rateLimitPerSecond');
|
||||
}
|
||||
|
||||
await expect(service.updateGroup('group-1', {
|
||||
carrier: 'mobile',
|
||||
|
||||
@@ -47,7 +47,6 @@ export interface CreateChannelGroupItemDto {
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
isBackup?: boolean;
|
||||
rateLimitPerSecond?: number;
|
||||
}
|
||||
|
||||
export interface UpdateChannelGroupDto {
|
||||
@@ -83,9 +82,19 @@ export interface CreateReportFieldDto {
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
exportName?: string;
|
||||
columnWidth?: number;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
defaultValue?: string;
|
||||
transform?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ReplaceReportFieldsDto {
|
||||
fields: Array<Omit<CreateReportFieldDto, 'channelId' | 'reportType'>>;
|
||||
}
|
||||
|
||||
export interface CreateReportMaterialDto {
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
@@ -787,7 +796,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
priority: data.priority ?? 100,
|
||||
weight: data.weight ?? 1,
|
||||
isBackup: data.isBackup ?? false,
|
||||
rateLimitPerSecond: data.rateLimitPerSecond,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -834,7 +842,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
priority: item.priority ?? 100,
|
||||
weight: item.weight ?? 1,
|
||||
isBackup: item.isBackup ?? false,
|
||||
rateLimitPerSecond: item.rateLimitPerSecond,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -928,15 +935,69 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
exportName: data.exportName?.trim() || field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: data.required ?? field.required,
|
||||
description: data.description ?? field.description,
|
||||
sortOrder: data.sortOrder ?? 100,
|
||||
columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80),
|
||||
imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600),
|
||||
imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600),
|
||||
defaultValue: data.defaultValue,
|
||||
transform: data.transform,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) throw new NotFoundException('Channel not found');
|
||||
const ids = data.fields.map((field) => field.drainageFieldId);
|
||||
if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段');
|
||||
const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } });
|
||||
if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||
const fieldById = new Map(libraryFields.map((field) => [field.id, field]));
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const oppositeType = reportType === 'signature' ? 'drainage' : 'signature';
|
||||
const [legacyBoth, oppositeFields] = await Promise.all([
|
||||
tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }),
|
||||
tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }),
|
||||
]);
|
||||
const oppositeCodes = new Set(oppositeFields.map((field) => field.code));
|
||||
await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } });
|
||||
for (const legacy of legacyBoth) {
|
||||
if (oppositeCodes.has(legacy.code)) continue;
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
||||
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
||||
}
|
||||
for (const [index, configured] of data.fields.entries()) {
|
||||
const field = fieldById.get(configured.drainageFieldId)!;
|
||||
await tx.channelReportField.create({
|
||||
data: {
|
||||
channelId,
|
||||
drainageFieldId: field.id,
|
||||
reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
exportName: configured.exportName?.trim() || field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: configured.required ?? field.required,
|
||||
description: configured.description ?? field.description,
|
||||
sortOrder: configured.sortOrder ?? (index + 1) * 10,
|
||||
columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80),
|
||||
imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600),
|
||||
imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600),
|
||||
defaultValue: configured.defaultValue,
|
||||
transform: configured.transform,
|
||||
status: configured.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
});
|
||||
}
|
||||
|
||||
listReportMaterials(signatureId?: string, channelId?: string) {
|
||||
return this.prisma.signatureReportMaterial.findMany({
|
||||
where: {
|
||||
@@ -1652,6 +1713,11 @@ function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: numb
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeBusinessCarrier(carrier?: string | null) {
|
||||
const normalized = normalizeChannelCarrier(carrier);
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { SendChainService } from '../send-chain/send-chain.service';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@@ -145,8 +147,13 @@ export class AdminOperationsController {
|
||||
}
|
||||
|
||||
@Post('gateway-submit-dead-letters/:id/requeue')
|
||||
requeueGatewaySubmitDeadLetter(@Param('id') id: string) {
|
||||
return this.sendChain.requeueGatewaySubmitDeadLetter(id);
|
||||
@RequireRecentAuthentication()
|
||||
requeueGatewaySubmitDeadLetter(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { confirmedNotSubmitted?: boolean; reason?: string },
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
|
||||
}
|
||||
|
||||
@Get('downstream-deliveries')
|
||||
|
||||
@@ -77,11 +77,15 @@ function createPrismaMock() {
|
||||
status: 'pending',
|
||||
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
||||
failureMessage: 'network down',
|
||||
rawPayload: '{"upstream":{"passwordCipher":"secret"}}',
|
||||
commandPayload: { upstream: { account: 'sp', passwordCipher: 'secret' } },
|
||||
tenant: { name: '租户A' },
|
||||
application: { name: '应用A' },
|
||||
channel: { code: 'CMPP-A' },
|
||||
}]),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
|
||||
},
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
@@ -408,10 +412,22 @@ describe('OperationsService', () => {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})).resolves.toEqual({
|
||||
items: [expect.objectContaining({ id: 'dead-1', status: 'pending' })],
|
||||
items: [expect.objectContaining({
|
||||
id: 'dead-1',
|
||||
status: 'pending',
|
||||
rawPayloadAvailable: true,
|
||||
commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } },
|
||||
})],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
summary: {
|
||||
pending: 1,
|
||||
requeueing: 0,
|
||||
requeued: 0,
|
||||
resolved: 0,
|
||||
oldestPendingAt: new Date('2026-07-08T12:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.gatewaySubmitDeadLetter.findMany).toHaveBeenCalledWith({
|
||||
|
||||
@@ -372,11 +372,10 @@ export class OperationsService {
|
||||
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ streamMessageId: { contains: query.keyword } },
|
||||
{ traceId: { contains: query.keyword } },
|
||||
@@ -386,7 +385,11 @@ export class OperationsService {
|
||||
{ failureMessage: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
...baseWhere,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
};
|
||||
const [items, total, statusGroups, oldestPending] = await Promise.all([
|
||||
this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true, channel: true },
|
||||
@@ -395,8 +398,39 @@ export class OperationsService {
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.gatewaySubmitDeadLetter.count({ where }),
|
||||
this.prisma.gatewaySubmitDeadLetter.groupBy({
|
||||
by: ['status'],
|
||||
where: baseWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.gatewaySubmitDeadLetter.findFirst({
|
||||
where: { ...baseWhere, status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
|
||||
const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value));
|
||||
const messageStates = messageIds.length > 0
|
||||
? await this.prisma.smsMessageRecord.findMany({
|
||||
where: { messageId: { in: messageIds } },
|
||||
select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true },
|
||||
})
|
||||
: [];
|
||||
const messageStateById = new Map(messageStates.map((item) => [item.messageId, item]));
|
||||
return {
|
||||
items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
pending: statusCounts.get('pending') ?? 0,
|
||||
requeueing: statusCounts.get('requeueing') ?? 0,
|
||||
requeued: statusCounts.get('requeued') ?? 0,
|
||||
resolved: statusCounts.get('resolved') ?? 0,
|
||||
oldestPendingAt: oldestPending?.createdAt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listDownstreamDeliveries(query: DownstreamDeliveryQuery) {
|
||||
@@ -1119,3 +1153,47 @@ function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { t
|
||||
userAgent: log.userAgent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeGatewaySubmitException(
|
||||
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
|
||||
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
|
||||
) {
|
||||
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
|
||||
return {
|
||||
...record,
|
||||
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
|
||||
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
|
||||
channel: channel ? {
|
||||
id: channel.id,
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
status: channel.status,
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
} : null,
|
||||
rawPayloadAvailable: Boolean(rawPayload),
|
||||
commandPayload: redactGatewayCommandValue(commandPayload),
|
||||
messageState: messageState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactGatewayCommandValue(item));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const redacted: Record<string, Prisma.JsonValue | null> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
redacted[key] = [
|
||||
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
|
||||
'token', 'apikey', 'accesskey', 'secretkey',
|
||||
].includes(normalizedKey)
|
||||
? '[REDACTED]'
|
||||
: redactGatewayCommandValue(child as Prisma.JsonValue);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService } from './report-materials.service';
|
||||
|
||||
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
|
||||
|
||||
@ApiTags('report-materials')
|
||||
@Controller('admin/report-materials')
|
||||
export class ReportMaterialsController {
|
||||
constructor(private readonly service: ReportMaterialsService) {}
|
||||
|
||||
@Get('pending')
|
||||
listPending(@Query('reportType') reportType?: 'signature' | 'drainage', @Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string) {
|
||||
return this.service.listPending({ reportType, tenantId, applicationId });
|
||||
}
|
||||
|
||||
@Get('import-profiles')
|
||||
listImportProfiles(@Query('reportType') reportType?: 'signature' | 'drainage') {
|
||||
return this.service.listImportProfiles(reportType);
|
||||
}
|
||||
|
||||
@Post('import-profiles')
|
||||
@RequireRecentAuthentication()
|
||||
saveImportProfile(@Body() body: CreateImportProfileDto) {
|
||||
return this.service.saveImportProfile(body);
|
||||
}
|
||||
|
||||
@Post('imports/analyze')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
|
||||
analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record<string, string>) {
|
||||
if (!file) throw new BadRequestException('请选择 XLSX 文件');
|
||||
return this.service.analyzeImport(file, {
|
||||
tenantId: body.tenantId,
|
||||
applicationId: body.applicationId || undefined,
|
||||
reportType: body.reportType as 'signature' | 'drainage',
|
||||
sheetName: body.sheetName || undefined,
|
||||
headerRowCount: Number(body.headerRowCount || 1),
|
||||
dataStartRow: Number(body.dataStartRow || 2),
|
||||
profileId: body.profileId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Put('imports/:id/commit')
|
||||
@RequireRecentAuthentication()
|
||||
commitImport(@Param('id') id: string, @Body() body: ImportCommitDto) {
|
||||
return this.service.commitImport(id, body);
|
||||
}
|
||||
|
||||
@Get('batches')
|
||||
listBatches() {
|
||||
return this.service.listBatches();
|
||||
}
|
||||
|
||||
@Post('batches')
|
||||
@RequireRecentAuthentication()
|
||||
createBatch(@Body() body: CreateReportBatchDto) {
|
||||
return this.service.createBatch(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { SmsConfigModule } from '../sms-config/sms-config.module';
|
||||
import { ReportMaterialsController } from './report-materials.controller';
|
||||
import { ReportMaterialsService } from './report-materials.service';
|
||||
|
||||
@Module({
|
||||
imports: [FilesModule, SmsConfigModule],
|
||||
controllers: [ReportMaterialsController],
|
||||
providers: [ReportMaterialsService],
|
||||
exports: [ReportMaterialsService],
|
||||
})
|
||||
export class ReportMaterialsModule {}
|
||||
@@ -0,0 +1,103 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import { ReportMaterialsService } from './report-materials.service';
|
||||
|
||||
describe('ReportMaterialsService', () => {
|
||||
it('detects WPS-compatible embedded images and source columns during XLSX analysis', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('签名资料');
|
||||
sheet.addRow(['短信签名', '营业执照']);
|
||||
sheet.addRow(['测试签名', '']);
|
||||
const imageId = workbook.addImage({ base64: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', extension: 'png' });
|
||||
sheet.addImage(imageId, { tl: { col: 1, row: 1 }, ext: { width: 80, height: 60 } });
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const prisma = {
|
||||
reportMaterialImportProfile: { findUnique: jest.fn().mockResolvedValue({ sheetName: '签名资料', columns: [{ sourceHeader: '短信签名', sourceHeaderPath: '短信签名', sourceColumnIndex: 9, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true, sortOrder: 10 }] }) },
|
||||
reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-1', ...data })) },
|
||||
};
|
||||
const files = { upload: jest.fn().mockResolvedValue({ id: 'source-1', fileName: '签名资料.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
|
||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
||||
|
||||
const result = await service.analyzeImport({ originalname: '签名资料.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, { tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2, profileId: 'profile-1' });
|
||||
|
||||
expect(result.imageCount).toBe(1);
|
||||
expect(result.columns).toEqual(expect.arrayContaining([expect.objectContaining({ sourceHeader: '营业执照', imageCount: 1 })]));
|
||||
expect(result.rows).toEqual([expect.objectContaining({ rowNumber: 2, imageColumns: [2] })]);
|
||||
expect(result.suggestedMappings).toEqual([expect.objectContaining({ sourceColumnIndex: 1, targetKind: 'signatureName' })]);
|
||||
});
|
||||
|
||||
it('expands one selected signature to every routed channel and embeds images in each XLSX', async () => {
|
||||
const uploadedWorkbooks: Buffer[] = [];
|
||||
let batchItemSequence = 0;
|
||||
let exportSequence = 0;
|
||||
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
|
||||
const prisma = {
|
||||
reportMaterialBatch: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
|
||||
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
|
||||
},
|
||||
smsSignature: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用' } }),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: channels.map((channel, index) => ({ priority: index, channel })) } }]) },
|
||||
reportMaterialBatchItem: { create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
|
||||
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
|
||||
channelReportField: { findMany: jest.fn().mockResolvedValue([
|
||||
{ code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
|
||||
{ code: 'license', name: '营业执照', exportName: '营业执照图片', required: true, columnWidth: 24, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
|
||||
]) },
|
||||
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `task-${String(data.channelId)}`, ...data })), update: jest.fn() },
|
||||
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
|
||||
reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `export-${++exportSequence}`, ...data })) },
|
||||
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
};
|
||||
const files = {
|
||||
getDownload: jest.fn().mockResolvedValue({ fileObject: { fileName: 'license.png', contentType: 'image/png' }, content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', 'base64') }),
|
||||
upload: jest.fn().mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
|
||||
uploadedWorkbooks.push(file.buffer);
|
||||
return Promise.resolve({ id: `file-${uploadedWorkbooks.length}`, fileName: file.originalname, contentType: file.mimetype });
|
||||
}),
|
||||
};
|
||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
||||
|
||||
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-1' }] });
|
||||
|
||||
expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
|
||||
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'signature-1' }, data: { pendingReport: false } });
|
||||
expect(uploadedWorkbooks).toHaveLength(2);
|
||||
for (const buffer of uploadedWorkbooks) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as never);
|
||||
const sheet = workbook.getWorksheet('签名报备');
|
||||
expect(sheet?.getCell('A1').text).toBe('通道签名');
|
||||
expect(sheet?.getCell('A2').text).toBe('测试签名');
|
||||
expect(sheet?.getImages()).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => {
|
||||
const prisma = {
|
||||
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) },
|
||||
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用' } }), update: jest.fn() },
|
||||
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
|
||||
reportMaterialBatchItem: { create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
|
||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
|
||||
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) },
|
||||
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
|
||||
reportExportFile: { create: jest.fn().mockResolvedValue({ id: 'export-2' }) },
|
||||
reportExportFileItem: { createMany: jest.fn() },
|
||||
};
|
||||
const files = { upload: jest.fn().mockResolvedValue({ id: 'file-2', fileName: 'empty.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
|
||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
||||
|
||||
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-2' }] });
|
||||
|
||||
expect(result).toMatchObject({ status: 'partial_failed' });
|
||||
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'waiting_material', reason: '通道未配置当前资料类型的报备字段' }) });
|
||||
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
|
||||
export type ImportMapping = {
|
||||
sourceHeader: string;
|
||||
sourceHeaderPath?: string;
|
||||
sourceColumnIndex: number;
|
||||
targetFieldCode: string;
|
||||
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
|
||||
fieldType: 'string' | 'image' | 'file';
|
||||
required?: boolean;
|
||||
transform?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export interface CreateImportProfileDto {
|
||||
id?: string;
|
||||
name: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
sheetName?: string;
|
||||
headerRowCount?: number;
|
||||
dataStartRow?: number;
|
||||
status?: string;
|
||||
columns: ImportMapping[];
|
||||
}
|
||||
|
||||
export interface ImportCommitDto {
|
||||
mappings: ImportMapping[];
|
||||
profile?: CreateImportProfileDto;
|
||||
}
|
||||
|
||||
export interface CreateReportBatchDto {
|
||||
createdById?: string;
|
||||
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }>;
|
||||
}
|
||||
|
||||
type AnalyzeImportOptions = {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
sheetName?: string;
|
||||
headerRowCount: number;
|
||||
dataStartRow: number;
|
||||
profileId?: string;
|
||||
};
|
||||
|
||||
type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
||||
|
||||
@Injectable()
|
||||
export class ReportMaterialsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: FilesService,
|
||||
private readonly smsConfig: SmsConfigService,
|
||||
) {}
|
||||
|
||||
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }) {
|
||||
const [signatures, drainageInfos] = await Promise.all([
|
||||
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
|
||||
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
|
||||
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
|
||||
include: { tenant: true, application: true, signature: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return [
|
||||
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
|
||||
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
|
||||
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
|
||||
}
|
||||
|
||||
listImportProfiles(reportType?: 'signature' | 'drainage') {
|
||||
return this.prisma.reportMaterialImportProfile.findMany({
|
||||
where: { reportType, status: 'active' },
|
||||
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async saveImportProfile(data: CreateImportProfileDto) {
|
||||
validateProfile(data);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const profile = data.id
|
||||
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
|
||||
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
|
||||
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
|
||||
await tx.reportMaterialImportProfileColumn.createMany({
|
||||
data: data.columns.map((column, index) => ({
|
||||
profileId: profile.id,
|
||||
sourceHeader: column.sourceHeader,
|
||||
sourceHeaderPath: column.sourceHeaderPath,
|
||||
sourceColumnIndex: column.sourceColumnIndex,
|
||||
targetFieldCode: column.targetFieldCode,
|
||||
targetKind: column.targetKind,
|
||||
fieldType: column.fieldType,
|
||||
required: column.required ?? false,
|
||||
transform: column.transform,
|
||||
sortOrder: column.sortOrder ?? (index + 1) * 10,
|
||||
})),
|
||||
});
|
||||
return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } });
|
||||
});
|
||||
}
|
||||
|
||||
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
|
||||
if (!options.tenantId) throw new BadRequestException('tenantId is required');
|
||||
if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage');
|
||||
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||
const workbook = await loadWorkbook(file.buffer);
|
||||
const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null;
|
||||
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
|
||||
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
|
||||
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
|
||||
const headerRowCount = clamp(options.headerRowCount, 1, 5);
|
||||
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
const columnCount = Math.min(worksheet.columnCount, 200);
|
||||
const columns = Array.from({ length: columnCount }, (_, offset) => {
|
||||
const sourceColumnIndex = offset + 1;
|
||||
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean);
|
||||
const sourceHeaderPath = [...new Set(parts)].join('/');
|
||||
return {
|
||||
sourceColumnIndex,
|
||||
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
|
||||
sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`,
|
||||
sourceHeaderPath,
|
||||
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
|
||||
};
|
||||
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
|
||||
const previewRows = [];
|
||||
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
|
||||
const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))]));
|
||||
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
|
||||
if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns });
|
||||
}
|
||||
const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file);
|
||||
const profileMappings = profile?.columns.map((column) => ({
|
||||
sourceHeader: column.sourceHeader,
|
||||
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
|
||||
sourceColumnIndex: column.sourceColumnIndex,
|
||||
targetFieldCode: column.targetFieldCode,
|
||||
targetKind: column.targetKind as ImportMapping['targetKind'],
|
||||
fieldType: column.fieldType as ImportMapping['fieldType'],
|
||||
required: column.required,
|
||||
transform: column.transform ?? undefined,
|
||||
sortOrder: column.sortOrder,
|
||||
}));
|
||||
const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType);
|
||||
const batch = await this.prisma.reportMaterialImportBatch.create({
|
||||
data: {
|
||||
tenantId: options.tenantId,
|
||||
applicationId: options.applicationId,
|
||||
profileId: options.profileId,
|
||||
fileObjectId: sourceFile.id,
|
||||
fileName: sourceFile.fileName,
|
||||
reportType: options.reportType,
|
||||
sheetName: worksheet.name,
|
||||
headerRowCount,
|
||||
dataStartRow,
|
||||
mapping: suggestedMappings as Prisma.InputJsonValue,
|
||||
preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue,
|
||||
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
||||
},
|
||||
});
|
||||
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
|
||||
}
|
||||
|
||||
async commitImport(batchId: string, data: ImportCommitDto) {
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new NotFoundException('导入批次不存在');
|
||||
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
|
||||
if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
|
||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||
const workbook = await loadWorkbook(content);
|
||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
||||
let successCount = 0;
|
||||
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
||||
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
|
||||
try {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const mapping of data.mappings) {
|
||||
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
|
||||
if (image && mapping.fieldType !== 'string') {
|
||||
const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, {
|
||||
originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`,
|
||||
mimetype: imageContentType(image.extension),
|
||||
size: image.buffer.length,
|
||||
buffer: image.buffer,
|
||||
});
|
||||
values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType };
|
||||
} else {
|
||||
values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform);
|
||||
}
|
||||
}
|
||||
if (!Object.values(values).some(hasValue)) continue;
|
||||
for (const mapping of data.mappings.filter((item) => item.required)) {
|
||||
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
|
||||
}
|
||||
if (batch.reportType === 'signature') await this.importSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
||||
else await this.importDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
failures.push({ rowNumber, reason: error instanceof Error ? error.message : '导入失败' });
|
||||
}
|
||||
}
|
||||
return this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: failures.length ? (successCount ? 'partial_failed' : 'failed') : 'completed',
|
||||
mapping: data.mappings as Prisma.InputJsonValue,
|
||||
result: { failures } as Prisma.InputJsonValue,
|
||||
successCount,
|
||||
failedCount: failures.length,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listBatches() {
|
||||
return this.prisma.reportMaterialBatch.findMany({
|
||||
include: { exportFiles: true, items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async createBatch(data: CreateReportBatchDto) {
|
||||
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
|
||||
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
|
||||
const batch = await this.prisma.reportMaterialBatch.create({
|
||||
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: uniqueItems.length },
|
||||
});
|
||||
try {
|
||||
const prepared = [];
|
||||
for (const selected of uniqueItems) prepared.push(await this.prepareBatchItem(batch.id, selected));
|
||||
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
|
||||
for (const item of prepared) {
|
||||
for (const channel of item.channels) {
|
||||
const current = channelMap.get(channel.id) ?? [];
|
||||
current.push({ ...item, channels: [channel] });
|
||||
channelMap.set(channel.id, current);
|
||||
}
|
||||
}
|
||||
const exportedFiles = [];
|
||||
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
|
||||
for (const [channelId, items] of channelMap) {
|
||||
const result = await this.exportChannelBatch(batch.id, channelId, items);
|
||||
exportedFiles.push(result.file);
|
||||
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
|
||||
}
|
||||
for (const item of prepared) {
|
||||
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue;
|
||||
if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } });
|
||||
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } });
|
||||
}
|
||||
return this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
|
||||
} catch (error) {
|
||||
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const name = mappedCoreValue(mappings, values, 'signatureName');
|
||||
if (!name) throw new Error('缺少短信签名');
|
||||
const purpose = mappedCoreValue(mappings, values, 'purpose');
|
||||
const signatureReportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } });
|
||||
if (existing) return this.smsConfig.updateSignature(existing.id, { applicationId, name, purpose, drainageInfo: { ...jsonRecord(existing.drainageInfo), signatureReportValues } });
|
||||
return this.smsConfig.createSignature({ tenantId, applicationId, name, purpose, drainageInfo: { signatureReportValues } }, { initialAuditStatus: 'approved' });
|
||||
}
|
||||
|
||||
private async importDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
|
||||
const siteName = mappedCoreValue(mappings, values, 'siteName');
|
||||
const url = mappedCoreValue(mappings, values, 'url');
|
||||
if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL');
|
||||
const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } });
|
||||
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
|
||||
const remark = mappedCoreValue(mappings, values, 'remark');
|
||||
const reportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } });
|
||||
if (existing) return this.smsConfig.updateDrainageInfo(existing.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
|
||||
return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
|
||||
}
|
||||
|
||||
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number]) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
|
||||
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
|
||||
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
|
||||
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
|
||||
if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过');
|
||||
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
}) : [];
|
||||
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active').map((channel) => [channel.id, channel])).values()];
|
||||
const snapshot = selected.reportType === 'signature'
|
||||
? { reportType: 'signature', signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
|
||||
: { reportType: 'drainage', signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
|
||||
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion;
|
||||
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } });
|
||||
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels };
|
||||
}
|
||||
|
||||
private async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportMaterialsService['prepareBatchItem']>>>) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) throw new NotFoundException('通道不存在');
|
||||
const reportTypes = [...new Set(items.map((item) => item.reportType))];
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
|
||||
const incompleteBatchItemIds: string[] = [];
|
||||
let totalRows = 0;
|
||||
for (const reportType of reportTypes) {
|
||||
const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
sheet.properties.defaultRowHeight = 22;
|
||||
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth }));
|
||||
styleHeader(sheet.getRow(1));
|
||||
for (const item of items.filter((current) => current.reportType === reportType)) {
|
||||
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
|
||||
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
||||
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
|
||||
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
|
||||
const task = existingTask
|
||||
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
|
||||
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
|
||||
if (missingReason) {
|
||||
incompleteBatchItemIds.push(item.batchItem.id);
|
||||
await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined);
|
||||
continue;
|
||||
}
|
||||
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
|
||||
totalRows += 1;
|
||||
let targetHeight = 22;
|
||||
for (const [index, value] of values.entries()) {
|
||||
if (!isFileRef(value)) continue;
|
||||
const downloaded = await this.files.getDownload(value.fileObjectId);
|
||||
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
|
||||
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]);
|
||||
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
|
||||
const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' });
|
||||
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
|
||||
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
|
||||
sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never);
|
||||
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
|
||||
}
|
||||
row.height = targetHeight;
|
||||
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
|
||||
await this.recordTask(task.id, channelId, existingTask?.status, 'exporting');
|
||||
}
|
||||
}
|
||||
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
|
||||
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
|
||||
const empty = workbook.addWorksheet('无可导出数据');
|
||||
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
|
||||
empty.getColumn(1).width = 64;
|
||||
}
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
|
||||
const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer });
|
||||
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } });
|
||||
if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) });
|
||||
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
|
||||
}
|
||||
|
||||
private recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) {
|
||||
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } });
|
||||
}
|
||||
}
|
||||
|
||||
function profileData(data: CreateImportProfileDto) {
|
||||
return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' };
|
||||
}
|
||||
|
||||
function validateProfile(data: CreateImportProfileDto) {
|
||||
if (!data.name?.trim()) throw new BadRequestException('映射模板名称不能为空');
|
||||
if (!data.columns?.length) throw new BadRequestException('映射模板至少包含一个字段');
|
||||
const indexes = data.columns.map((column) => column.sourceColumnIndex);
|
||||
if (new Set(indexes).size !== indexes.length) throw new BadRequestException('同一源列不能重复映射');
|
||||
}
|
||||
|
||||
async function loadWorkbook(buffer: Buffer) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as never);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] {
|
||||
const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages;
|
||||
if (!getImages) return [];
|
||||
return getImages.call(worksheet).flatMap((drawing) => {
|
||||
const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId);
|
||||
if (!image) return [];
|
||||
const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1;
|
||||
const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1;
|
||||
const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined);
|
||||
return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] {
|
||||
return columns.flatMap((column, index) => {
|
||||
const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`);
|
||||
const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized);
|
||||
if (!core && !column.imageCount) return [];
|
||||
return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }];
|
||||
});
|
||||
}
|
||||
|
||||
function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] {
|
||||
const used = new Set<number>();
|
||||
return profileColumns.flatMap((profileColumn) => {
|
||||
const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader);
|
||||
const header = normalizeHeader(profileColumn.sourceHeader);
|
||||
const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath)
|
||||
?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header);
|
||||
if (!source) return [];
|
||||
used.add(source.sourceColumnIndex);
|
||||
return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }];
|
||||
});
|
||||
}
|
||||
|
||||
function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
||||
if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
||||
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
||||
if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
||||
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true };
|
||||
if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true };
|
||||
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
|
||||
function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
|
||||
function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
|
||||
|
||||
function cellText(cell: ExcelJS.Cell) {
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value);
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim();
|
||||
if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim();
|
||||
if ('richText' in value) return value.richText.map((item) => item.text).join('').trim();
|
||||
if ('text' in value) return String(value.text).trim();
|
||||
return cell.text.trim();
|
||||
}
|
||||
|
||||
function transformValue(value: string, transform?: string) {
|
||||
if (!transform || transform === 'trim') return value.trim();
|
||||
if (transform === 'digits') return value.replace(/\D/g, '');
|
||||
if (transform === 'uppercase') return value.trim().toUpperCase();
|
||||
if (transform === 'lowercase') return value.trim().toLowerCase();
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function mappedCoreValue(mappings: ImportMapping[], values: Record<string, unknown>, kind: ImportMapping['targetKind']) {
|
||||
const mapping = mappings.find((item) => item.targetKind === kind);
|
||||
return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : '';
|
||||
}
|
||||
|
||||
function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]]));
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; }
|
||||
function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record<string, unknown>).fileObjectId === 'string'; }
|
||||
|
||||
function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) {
|
||||
const values = jsonRecord(snapshot.values);
|
||||
if (hasValue(values[code])) return values[code];
|
||||
const signature = jsonRecord(snapshot.signature);
|
||||
const drainage = jsonRecord(snapshot.drainage);
|
||||
const aliases: Record<string, unknown> = {
|
||||
signature_name: signature.name, sign_name: signature.name, signatureName: signature.name,
|
||||
purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName,
|
||||
application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark,
|
||||
};
|
||||
if (hasValue(aliases[code])) return aliases[code];
|
||||
const semantic = normalizeHeader(`${code}/${name ?? ''}`);
|
||||
if (/短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)) return signature.name;
|
||||
if (/签名用途|签名依据|purpose/.test(semantic)) return signature.purpose;
|
||||
if (/企业名称|公司名称|enterprisename|companyname/.test(semantic)) return signature.tenantName;
|
||||
if (/应用名称|applicationname|appname/.test(semantic)) return signature.applicationName;
|
||||
if (/站点名称|网站名称|sitename/.test(semantic)) return drainage.siteName;
|
||||
if (/引流地址|网址|链接|url/.test(semantic)) return drainage.url;
|
||||
if (/备注|说明|remark/.test(semantic)) return drainage.remark;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function applyExportTransform(value: unknown, transform?: string | null) {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return transformValue(text, transform ?? undefined);
|
||||
}
|
||||
|
||||
function styleHeader(row: ExcelJS.Row) {
|
||||
row.height = 28;
|
||||
row.eachCell((cell) => {
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } } };
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
|
||||
function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
|
||||
function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
|
||||
@@ -224,9 +224,12 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'dead-1',
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'channel-1',
|
||||
streamMessageId: '1710000000000-0',
|
||||
submitId: 'SUB-1',
|
||||
messageId: 'MSG-1',
|
||||
status: 'pending',
|
||||
manualRetryCount: 0,
|
||||
commandPayload: {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
@@ -1517,7 +1520,7 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records gateway submit dead letters and allows manual requeue', async () => {
|
||||
it('records gateway submit exceptions and safely allows manual requeue', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue('1710000001000-0');
|
||||
|
||||
@@ -1551,9 +1554,17 @@ describe('SendChainService', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await service.requeueGatewaySubmitDeadLetter('dead-1');
|
||||
await service.requeueGatewaySubmitDeadLetter('dead-1', {
|
||||
confirmedNotSubmitted: true,
|
||||
reason: '确认通道连接失败且运营商未收到该短信',
|
||||
operatorId: 'user-1',
|
||||
});
|
||||
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' }));
|
||||
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'dead-1', status: 'pending' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dead-1' },
|
||||
data: expect.objectContaining({
|
||||
@@ -1567,10 +1578,32 @@ describe('SendChainService', () => {
|
||||
action: 'gateway.submit_dead_letter_requeue',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: 'dead-1',
|
||||
userId: 'user-1',
|
||||
detail: expect.objectContaining({
|
||||
reason: '确认通道连接失败且运营商未收到该短信',
|
||||
confirmedNotSubmitted: true,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks submit exception requeue when the upstream result may already be accepted', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
messageId: 'MSG-1',
|
||||
status: 'submitted',
|
||||
submitStatus: 'accepted',
|
||||
receiptStatus: null,
|
||||
});
|
||||
|
||||
await expect(service.requeueGatewaySubmitDeadLetter('dead-1', {
|
||||
confirmedNotSubmitted: true,
|
||||
reason: '尝试重新发送这条短信',
|
||||
})).rejects.toThrow('为避免重复发送,禁止重新入队');
|
||||
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records gateway downstream recovery statuses', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
|
||||
@@ -158,6 +158,12 @@ export interface GatewaySubmitDeadLetterDto {
|
||||
deadLetteredAt?: string;
|
||||
}
|
||||
|
||||
export interface RequeueGatewaySubmitExceptionDto {
|
||||
confirmedNotSubmitted?: boolean;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamRecoveryStatusDto {
|
||||
account: string;
|
||||
gatewayInstanceId?: string;
|
||||
@@ -1244,15 +1250,69 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async requeueGatewaySubmitDeadLetter(id: string) {
|
||||
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
|
||||
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!deadLetter) {
|
||||
throw new NotFoundException('Gateway submit dead letter not found');
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (!deadLetter.commandPayload || typeof deadLetter.commandPayload !== 'object') {
|
||||
throw new BadRequestException('该死信缺少可重放的 SubmitCommand');
|
||||
if (deadLetter.status !== 'pending') {
|
||||
throw new BadRequestException('该提交异常当前状态不允许重新入队');
|
||||
}
|
||||
if (!data.confirmedNotSubmitted) {
|
||||
throw new BadRequestException('请确认运营商未接收该短信后再重新入队');
|
||||
}
|
||||
const reason = String(data.reason ?? '').trim();
|
||||
if (reason.length < 5 || reason.length > 500) {
|
||||
throw new BadRequestException('请填写5至500字的重新入队原因');
|
||||
}
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand');
|
||||
}
|
||||
if (deadLetter.manualRetryCount >= 3) {
|
||||
throw new BadRequestException('该提交异常已达到人工重新入队次数上限');
|
||||
}
|
||||
const message = deadLetter.messageId
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } })
|
||||
: null;
|
||||
if (message && (
|
||||
message.submitStatus === 'accepted'
|
||||
|| ['submitted', 'delivered', 'unknown'].includes(message.status)
|
||||
|| ['delivered', 'unknown'].includes(message.receiptStatus ?? '')
|
||||
)) {
|
||||
throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队');
|
||||
}
|
||||
const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim();
|
||||
if (!commandChannelId) {
|
||||
throw new BadRequestException('该提交异常缺少通道信息');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: commandChannelId },
|
||||
include: { connectionStates: true },
|
||||
});
|
||||
if (!channel || channel.status !== 'active') {
|
||||
throw new BadRequestException('原通道不存在或已停用,不能重新入队');
|
||||
}
|
||||
if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) {
|
||||
throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道');
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'pending' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
let retryStreamMessageId: string;
|
||||
try {
|
||||
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
|
||||
if (!publishedStreamMessageId) {
|
||||
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
|
||||
}
|
||||
retryStreamMessageId = publishedStreamMessageId;
|
||||
} catch (error) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.update({ where: { id }, data: { status: 'pending' } });
|
||||
throw error;
|
||||
}
|
||||
const retryStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
|
||||
const updated = await this.prisma.gatewaySubmitDeadLetter.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -1265,6 +1325,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
userId: data.operatorId,
|
||||
action: 'gateway.submit_dead_letter_requeue',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: updated.id,
|
||||
@@ -1273,6 +1334,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
retryStreamMessageId,
|
||||
submitId: updated.submitId,
|
||||
messageId: updated.messageId,
|
||||
reason,
|
||||
confirmedNotSubmitted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -853,6 +853,9 @@ export class SmsConfigService {
|
||||
purpose: data.purpose,
|
||||
auditStatus: data.auditStatus,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
reportChangedAt: new Date(),
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
@@ -935,6 +938,9 @@ export class SmsConfigService {
|
||||
rejectReason: null,
|
||||
submittedAt: new Date(),
|
||||
reviewedAt: auditStatus === 'approved' ? new Date() : null,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
reportChangedAt: new Date(),
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
|
||||
@@ -234,7 +234,8 @@
|
||||
7. Gateway 必须实现真实 CMPP Submit,包括短信内容编码、长短信拆分、RegisteredDelivery、serviceId、srcId、destTerminalId、msgFmt、feeType/feeCode 等字段映射。
|
||||
8. Gateway 必须消费 NestJS 投递的 `SubmitCommand` 队列或等价内部接口;提交成功、提交失败、超时均必须回传 `SubmitResult`,不得只停留在 API 侧入队。
|
||||
9. Gateway 必须按通道连接和窗口容量控制并发,处理窗口满、SMSC 慢响应、sequence 回绕、连接断开时的在途消息状态。
|
||||
10. Gateway 不承担业务审核、计费、签名报备、通道组路由、黑名单或敏感词判断;这些由 NestJS 完成,Gateway 只执行已授权通道提交与协议事件回传。
|
||||
10. Gateway 必须对每个物理通道执行 Redis 分布式 TPS 限速。`SmsChannel.rateLimitPerSecond` 是单通道上限,不是平台总上限;同一通道被多个通道组或多个 Gateway 实例使用时共享同一额度,不同通道独立计数。通道连接命令下发的配置值是最终上限,提交命令携带的值只能进一步降低、不能放大该上限。超流速消息必须继续保留在 Redis Stream pending 中等待可用时隙,不能因等待直接标记发送失败;Gateway 重启后仍可由 consumer group 恢复。worker 必须并发处理一个读取批次,让不同通道独立等待,不能因低 TPS 通道造成其他通道队头阻塞;单通道实际并发仍由 Redis 限速和 CMPP 窗口共同约束。
|
||||
11. Gateway 不承担业务审核、计费、签名报备、通道组路由、黑名单或敏感词判断;这些由 NestJS 完成,Gateway 只执行已授权通道提交与协议事件回传。
|
||||
|
||||
#### 4.8.2 下游客户 CMPP 接入能力
|
||||
|
||||
@@ -278,7 +279,8 @@
|
||||
- 已实现 SubmitCommand 在途恢复第一版:Go Gateway submit worker 在消费新消息前会对 Redis Stream consumer group 中空闲超过阈值的 pending 命令执行 `XAUTOCLAIM`,重新提交并按正常成功路径 ack,避免 Gateway 重启后命令永久滞留在 PEL。
|
||||
- 已实现上游连接断开时的 pending submit 状态补偿第一版:如果某条上游 CMPP 连接在收到 submit resp 前断开,Gateway 会立即唤醒该连接上等待中的 pending submit,请求返回 `timeout/CONNECTION_LOST`,由 NestJS 进入既有补发或释放冻结逻辑,不再只依赖固定超时。
|
||||
- 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。
|
||||
- 已实现 SubmitCommand 死信治理第一版:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表;运营端后端接口可分页查询死信,并支持人工将原始 `SubmitCommand` 重新写回 Redis Stream。
|
||||
- 已实现 Gateway 提交异常治理:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表(数据库表名和内部接口保留技术兼容名,页面统一称“Gateway提交异常”)。运营端 `/admin/gateway-submit-exceptions` 提供真实分页、筛选、汇总、脱敏详情和单条重新入队;原始 payload、密码、密钥不得返回浏览器。重新入队必须要求近期认证、填写原因、勾选“已确认上游未受理”,并校验短信尚未 accepted/submitted/delivered/unknown、通道 active 且 connected、人工次数小于 3;服务端以 pending 到 requeueing 的原子状态抢占防止重复点击,成功写回 Redis Stream 后记录操作人、原因、Stream ID 和时间。收到后续 SubmitResult 时必须将对应异常记录闭环为 resolved。
|
||||
- 已实现 Gateway 通道级 Redis 限速:NestJS 入队前保留业务层通道限速,Go Gateway 在真正调用上游 Submit 前再次按通道 ID 预约发送时隙;连接命令把权威 TPS 写入 Redis,提交按权威值与消息值的较小者执行。普通 Stream 消息在等待期间不 ACK、不转失败,多实例共同使用同一限速状态;worker 对同批消息并发调度,低 TPS 通道等待不阻塞其他通道。
|
||||
- 已实现客户侧下游投递重试第二版:客户系统负责断线后重连;平台在客户离线或投递失败时把 Deliver Receipt/上行 Deliver 保留在 `CmppDownstreamDelivery`,客户 bind 成功后立即拉取 pending,且 Gateway 会对当前在线账号周期补投;超过重试上限后转 `failed` 并写失败审计。
|
||||
- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。
|
||||
- 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。
|
||||
@@ -315,7 +317,7 @@
|
||||
- 客户侧 Deliver 重投当前已经具备账号级周期恢复、退避、Redis token 租约锁和控制面状态观测;恢复状态已同步到 NestJS 持久化审计表并进入运营端独立页面,且具备第一版失败分类分析和多 Gateway 抢占协调。
|
||||
- 普通上行匹配已覆盖 messageId、接入号、手机号时间窗口和共享接入号多候选人工认领;后续仍需补更复杂的批量认领、认领规则推荐和认领准确率指标。
|
||||
- 长短信分片当前已具备真实分片提交/回执/补偿审计,运营端短信详情可查看 `SmsMessageSegmentAudit`;后续仍需补“按单个分片自动重投”和分片级人工补偿操作。
|
||||
- 多连接窗口当前覆盖单进程内连接池和窗口满等待;在途恢复当前覆盖 Redis Stream pending claim、连接断开时的 pending submit 唤醒、receipt 驱动的保守唯一候选补偿、SubmitCommand 死信入库/人工重入队第一版,以及下游客户在线时的周期补投、Gateway 重启后的恢复候选扫描、账号级 Redis token 租约锁/退避/状态观测、恢复状态入库/运营端可视化;尚未实现连接级状态持久化、窗口指标回写、死信后台自动重试策略、长恢复任务锁续租,以及“上游已受理但 submit resp 丢失”场景的强确认或完整幂等补偿。
|
||||
- 多连接窗口当前覆盖单进程内连接池和窗口满等待;在途恢复当前覆盖 Redis Stream pending claim、连接断开时的 pending submit 唤醒、receipt 驱动的保守唯一候选补偿、Gateway 提交异常转存/安全人工重新入队,以及下游客户在线时的周期补投、Gateway 重启后的恢复候选扫描、账号级 Redis token 租约锁/退避/状态观测、恢复状态入库/运营端可视化;尚未实现连接级状态持久化、窗口指标回写、提交异常后台自动重试策略、长恢复任务锁续租,以及“上游已受理但 submit resp 丢失”场景的强确认或完整幂等补偿。
|
||||
|
||||
基于当前真实代码,CMPP 端到端链路剩余缺口可以明确收敛为以下几类:
|
||||
|
||||
@@ -698,6 +700,8 @@
|
||||
9. 按企业应用绑定的对应运营商通道组执行路由:通道组只能是移动、联通、电信之一,发送时必须同时满足路由规则运营商、通道组运营商、通道组明细 carrier 与号码识别运营商一致;再校验通道本体 carrier 为对应运营商或三网;最后先匹配省网通道,再匹配全国通道,不得直接绑定或 fallback 到非授权单通道。
|
||||
10. 过滤业务 disabled、连接离线、认证失败、心跳超时或无可用连接数的通道。
|
||||
11. 通过通道限速器控制 TPS;优先队列不得突破通道配置的供应商 TPS 和连接窗口上限。
|
||||
- TPS 按物理通道独立计数,不是整个平台共享一个总额度。例如通道 A、B 均配置 100 TPS 时,各自最多 100 TPS,平台理论合计为 200 TPS。
|
||||
- 同一物理通道被多个通道组使用时,共享 `SmsChannel.rateLimitPerSecond` 的同一额度;通道组成员不提供单独流速配置,避免多个组并发使用同一供应商账号时重复计算额度。
|
||||
12. 调用 Gateway Adapter 提交短信。
|
||||
13. 写入 submit 状态。
|
||||
14. Submit rejected、submit timeout、Gateway 连接断开或未提交成功、receipt failed 等场景按通道组策略补发到下一可用全国通道;unknown、超过 72 小时、超过通道组补发时间上限或关闭补发时不再补发。
|
||||
@@ -1473,3 +1477,12 @@
|
||||
|
||||
然后按计划逐步实现。每完成一步都要运行构建或测试,并更新文档。
|
||||
```
|
||||
|
||||
### 2026-07-15 签名与引流资料批量导入、通道映射及统一报备
|
||||
|
||||
1. 运营端在“报备任务”下提供“待报备资料”工作台。WPS 在线表格须先由用户另存为 `.xlsx`,系统读取真实工作簿、工作表、表头、单元格和内嵌图片;原始文件及拆出的图片写入 MinIO,导入批次、映射和业务资料写入 PostgreSQL,不支持用 CSV、前端静态数组或浏览器本地存储冒充图片导入。
|
||||
2. 导入分为“解析预览”和“确认入库”两步。用户可指定企业、企业应用、资料类型、表头行数、数据起始行并复用映射方案;每个源列可映射到签名名称、用途、所属签名、站点名称、URL、备注或报备字段库中的动态字段,同时配置文本/图片/文件、必填和转换规则。源文件字段名称和顺序不固定,映射方案必须可持久化复用。
|
||||
3. 导入和业务页面的新建/修改只将已审核签名或引流信息标记为待报备,并递增材料版本;不得在每次导入后自动创建通道报备任务。运营人员可跨签名、跨引流信息勾选资料,一次创建统一报备批次。
|
||||
4. 创建批次时按每条资料所属企业应用的当前生效路由规则展开所有通道;一个签名走多个通道时,必须为每个通道创建或重置独立报备任务并生成一份该通道的 `.xlsx`。无生效路由、通道未配置字段或缺少通道必填资料时,该资料继续保留在待报备池,任务进入“资料待补充”,不得伪装为已完成。
|
||||
5. 通道“配置签名报备字段”和“配置引流信息字段”弹窗使用字段池,按资料类型分别配置。每列包含标准字段、通道导出表头、列顺序、必填、说明、列宽、文本转换、缺省值以及图片宽高;导出表头和列顺序必须严格使用通道配置,不受导入表格原始名称和顺序影响。
|
||||
6. 通道导出文件必须为 WPS/Excel 可打开的 `.xlsx`,图片直接内嵌到对应单元格区域,而不是仅写 MinIO URL 或本地路径。批次保留所选材料版本快照、通道文件、行号和通道任务关联,可从最近批次直接下载每个通道文件。
|
||||
|
||||
@@ -64,6 +64,8 @@ PROD_ADMIN_PASSWORD='change-me'
|
||||
|
||||
`API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。
|
||||
|
||||
Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会写入 `rate:gateway:channel:config:<channelId>` 权威上限,实际预约使用 `rate:gateway:channel:<channelId>`;这些 key 不应在正常发布时清理。多 Gateway 实例必须指向同一 Redis,才能共享单通道额度。超速的 `gateway.submit.commands` 消息会保持在 consumer group pending 中等待,不应通过手工 `XACK` 或删除 Stream 处理积压;先检查通道配置、Redis key、consumer group 和 Gateway 日志。
|
||||
|
||||
日报任务默认启用,并由 `REPORT_REFRESH_INTERVAL_MS` 每小时检查一次北京时间业务日是否变化;每个业务日只执行一次 T-4 至 T-1 重算。服务重启后也会自动补跑最近四个完整自然日,确保 72 小时回执更新反映到对账和利润报表。
|
||||
|
||||
如生产验证服务器临时无法稳定下载 MinIO,可显式传入 `OBJECT_STORAGE_DRIVER=local`,文件会通过真实 API 保存到服务器本地目录 `OBJECT_STORAGE_LOCAL_ROOT`,`cmpp-minio` 服务会跳过安装和启动。该模式只建议用于验证环境;正式生产建议恢复 `OBJECT_STORAGE_DRIVER=minio`。
|
||||
@@ -103,6 +105,8 @@ curl http://127.0.0.1:12026/
|
||||
redis-cli -h 127.0.0.1 -p 6379 ping
|
||||
pg_isready -d "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)"
|
||||
grep -E '^(API_ENABLE_SEND_WORKER|API_SEND_WORKER_CONCURRENCY)=' /etc/cmpp-platform/cmpp-platform.env
|
||||
redis-cli --scan --pattern 'rate:gateway:channel:*'
|
||||
redis-cli XINFO GROUPS gateway.submit.commands
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
@@ -1306,21 +1306,21 @@
|
||||
- 如果存在多条候选或无候选,则拒绝归因,不得误绑到其他短信。
|
||||
- 已经由新通道成功送达的短信,旧尝试迟到回执仍只记历史,不覆盖最终送达状态。
|
||||
|
||||
### TC-GW-012 SubmitCommand 死信入库与人工重入队
|
||||
### TC-GW-012 Gateway 提交异常入库与人工重新入队
|
||||
|
||||
- 优先级:P0
|
||||
- 前置条件:Gateway submit worker 已连接 Redis Stream `gateway.submit.commands`;准备一条会持续触发处理错误的 `SubmitCommand`;NestJS `/gateway/events/dead-letter` 和运营端 `/api/admin/operations/gateway-submit-dead-letters` 真实可用。
|
||||
- 前置条件:Gateway submit worker 已连接 Redis Stream `gateway.submit.commands`;准备一条会持续触发处理错误的 `SubmitCommand`;NestJS 内部异常上报接口和运营端 Gateway 提交异常 API 真实可用。
|
||||
- 步骤:
|
||||
1. 让同一条 `SubmitCommand` 连续处理失败,达到 Gateway 配置的死信阈值。
|
||||
1. 让同一条 `SubmitCommand` 连续处理失败,达到 Gateway 配置的异常转存阈值。
|
||||
2. 检查 Redis PEL 中该消息是否被 ack,不再无限 pending。
|
||||
3. 检查 NestJS 是否在真实数据库写入一条 `GatewaySubmitDeadLetter`,保存失败原因、尝试次数和原始命令载荷。
|
||||
4. 调用运营端真实接口查询死信列表。
|
||||
5. 调用人工重入队接口,将该死信重新写回 `gateway.submit.commands`。
|
||||
4. 从运营端“Gateway提交异常”页面查询异常列表并打开脱敏详情。
|
||||
5. 完成风险确认、原因和状态校验后,将该异常命令重新写回 `gateway.submit.commands`。
|
||||
- 预期结果:
|
||||
- 达到阈值后,Gateway 会把该消息转为死信,而不是永久卡在 PEL。
|
||||
- 死信记录来自真实数据库,包含 `streamMessageId`、`messageId/submitId`、失败原因、尝试次数和原始 `SubmitCommand`。
|
||||
- 人工重入队成功后,死信状态更新为 `requeued`,记录新的 Redis Stream 消息 ID,并写系统日志。
|
||||
- 重入队后如后续收到真实 `SubmitResult`,对应死信记录应自动转为 `resolved`。
|
||||
- 达到阈值后,Gateway 会把该消息转存为提交异常,而不是永久卡在 PEL。
|
||||
- 异常记录来自真实数据库,包含 `streamMessageId`、`messageId/submitId`、失败原因和尝试次数;浏览器端只收到脱敏命令,不收到原始 payload 或密码密钥。
|
||||
- 人工重新入队成功后,异常状态更新为 `requeued`,记录新的 Redis Stream 消息 ID、操作人和原因,并写系统日志。
|
||||
- 重新入队后如后续收到真实 `SubmitResult`,对应异常记录应自动转为 `resolved`。
|
||||
|
||||
### TC-GW-013 下游客户在线时周期补投与失败封顶
|
||||
|
||||
@@ -3213,6 +3213,15 @@ npm run test:gateway
|
||||
npm run verify:phase8
|
||||
```
|
||||
|
||||
## 2026-07-15 通道 TPS 归属与通道组成员字段清理
|
||||
|
||||
| 用例编号 | 场景 | 操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| TC-CHANNEL-TPS-001 | 单通道限速 | 通道 A 配置 100 TPS,持续提交超过 100 条/秒 | PostgreSQL 保存 `SmsChannel.rateLimitPerSecond=100`;NestJS 以通道 A 的 ID 建立 Redis 限速桶;单个自然秒放行不超过 100 条。 |
|
||||
| TC-CHANNEL-TPS-002 | 多通道独立限速 | 通道 A、B 均配置 100 TPS,并发向两个通道提交 | A、B 使用不同通道 ID 的 Redis 限速桶,各自最多 100 TPS;平台不存在共享的 100 TPS 总额度。 |
|
||||
| TC-CHANNEL-TPS-003 | 多通道组共享同一物理通道 | 两个通道组同时选中通道 A 并产生发送 | 两组发送共享通道 A 的同一个 100 TPS 限速桶,合计不超过通道 A 配置;通道组成员接口和页面均无单独流速字段。 |
|
||||
| TC-CHANNEL-TPS-004 | 数据库结构清理 | 执行 Prisma migration 并读取 `SmsChannelGroupItem` 结构 | `SmsChannelGroupItem.rateLimitPerSecond` 已删除,通道自身的 `SmsChannel.rateLimitPerSecond` 保留。 |
|
||||
|
||||
测试环境必须具备 PostgreSQL、Redis、MinIO 或等价本地服务后,才能将 E2E smoke 和真实 API HTTP 测试记为系统功能通过;缺失时对应用例标记为阻塞或未执行。
|
||||
|
||||
## 17. 新增和更新用例细化执行清单
|
||||
@@ -3419,3 +3428,27 @@ npm run verify:phase8
|
||||
| TC-GW-ACK-005 | 客户 Submit 后由业务校验立即生成失败回执,并覆盖在线即时投递、Gateway 重启后恢复投递;另模拟客户端对 `Msg_Id=0` 返回 Result=0。 | 客户收到的第一个响应包必须是对应 `CMPP_SUBMIT_RESP`,之后 Deliver 的 `Msg_Id` 非 0 且与 SubmitResp 完全一致;重启后根据持久化 Submit Sequence_Id 重建同一 Msg_Id;Result=0/Msg_Id=0 不得写为 delivered。 |
|
||||
| TC-GW-ACK-006 | 对一条 Gateway 已丢失原消息映射且 payload 缺少 `submitSequenceId` 的历史状态回执执行人工重投;另对字段完整但客户离线的记录重投。 | 缺少序列号的记录由 Gateway 返回 `retryable=false/MISSING_SUBMIT_SEQUENCE_ID`,NestJS 立即终结为 `failed` 并保存明确原因;客户暂时离线的记录返回 `retryable=true/CLIENT_DISCONNECTED`,按次数上限和指数退避继续处理,不得无限保持 `retryCount=0`。 |
|
||||
| TC-GW-ACK-007 | 构造一条从未重投且 pending 超过 72 小时的记录,以及一条人工重投后尚未满 72 小时但原 `createdAt` 很早的记录,执行自动扫描并查看告警。 | 第一条自动转为 `failed/queue_timeout`;第二条仍保持 pending,终结时间和 10 分钟积压告警均从 `lastRetriedAt` 重新计算,刚重投后不立即告警。 |
|
||||
|
||||
### 17.12 签名与引流资料导入及统一通道报备
|
||||
|
||||
| 用例编号 | 操作 | 预期结果 |
|
||||
| --- | --- | --- |
|
||||
| TC-REPORT-MATERIAL-IMPORT-001 | 将含两行表头、文本列和营业执照/身份证等内嵌图片的 WPS 在线表格另存为 `.xlsx`,选择企业、应用和签名资料后解析。 | NestJS 读取真实工作表及图片锚点,返回列、组合表头、前十行和图片数预览;原文件写 MinIO,导入批次写 PostgreSQL;未确认前不改签名、不建通道任务。 |
|
||||
| TC-REPORT-MATERIAL-IMPORT-002 | 将源列分别映射到短信签名、签名用途和动态报备字段,调整数据类型/必填/转换规则,保存映射方案后确认导入;再用列顺序不同但表头相同的文件复用方案。 | 新签名或已存在签名的资料真实入库,内嵌图片拆出并写 MinIO 引用,材料版本递增且进入待报备池;映射方案持久化并可再次选择,源列顺序不影响目标字段。 |
|
||||
| TC-REPORT-MATERIAL-IMPORT-003 | 导入引流资料,将所属签名、站点、URL、备注和动态图片映射后确认;其中一行引用不存在或未审核签名。 | 合法行创建/更新真实 `SmsDrainageInfo` 并进入待报备池;非法行记录行号和原因,批次为部分失败,不因单行错误回滚其他合法行,也不自动创建报备任务。 |
|
||||
| TC-REPORT-CHANNEL-FIELD-001 | 在同一通道分别打开签名和引流字段配置,添加字段、修改通道表头、上下排序、设置必填/列宽/图片宽高后保存并刷新。 | 两类配置相互独立且完整持久化;刷新后字段池、映射表头和顺序一致;重复字段、停用字段和非法尺寸由 API 拒绝或归一化。 |
|
||||
| TC-REPORT-BATCH-001 | 一个应用配置两个生效通道,选择一个待报备签名创建统一批次。 | 系统从真实应用路由展开两个通道,生成两个独立通道任务和两个 `.xlsx`;每个文件表头名称、列顺序和列宽均来自对应通道配置,批次可下载两份文件。 |
|
||||
| TC-REPORT-BATCH-002 | 两个通道对同一标准字段配置不同表头和顺序,并包含图片列,生成批次后分别用 WPS 打开。 | 两份工作簿各自使用对应通道映射,图片直接显示在数据行内且尺寸按通道配置;文件不是 URL 清单,文本与图片属于同一材料快照。 |
|
||||
| TC-REPORT-BATCH-003 | 分别制造无生效路由、通道未配置字段、缺少通道必填图片,再创建批次。 | 对应资料不会清除待报备标记;有通道但资料不全时任务为 `waiting_material` 并记录原因;批次为部分失败,无任何假成功任务。 |
|
||||
| TC-REPORT-BATCH-004 | 同一签名修改资料后再次选择生成批次。 | 材料版本递增;复用同一签名/通道任务并重置到新一轮状态,批次项目保留当次版本和快照,历史导出文件仍可追溯。 |
|
||||
|
||||
### 17.13 Gateway 提交异常与通道级 TPS 限速
|
||||
|
||||
| 用例编号 | 操作 | 预期结果 |
|
||||
| --- | --- | --- |
|
||||
| TC-GW-SUBMIT-EXCEPTION-001 | 制造一条超过 Gateway 最大处理次数的真实 `SubmitCommand`,打开运营端“Gateway提交异常”,按状态、应用、通道和关键字筛选并查看详情。 | 记录写入 PostgreSQL,页面汇总、分页和详情来自 NestJS API;手机号脱敏,命令中的密码、密钥和原始 payload 不返回浏览器,页面不使用“死信”作为业务名称。 |
|
||||
| TC-GW-SUBMIT-EXCEPTION-002 | 对短信仍处于 pending/failed、通道 active 且 connected 的异常记录,输入 5~500 字原因,勾选“已确认上游未受理”并重新入队。 | 近期认证通过后服务端原子抢占记录、真实写入 Redis Stream;记录变为 requeued,人工次数、操作人、原因、Stream ID 和时间完整留痕,收到 SubmitResult 后变为 resolved。 |
|
||||
| TC-GW-SUBMIT-EXCEPTION-003 | 不勾选确认、原因过短、重复点击同一记录,或分别把短信置为 accepted/submitted/delivered/unknown、把通道置为停用/断开、人工重试达到 3 次后尝试重新入队。 | API 拒绝危险或重复操作,不产生额外 Stream 命令;页面显示可读原因,操作日志不伪造成功。 |
|
||||
| TC-GW-RATE-001 | 给通道 A 配置 10 TPS,连续投递 20 条;通道 B 同时配置 20 TPS 并投递,另让提交命令携带高于通道配置的数值。 | Gateway A 实际提交节奏不超过 10 TPS,B 独立按自身额度执行;消息值不能放大 A 的权威上限,同一通道跨通道组共享额度。 |
|
||||
| TC-GW-RATE-002 | 超过通道 TPS 后观察 Redis Stream consumer group,并在存在等待消息时重启 Gateway。 | 超流速消息保留在 Stream pending,不直接失败;重启后通过 PEL/XAUTOCLAIM 恢复并继续按通道 TPS 排队提交,不丢失、不重复 ACK。 |
|
||||
| TC-GW-RATE-003 | 启动两个共享同一 Redis 的 Gateway 消费实例,同时向同一通道发送,再向两个不同通道发送。 | 同一通道的两个实例共享 Redis 限速额度,总 TPS 不叠加;不同通道使用独立 key,不被合并成平台总 TPS。 |
|
||||
|
||||
@@ -1877,6 +1877,7 @@ git diff --check
|
||||
## 2026-07-14 服务端安全会话与自动锁定
|
||||
|
||||
- 将可预测的 `dev-token:userId:sessionVersion` 和 localStorage 访问令牌替换为 256 位随机会话标识;浏览器只通过 HttpOnly、SameSite Cookie 携带,Redis 使用会话标识 SHA-256 键保存真实状态。生产模式 Cookie 默认 `Secure`;本次按用户要求部署到现有 HTTP 生产验证环境时显式配置 `SESSION_COOKIE_SECURE=false`,正式生产切换 HTTPS 后必须恢复为 `true`。
|
||||
|
||||
- 运营端/客户端无操作阈值分别为 60/120 分钟,提前 5 分钟提醒;超时进入密码锁屏,4 小时内可用当前密码解锁并轮换会话标识,超过后完整登录。绝对会话时长 12 小时不可滑动续期;敏感操作最近密码认证窗口为 30 分钟。
|
||||
- NestJS 中间件对运营端和客户端受保护 API 强制要求 Redis 会话,逐次校验用户状态和 `sessionVersion`;Gateway 回调和 health 保持原内部链路,不被浏览器会话门禁拦截。自动轮询只有检测到近期真实浏览器操作时才携带活动标识,不能长期保活无人值守会话。
|
||||
- 用户/权限、企业状态、应用密钥、通道和路由、报备状态、手工充值/退款/调整已接后端最近认证 Guard。前端收到 `RECENT_AUTHENTICATION_REQUIRED` 后要求当前密码,成功后自动重试;普通 JSON、Blob 和文件上传统一处理会话 401。会话创建、锁定、解锁、再认证和退出写 `OperationLog`,多标签页同步状态。
|
||||
@@ -1914,3 +1915,25 @@ git diff --check
|
||||
- 工作区完整改动已提交并 push:`a7a4e8d9f6aba00b8137e5b70c59bdef67ed3b57`(`feat: polish reporting templates and shared controls`)。部署前确认本地 `main` 与 `origin/main` 一致,并备份生产 PostgreSQL、运行源码和环境配置至 `/opt/cmpp-platform/backups/releases/20260715-163418`;三份备份均通过 SHA-256 复核和压缩包完整性检查,其中数据库备份 SHA-256 为 `a59d3b7f4538f99cdc73a1092ac31628efa45bc38d4adf20a05d6a1f075ec5da`,运行源码备份为 `305c529fb94c71fd6e49f6228717b2ce93f14fff84a84e477d1f202f3192ef58`。
|
||||
- 发布快照本地与服务器 SHA-256 均为 `489d6c969252403689dfdcca15b87e4f5a93b65187551fa6650451527366942e`。生产 `.deployed-commit=a7a4e8d9f6aba00b8137e5b70c59bdef67ed3b57`,47 条 migration 全部齐全;`cmpp-api`、`cmpp-gateway`、MinIO、Nginx、PostgreSQL、Redis 均为 active,`12026/17890/8090/3000/9000` 正常监听,API/Gateway health、Redis PONG、PostgreSQL readiness 和外部首页/运营端/API HTTP 均通过,部署后 API/Gateway 无 error 级日志。
|
||||
- 生产浏览器确认登录页加载成功且标题为“聆界短信管理平台”。服务器凭据文件对存量管理员仅记录 `password=unchanged`,不是可用明文密码,因此未擅自重置生产密码;通用 Select 的企业搜索、企业与应用联动、弹窗越界和普通筛选区 Portal 交互已在部署前通过本地真实 NestJS API、PostgreSQL 数据和生产同构建验证,未使用 mock、localStorage 或静态数组。
|
||||
|
||||
## 2026-07-15 签名与引流资料批量导入及统一通道报备(未提交)
|
||||
|
||||
- 新增真实待报备资料工作台:WPS 表格另存 `.xlsx` 后由 NestJS + ExcelJS 解析多行表头、文本和内嵌图片,原文件及图片走 MinIO,导入批次、可复用映射方案、材料版本和待报备状态走 Prisma/PostgreSQL;导入只更新资料池,不自动生成通道任务。
|
||||
- 新增统一报备批次:运营勾选新建/修改的签名与引流信息后,按企业应用当前生效路由展开全部通道,每通道生成一份内嵌图片的 `.xlsx`,并关联批次材料版本快照、文件行号和真实通道报备任务。无路由、未配置通道字段或缺必填资料不会清除待报备标记。
|
||||
- 通道签名/引流字段配置按设计基线恢复为字段池和已选字段双栏,支持通道导出表头、顺序、必填、列宽、图片尺寸、缺省值和文本转换;导出严格使用各通道自己的映射名称与顺序。
|
||||
- Prisma migrations `20260715190000_add_report_material_import_export_workflow`、`20260715193000_scope_channel_report_fields_by_type`、`20260715194000_initialize_existing_report_material_pending` 已在本地真实 PostgreSQL 成功应用,50 条 migration status 齐全;字段范围迁移将旧 `both` 配置拆成签名/引流两份,并允许同一标准字段在两类中使用不同表头和顺序;初始化迁移不把上线前所有历史签名误认成本次新建/修改资料。Prisma validate/generate、API 全量 19 suites/198 项、API build、前端 build、Gateway 全量 Go 测试、根目录与 API 生产依赖 audit、`git diff --check` 均通过;audit 为 0 漏洞,前端仅有既有 Vite chunk size warning,Jest 仍需 `--forceExit` 退出既有异步句柄。
|
||||
- 应用内浏览器使用本地真实 NestJS API/PostgreSQL 会话验证 `/admin/report-materials`:真实待报备签名加载成功,勾选后“统一生成通道报备”由禁用变为可用;导入弹窗展示企业/应用、映射方案、表头行和 XLSX 文件控件。进入真实通道报备详情后,签名字段配置弹窗按字段池/导出字段双栏渲染,页面和两次交互均无 console error/warn、无框架错误覆盖。未点击统一生成、未上传客户文件、未写入烟测业务数据。
|
||||
|
||||
## 2026-07-15 通道 TPS 配置口径清理(未提交)
|
||||
|
||||
- 明确 `SmsChannel.rateLimitPerSecond` 是单个物理通道的 TPS 上限,不是平台总流速;Redis 限速键包含通道 ID,不同通道独立计数。
|
||||
- 同一物理通道即使被多个通道组引用,也共享通道自身的同一限速桶,不按通道组重复获得额度。
|
||||
- 删除未参与发送链路的 `SmsChannelGroupItem.rateLimitPerSecond`:Prisma 模型、通道组成员 DTO、真实 API 持久化和前端类型均已清理,并新增数据库迁移删除对应列;通道管理中的 `SmsChannel.rateLimitPerSecond` 和现有 NestJS Redis 限速链路保留。
|
||||
- 本地真实 PostgreSQL 已应用 `20260715200000_drop_channel_group_item_rate_limit`,Prisma validate/generate/migrate status 通过;通道服务定向测试 29 项、API 全量 19 suites/198 项、API build、前端 build、Gateway `go test ./...` 均通过。Jest 仍存在测试完成后异步句柄不自动退出的既有提示,前端仍只有既有 chunk size warning。
|
||||
|
||||
## 2026-07-15 Gateway 提交异常处理与最终流速限制(待部署)
|
||||
|
||||
- 运营端新增“Gateway提交异常”页面和真实 NestJS API:支持状态/应用/通道/关键字筛选、服务端汇总、脱敏详情及单条重新入队;浏览器响应不包含原始 payload、通道密码、密钥或鉴权字段,数据库和内部兼容接口暂保留 `GatewaySubmitDeadLetter` 技术命名。
|
||||
- 重新入队增加近期认证、人工原因、明确确认上游未受理、短信状态、通道状态/真实连接状态、最多 3 次以及 pending→requeueing 原子抢占校验;写入 Redis Stream 失败会恢复 pending,成功记录操作人、原因、Stream ID 和时间,后续 SubmitResult 自动闭环 resolved。
|
||||
- Go Gateway 新增 Redis 分布式单通道限速。连接命令保存权威通道 TPS,提交取权威值与消息值的较小者;同一通道在多实例和多个通道组间共享额度,不同通道独立。Stream 消息超速时保持未 ACK 并等待,不作为发送失败,重启后继续使用既有 pending 恢复机制;worker 对同批消息并发处理,避免低 TPS 通道等待阻塞其他通道。
|
||||
- 定向验证已通过:Gateway `internal/ratelimit`、`internal/control`、`internal/submitworker`;API `operations.service.spec.ts`、`send-chain.service.spec.ts` 共 73 项。最终本地门禁通过:Prisma validate/generate、51 条 migration status、API 19 suites/199 项、API build、前端 build、Gateway `go test ./...` 和 `git diff --check`;前端仅有既有 chunk size warning,Jest 仍需 `--forceExit` 退出既有异步句柄。应用内浏览器确认受保护新路由真实跳转运营端登录、标题和 DOM 正常、console 无 error/warn;因真实图形验证码未获授权代解,未绕过认证或注入会话。生产备份、提交、push 与部署结果在发布后补录。
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"cmpp-platform/gateway/internal/control"
|
||||
"cmpp-platform/gateway/internal/health"
|
||||
"cmpp-platform/gateway/internal/inbound"
|
||||
"cmpp-platform/gateway/internal/ratelimit"
|
||||
"cmpp-platform/gateway/internal/submitworker"
|
||||
"cmpp-platform/gateway/internal/upstream"
|
||||
)
|
||||
@@ -24,6 +25,10 @@ func main() {
|
||||
}
|
||||
apiBaseURL := os.Getenv("API_BASE_URL")
|
||||
upstreamManager := &upstream.Manager{APIBaseURL: apiBaseURL}
|
||||
channelLimiter, err := ratelimit.New(os.Getenv("REDIS_URL"))
|
||||
if err != nil {
|
||||
log.Fatalf("gateway channel rate limiter init failed: %v", err)
|
||||
}
|
||||
presenceStore, err := inbound.NewRedisPresenceStore(os.Getenv("REDIS_URL"))
|
||||
if err != nil {
|
||||
log.Printf("gateway downstream presence store init failed: %v", err)
|
||||
@@ -69,6 +74,7 @@ func main() {
|
||||
control.Register(mux, control.Server{
|
||||
APIBaseURL: apiBaseURL,
|
||||
Upstream: upstreamManager,
|
||||
Limiter: channelLimiter,
|
||||
RecoveryCandidates: func(ctx context.Context) ([]inbound.DownstreamPresence, error) {
|
||||
return inbound.ListRecoveryCandidates(ctx, presenceStore)
|
||||
},
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
|
||||
"cmpp-platform/gateway/internal/inbound"
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
"cmpp-platform/gateway/internal/ratelimit"
|
||||
"cmpp-platform/gateway/internal/upstream"
|
||||
)
|
||||
|
||||
type ConnectFunc func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error)
|
||||
type SubmitFunc func(context.Context, queue.SubmitCommand) (queue.SubmitResult, error)
|
||||
|
||||
type ConnectChannelCommand struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
@@ -54,7 +56,9 @@ type Server struct {
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Connect ConnectFunc
|
||||
Submit SubmitFunc
|
||||
Upstream *upstream.Manager
|
||||
Limiter ratelimit.Limiter
|
||||
RecoveryCandidates func(context.Context) ([]inbound.DownstreamPresence, error)
|
||||
RecoveryStatuses func(context.Context) ([]inbound.DownstreamRecoveryStatus, error)
|
||||
}
|
||||
@@ -74,6 +78,9 @@ func Register(mux *http.ServeMux, server Server) {
|
||||
if server.Connect == nil {
|
||||
server.Connect = server.connectChannel
|
||||
}
|
||||
if server.Submit == nil {
|
||||
server.Submit = server.Upstream.Submit
|
||||
}
|
||||
mux.HandleFunc("/connections/connect", server.handleConnectChannel)
|
||||
mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit)
|
||||
mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt)
|
||||
@@ -98,6 +105,12 @@ func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if configurer, ok := s.Limiter.(ratelimit.Configurer); ok {
|
||||
if err := configurer.Configure(r.Context(), command.ChannelID, command.Channel.RateLimitPerSecond); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to configure gateway channel rate limit: %v", err), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status, err := s.Connect(r.Context(), command)
|
||||
if err != nil {
|
||||
@@ -119,7 +132,13 @@ func (s Server) handleUpstreamSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("invalid submit command: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := s.Upstream.Submit(r.Context(), command)
|
||||
if s.Limiter != nil {
|
||||
if _, err := s.Limiter.Wait(r.Context(), command.ChannelID, command.Route.RateLimitPerSecond); err != nil {
|
||||
http.Error(w, fmt.Sprintf("gateway channel rate limit unavailable: %v", err), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := s.Submit(r.Context(), command)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
|
||||
@@ -10,10 +10,57 @@ import (
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/inbound"
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
)
|
||||
|
||||
type controlRecordingLimiter struct {
|
||||
channelID string
|
||||
rate int
|
||||
configuredChannelID string
|
||||
configuredRate int
|
||||
}
|
||||
|
||||
func (l *controlRecordingLimiter) Wait(_ context.Context, channelID string, rate int) (time.Duration, error) {
|
||||
l.channelID = channelID
|
||||
l.rate = rate
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (l *controlRecordingLimiter) Configure(_ context.Context, channelID string, rate int) error {
|
||||
l.configuredChannelID = channelID
|
||||
l.configuredRate = rate
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpstreamSubmitUsesGatewayChannelLimiter(t *testing.T) {
|
||||
limiter := &controlRecordingLimiter{}
|
||||
handler := handlerWithServer(Server{
|
||||
Limiter: limiter,
|
||||
Submit: func(_ context.Context, command queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
return queue.SubmitResult{Envelope: command.Envelope, SubmitStatus: "accepted"}, nil
|
||||
},
|
||||
})
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/upstream/submit", strings.NewReader(`{
|
||||
"schemaVersion":"v1","messageType":"SubmitCommand","messageId":"msg-1","channelId":"channel-1",
|
||||
"submitId":"submit-1","tenantId":"tenant-1","applicationId":"app-1","phoneNumber":"13800138000","content":"hello",
|
||||
"route":{"channelCode":"CMPP-A","cmppAccountCode":"sp","rateLimitPerSecond":100},
|
||||
"cmpp":{"serviceId":"SMS","srcId":"10690000","registeredDelivery":1,"msgFmt":8},
|
||||
"upstream":{"gatewayHost":"127.0.0.1","gatewayPort":17890,"account":"sp","passwordCipher":"secret","cmppVersion":"3.0"},
|
||||
"retry":{"attempt":0,"maxAttempts":1}
|
||||
}`))
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if limiter.channelID != "channel-1" || limiter.rate != 100 {
|
||||
t.Fatalf("unexpected limiter call: %+v", limiter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
||||
handler := handlerWithConnect(func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
||||
limiter := &controlRecordingLimiter{}
|
||||
handler := handlerWithServer(Server{Limiter: limiter, Connect: func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
||||
return ConnectionStateCallback{
|
||||
ChannelID: "channel-1",
|
||||
ConnectionID: "channel-1:primary",
|
||||
@@ -23,7 +70,7 @@ func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
||||
LastConnectedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastHeartbeatAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}, nil
|
||||
})
|
||||
}})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(validConnectCommand()))
|
||||
@@ -45,6 +92,9 @@ func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
||||
if callback.LastConnectedAt == "" || callback.LastHeartbeatAt == "" {
|
||||
t.Fatalf("expected connection timestamps: %+v", callback)
|
||||
}
|
||||
if limiter.configuredChannelID != "channel-1" || limiter.configuredRate != 100 {
|
||||
t.Fatalf("unexpected configured limiter: %+v", limiter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectChannelCallbacksFailedState(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRatePerSecond = 100
|
||||
defaultKeyPrefix = "rate:gateway:channel:"
|
||||
)
|
||||
|
||||
// Limiter reserves one distributed, per-channel submit slot and waits until it
|
||||
// becomes available. The reservation is stored in Redis so multiple Gateway
|
||||
// instances share one supplier TPS budget.
|
||||
type Limiter interface {
|
||||
Wait(context.Context, string, int) (time.Duration, error)
|
||||
}
|
||||
|
||||
// Configurer stores the authoritative channel limit received with the channel
|
||||
// connection command. Wait always applies the lower of this value and the
|
||||
// message value, so a producer cannot raise the supplier TPS ceiling.
|
||||
type Configurer interface {
|
||||
Configure(context.Context, string, int) error
|
||||
}
|
||||
|
||||
type RedisLimiter struct {
|
||||
Redis *redis.Client
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
var reserveScript = redis.NewScript(`
|
||||
local now = redis.call('TIME')
|
||||
local now_us = (tonumber(now[1]) * 1000000) + tonumber(now[2])
|
||||
local requested_rate = tonumber(ARGV[1])
|
||||
local configured_rate = tonumber(redis.call('GET', KEYS[2]))
|
||||
local effective_rate = requested_rate
|
||||
if configured_rate and configured_rate > 0 and configured_rate < effective_rate then
|
||||
effective_rate = configured_rate
|
||||
end
|
||||
local interval_us = math.ceil(1000000 / effective_rate)
|
||||
local next_us = tonumber(redis.call('GET', KEYS[1])) or now_us
|
||||
if next_us < now_us then
|
||||
next_us = now_us
|
||||
end
|
||||
local delay_us = next_us - now_us
|
||||
local reserved_until_us = next_us + interval_us
|
||||
local ttl_ms = math.ceil((reserved_until_us - now_us) / 1000) + 1000
|
||||
redis.call('PSETEX', KEYS[1], ttl_ms, reserved_until_us)
|
||||
return delay_us
|
||||
`)
|
||||
|
||||
func New(redisURL string) (*RedisLimiter, error) {
|
||||
if strings.TrimSpace(redisURL) == "" {
|
||||
redisURL = "redis://127.0.0.1:6379"
|
||||
}
|
||||
options, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewWithClient(redis.NewClient(options)), nil
|
||||
}
|
||||
|
||||
func NewWithClient(client *redis.Client) *RedisLimiter {
|
||||
return &RedisLimiter{Redis: client, KeyPrefix: defaultKeyPrefix}
|
||||
}
|
||||
|
||||
func (l *RedisLimiter) Configure(ctx context.Context, channelID string, ratePerSecond int) error {
|
||||
if l == nil || l.Redis == nil {
|
||||
return fmt.Errorf("gateway rate limiter Redis client is required")
|
||||
}
|
||||
channelID = strings.TrimSpace(channelID)
|
||||
if channelID == "" {
|
||||
return fmt.Errorf("gateway rate limiter channelId is required")
|
||||
}
|
||||
if ratePerSecond <= 0 {
|
||||
ratePerSecond = defaultRatePerSecond
|
||||
}
|
||||
if err := l.Redis.Set(ctx, l.configKey(channelID), ratePerSecond, 0).Err(); err != nil {
|
||||
return fmt.Errorf("configure gateway channel rate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *RedisLimiter) Wait(ctx context.Context, channelID string, ratePerSecond int) (time.Duration, error) {
|
||||
if l == nil || l.Redis == nil {
|
||||
return 0, fmt.Errorf("gateway rate limiter Redis client is required")
|
||||
}
|
||||
channelID = strings.TrimSpace(channelID)
|
||||
if channelID == "" {
|
||||
return 0, fmt.Errorf("gateway rate limiter channelId is required")
|
||||
}
|
||||
if ratePerSecond <= 0 {
|
||||
ratePerSecond = defaultRatePerSecond
|
||||
}
|
||||
delayMicros, err := reserveScript.Run(
|
||||
ctx,
|
||||
l.Redis,
|
||||
[]string{l.key(channelID), l.configKey(channelID)},
|
||||
ratePerSecond,
|
||||
).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reserve gateway channel rate slot: %w", err)
|
||||
}
|
||||
delay := time.Duration(delayMicros) * time.Microsecond
|
||||
if delay <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return delay, ctx.Err()
|
||||
case <-timer.C:
|
||||
return delay, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RedisLimiter) configKey(channelID string) string {
|
||||
return l.key("config:" + channelID)
|
||||
}
|
||||
|
||||
func (l *RedisLimiter) key(channelID string) string {
|
||||
prefix := l.KeyPrefix
|
||||
if prefix == "" {
|
||||
prefix = defaultKeyPrefix
|
||||
}
|
||||
return prefix + channelID
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestRedisLimiterSharesOneChannelBudget(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
limiter := NewWithClient(client)
|
||||
|
||||
first, err := limiter.Wait(context.Background(), "channel-a", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("first wait: %v", err)
|
||||
}
|
||||
second, err := limiter.Wait(context.Background(), "channel-a", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("second wait: %v", err)
|
||||
}
|
||||
if first != 0 {
|
||||
t.Fatalf("first delay = %v, want 0", first)
|
||||
}
|
||||
if second < 40*time.Millisecond {
|
||||
t.Fatalf("second delay = %v, want a shared channel delay", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisLimiterUsesIndependentChannelBudgets(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
limiter := NewWithClient(client)
|
||||
|
||||
if _, err := limiter.Wait(context.Background(), "channel-a", 1); err != nil {
|
||||
t.Fatalf("channel a wait: %v", err)
|
||||
}
|
||||
delay, err := limiter.Wait(context.Background(), "channel-b", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("channel b wait: %v", err)
|
||||
}
|
||||
if delay != 0 {
|
||||
t.Fatalf("channel b delay = %v, want 0", delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisLimiterConfiguredRateIsAuthoritativeUpperBound(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
limiter := NewWithClient(client)
|
||||
|
||||
if err := limiter.Configure(context.Background(), "channel-a", 10); err != nil {
|
||||
t.Fatalf("configure: %v", err)
|
||||
}
|
||||
if _, err := limiter.Wait(context.Background(), "channel-a", 1000); err != nil {
|
||||
t.Fatalf("first wait: %v", err)
|
||||
}
|
||||
delay, err := limiter.Wait(context.Background(), "channel-a", 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("second wait: %v", err)
|
||||
}
|
||||
if delay < 90*time.Millisecond {
|
||||
t.Fatalf("delay = %v, want configured 10 TPS ceiling", delay)
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
"cmpp-platform/gateway/internal/ratelimit"
|
||||
"cmpp-platform/gateway/internal/upstream"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -28,6 +30,7 @@ const (
|
||||
type Worker struct {
|
||||
Redis *redis.Client
|
||||
Upstream *upstream.Manager
|
||||
Limiter ratelimit.Limiter
|
||||
Submit func(context.Context, queue.SubmitCommand) (queue.SubmitResult, error)
|
||||
ReportDeadLetter func(context.Context, DeadLetterEvent) error
|
||||
Stream string
|
||||
@@ -64,7 +67,7 @@ func New(redisURL string, manager *upstream.Manager) (*Worker, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Worker{Redis: client, Upstream: manager}, nil
|
||||
return &Worker{Redis: client, Upstream: manager, Limiter: ratelimit.NewWithClient(client)}, nil
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
@@ -163,12 +166,18 @@ func (w *Worker) recoverPending(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (w *Worker) processMessages(ctx context.Context, messages []redis.XMessage) error {
|
||||
var group sync.WaitGroup
|
||||
for _, message := range messages {
|
||||
if err := w.processMessage(ctx, message); err != nil {
|
||||
w.logf("gateway submit worker message %s failed: %v", message.ID, err)
|
||||
continue
|
||||
}
|
||||
message := message
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
if err := w.processMessage(ctx, message); err != nil {
|
||||
w.logf("gateway submit worker message %s failed: %v", message.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -178,6 +187,9 @@ func (w *Worker) processMessage(ctx context.Context, message redis.XMessage) err
|
||||
return w.deadLetterMalformedMessage(ctx, message, err)
|
||||
}
|
||||
if err := w.handleCommand(ctx, command); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
attempts, attemptsErr := w.incrementFailureAttempt(ctx, message.ID)
|
||||
if attemptsErr != nil {
|
||||
w.logf("gateway submit worker increment failure %s failed: %v", message.ID, attemptsErr)
|
||||
@@ -194,6 +206,11 @@ func (w *Worker) processMessage(ctx context.Context, message redis.XMessage) err
|
||||
}
|
||||
|
||||
func (w *Worker) handleCommand(ctx context.Context, command queue.SubmitCommand) error {
|
||||
if w.Limiter != nil {
|
||||
if _, err := w.Limiter.Wait(ctx, command.ChannelID, command.Route.RateLimitPerSecond); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
submit := w.Submit
|
||||
if submit == nil {
|
||||
if w.Upstream == nil {
|
||||
|
||||
@@ -3,12 +3,26 @@ package submitworker
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type recordingLimiter struct {
|
||||
channelID string
|
||||
rate int
|
||||
called bool
|
||||
}
|
||||
|
||||
func (l *recordingLimiter) Wait(_ context.Context, channelID string, rate int) (time.Duration, error) {
|
||||
l.called = true
|
||||
l.channelID = channelID
|
||||
l.rate = rate
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestCommandFromStreamValuesParsesSubmitCommand(t *testing.T) {
|
||||
command, err := CommandFromStreamValues(map[string]interface{}{
|
||||
"messageType": "SubmitCommand",
|
||||
@@ -70,20 +84,22 @@ func TestCommandFromStreamValuesRejectsMissingData(t *testing.T) {
|
||||
|
||||
func TestHandleMessageUsesInjectedSubmit(t *testing.T) {
|
||||
var got queue.SubmitCommand
|
||||
limiter := &recordingLimiter{}
|
||||
worker := &Worker{
|
||||
Limiter: limiter,
|
||||
Submit: func(_ context.Context, command queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
got = command
|
||||
return queue.SubmitResult{SubmitStatus: "accepted"}, nil
|
||||
},
|
||||
}
|
||||
if err := worker.handleCommand(context.Background(), queue.SubmitCommand{
|
||||
Envelope: queue.Envelope{MessageID: "msg-worker-0002"},
|
||||
Envelope: queue.Envelope{MessageID: "msg-worker-0002", ChannelID: "channel-1"},
|
||||
SubmitID: "submit-2",
|
||||
PhoneNumber: "13800138000",
|
||||
Content: "hello",
|
||||
Upstream: queue.UpstreamConfig{GatewayHost: "127.0.0.1", GatewayPort: 17890, Account: "account-a", PasswordCipher: "secret", CMPPVersion: "3.0"},
|
||||
CMPP: queue.CMPP{ServiceID: "SMS", SrcID: "10690000", RegisteredDelivery: 1, MsgFmt: 8},
|
||||
Route: queue.Route{ChannelCode: "CMPP-A"},
|
||||
Route: queue.Route{ChannelCode: "CMPP-A", RateLimitPerSecond: 320},
|
||||
Retry: queue.Retry{Attempt: 0, MaxAttempts: 1},
|
||||
ApplicationID: "app-1",
|
||||
TenantID: "tenant-1",
|
||||
@@ -93,6 +109,9 @@ func TestHandleMessageUsesInjectedSubmit(t *testing.T) {
|
||||
if got.MessageID != "msg-worker-0002" || got.SubmitID != "submit-2" {
|
||||
t.Fatalf("unexpected command: %+v", got)
|
||||
}
|
||||
if !limiter.called || limiter.channelID != "channel-1" || limiter.rate != 320 {
|
||||
t.Fatalf("unexpected limiter call: %+v", limiter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinIdleDefaultsToThirtySeconds(t *testing.T) {
|
||||
@@ -102,6 +121,52 @@ func TestMinIdleDefaultsToThirtySeconds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessagesDoesNotLetOneChannelBlockAnother(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
startedA := make(chan struct{})
|
||||
startedB := make(chan struct{})
|
||||
releaseA := make(chan struct{})
|
||||
worker := &Worker{
|
||||
Redis: client,
|
||||
Submit: func(_ context.Context, command queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
switch command.ChannelID {
|
||||
case "channel-a":
|
||||
close(startedA)
|
||||
<-releaseA
|
||||
case "channel-b":
|
||||
close(startedB)
|
||||
}
|
||||
return queue.SubmitResult{SubmitStatus: "accepted"}, nil
|
||||
},
|
||||
}
|
||||
messages := []redis.XMessage{
|
||||
{ID: "1-0", Values: submitCommandValues("message-a", "channel-a")},
|
||||
{ID: "2-0", Values: submitCommandValues("message-b", "channel-b")},
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = worker.processMessages(context.Background(), messages)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-startedA:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("channel-a did not start")
|
||||
}
|
||||
select {
|
||||
case <-startedB:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("channel-b was blocked by channel-a")
|
||||
}
|
||||
close(releaseA)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("message batch did not complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessageDeadLettersAfterMaxFailures(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
@@ -178,3 +243,17 @@ func TestProcessMessageDeadLettersAfterMaxFailures(t *testing.T) {
|
||||
t.Fatalf("failure attempt key was not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func submitCommandValues(messageID string, channelID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"data": `{
|
||||
"schemaVersion":"v1","messageType":"SubmitCommand","traceId":"trace-1",
|
||||
"messageId":"` + messageID + `","channelId":"` + channelID + `","submitId":"submit-1",
|
||||
"tenantId":"tenant-1","applicationId":"app-1","phoneNumber":"13800138000","content":"hello",
|
||||
"route":{"channelCode":"CMPP-A","rateLimitPerSecond":100},
|
||||
"cmpp":{"serviceId":"SMS","srcId":"10690000","registeredDelivery":1,"msgFmt":8},
|
||||
"upstream":{"gatewayHost":"127.0.0.1","gatewayPort":17890,"account":"sp","passwordCipher":"secret","cmppVersion":"3.0"},
|
||||
"retry":{"attempt":0,"maxAttempts":1}
|
||||
}`,
|
||||
}
|
||||
}
|
||||
|
||||
+139
-1
@@ -103,6 +103,33 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise<
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
if (response.status === 403 && session && !reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
|
||||
await requestReauthentication();
|
||||
return requestForm<T>(path, form, true);
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export type AdminChannel = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -556,7 +583,6 @@ export type ChannelGroupItem = DictionaryItem & {
|
||||
priority: number;
|
||||
weight?: number;
|
||||
isBackup?: boolean;
|
||||
rateLimitPerSecond?: number | null;
|
||||
channel?: AdminChannel;
|
||||
};
|
||||
|
||||
@@ -570,9 +596,53 @@ export type ChannelReportField = DictionaryItem & {
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
sortOrder?: number;
|
||||
exportName?: string | null;
|
||||
columnWidth?: number;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
defaultValue?: string | null;
|
||||
transform?: string | null;
|
||||
drainageField?: DictionaryItem | null;
|
||||
};
|
||||
|
||||
export type ReportMaterialPendingItem = {
|
||||
id: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
signatureId: string;
|
||||
drainageItemId?: string | null;
|
||||
materialVersion: number;
|
||||
changedAt: string;
|
||||
name: string;
|
||||
detail?: string | null;
|
||||
signatureName?: string;
|
||||
tenant?: TenantOption;
|
||||
application?: ClientSmsApplication | null;
|
||||
};
|
||||
|
||||
export type ReportImportMapping = {
|
||||
sourceHeader: string;
|
||||
sourceHeaderPath?: string;
|
||||
sourceColumnIndex: number;
|
||||
targetFieldCode: string;
|
||||
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
|
||||
fieldType: 'string' | 'image' | 'file';
|
||||
required?: boolean;
|
||||
transform?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type ReportImportProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
sheetName?: string | null;
|
||||
headerRowCount: number;
|
||||
dataStartRow: number;
|
||||
columns: ReportImportMapping[];
|
||||
};
|
||||
|
||||
export type ApplicationReportField = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -962,6 +1032,51 @@ export type GatewayDownstreamRecoveryStatus = {
|
||||
application?: EnterpriseApplication | null;
|
||||
};
|
||||
|
||||
export type GatewaySubmitException = {
|
||||
id: string;
|
||||
streamMessageId: string;
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
channelId?: string | null;
|
||||
traceId?: string | null;
|
||||
messageId?: string | null;
|
||||
submitId?: string | null;
|
||||
status: 'pending' | 'requeueing' | 'requeued' | 'resolved' | string;
|
||||
failureCode: string;
|
||||
failureMessage: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
commandPayload?: Record<string, unknown> | null;
|
||||
rawPayloadAvailable?: boolean;
|
||||
messageState?: {
|
||||
status: string;
|
||||
submitStatus?: string | null;
|
||||
receiptStatus?: string | null;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
} | null;
|
||||
manualRetryCount: number;
|
||||
lastRetryStreamId?: string | null;
|
||||
lastRetriedAt?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
resolvedStatus?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tenant?: Pick<TenantOption, 'id' | 'name' | 'code' | 'status'> | null;
|
||||
application?: Pick<EnterpriseApplication, 'id' | 'tenantId' | 'name' | 'status'> | null;
|
||||
channel?: Pick<AdminChannel, 'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond'> | null;
|
||||
};
|
||||
|
||||
export type GatewaySubmitExceptionResponse = PagedResponse<GatewaySubmitException> & {
|
||||
summary: {
|
||||
pending: number;
|
||||
requeueing: number;
|
||||
requeued: number;
|
||||
resolved: number;
|
||||
oldestPendingAt?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DownstreamRecoveryStatusResponse = PagedResponse<GatewayDownstreamRecoveryStatus> & {
|
||||
summary: {
|
||||
total: number;
|
||||
@@ -1163,6 +1278,25 @@ export const adminApi = {
|
||||
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
|
||||
createChannelReportField: (body: Record<string, unknown>) =>
|
||||
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array<Record<string, unknown>>) =>
|
||||
request<ChannelReportField[]>(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }),
|
||||
listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } = {}) =>
|
||||
request<ReportMaterialPendingItem[]>(withQuery('/admin/report-materials/pending', query)),
|
||||
listReportImportProfiles: (reportType?: 'signature' | 'drainage') =>
|
||||
request<ReportImportProfile[]>(withQuery('/admin/report-materials/import-profiles', { reportType })),
|
||||
saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) =>
|
||||
request<ReportImportProfile>('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }),
|
||||
analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => {
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); });
|
||||
return requestForm<Record<string, unknown> & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array<Record<string, unknown>>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form);
|
||||
},
|
||||
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
|
||||
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
|
||||
createReportMaterialBatch: (body: { createdById?: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }> }) =>
|
||||
request<Record<string, unknown>>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -1188,6 +1322,10 @@ export const adminApi = {
|
||||
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
|
||||
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)),
|
||||
listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
||||
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
||||
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
||||
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
|
||||
|
||||
type ReportType = 'signature' | 'drainage' | 'both';
|
||||
type ReportType = 'signature' | 'drainage';
|
||||
type DrainageItem = Record<string, unknown> & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string };
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
|
||||
@@ -22,8 +23,6 @@ const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | '
|
||||
filing: { label: '报备中', tone: 'warning' },
|
||||
};
|
||||
|
||||
const fieldTypeLabel: Record<string, string> = { string: '字符串', image: '图片', file: '文件' };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -81,9 +80,6 @@ export function AdminChannelReportPage() {
|
||||
const [nextStatus, setNextStatus] = useState('approved');
|
||||
const [statusReason, setStatusReason] = useState('');
|
||||
const [configType, setConfigType] = useState<ReportType>();
|
||||
const [drainageFieldId, setDrainageFieldId] = useState('');
|
||||
const [required, setRequired] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
@@ -119,11 +115,10 @@ export function AdminChannelReportPage() {
|
||||
return records.find((record) => record.taskId === taskId && record.action === action);
|
||||
}
|
||||
|
||||
function createField() {
|
||||
if (!configType || !drainageFieldId) return;
|
||||
adminApi.createChannelReportField({ channelId, drainageFieldId, reportType: configType, required, description, status: 'active' })
|
||||
.then(() => { setConfigType(undefined); setDrainageFieldId(''); setRequired(false); setDescription(''); loadData(); })
|
||||
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
|
||||
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
|
||||
if (!configType) return;
|
||||
await adminApi.replaceChannelReportFields(channelId, configType, nextFields);
|
||||
loadData();
|
||||
}
|
||||
|
||||
function saveTaskStatus() {
|
||||
@@ -180,13 +175,7 @@ export function AdminChannelReportPage() {
|
||||
|
||||
{detail ? <DetailModal {...detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
<Modal footer={<><Button onClick={() => setStatusTask(undefined)} variant="ghost">取消</Button><Button onClick={saveTaskStatus}>保存</Button></>} onClose={() => setStatusTask(undefined)} open={Boolean(statusTask)} title="修改当前通道报备状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
|
||||
<Modal footer={<><Button onClick={() => setConfigType(undefined)} variant="ghost">取消</Button><Button disabled={!drainageFieldId} onClick={createField}>保存</Button></>} onClose={() => setConfigType(undefined)} open={Boolean(configType)} title={configType === 'drainage' ? '配置引流信息报备字段' : '配置签名报备字段'}>
|
||||
<div className="admin-system-modal-form">
|
||||
<Select label="报备字段库字段" onChange={(event) => setDrainageFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}(${fieldTypeLabel[String(field.fieldType)] ?? field.fieldType})`, value: field.id }))]} value={drainageFieldId} />
|
||||
<Select label="是否必填" onChange={(event) => setRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(required)} />
|
||||
<Textarea label="通道报备说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
|
||||
</div>
|
||||
</Modal>
|
||||
{configType ? <ReportFieldMappingModal fields={fields} libraryFields={libraryFields} onClose={() => setConfigType(undefined)} onSave={saveFieldMapping} reportType={configType} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, RotateCcw, Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type EnterpriseApplication, type GatewaySubmitException } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
requeueing: '正在入队',
|
||||
requeued: '已重新入队',
|
||||
resolved: '已处理',
|
||||
};
|
||||
|
||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'danger',
|
||||
requeueing: 'warning',
|
||||
requeued: 'info',
|
||||
resolved: 'success',
|
||||
};
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function maskPhone(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
|
||||
}
|
||||
|
||||
function commandValue(record: GatewaySubmitException, key: string) {
|
||||
const value = record.commandPayload?.[key];
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
|
||||
record: GatewaySubmitException;
|
||||
onClose: () => void;
|
||||
onRequestRequeue: () => void;
|
||||
}) {
|
||||
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
|
||||
const content = record.messageState?.content ?? commandValue(record, 'content');
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>Gateway提交异常详情</h2><p>{record.messageId ?? record.streamMessageId}</p></div>}
|
||||
footer={(
|
||||
<div className="modal-footer-actions">
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
{record.status === 'pending' ? <Button icon={<RotateCcw size={15} />} onClick={onRequestRequeue}>校验并重新入队</Button> : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="report-record-detail gateway-exception-detail">
|
||||
<div className="admin-detail-metric-grid admin-detail-metric-grid--compact">
|
||||
<div className="surface mini-status-card"><AlertTriangle size={20} /><div><span>处理状态</span><strong>{statusLabel[record.status] ?? record.status}</strong></div></div>
|
||||
<div className="surface mini-status-card"><RefreshCw size={20} /><div><span>自动尝试</span><strong>{record.attempts}/{record.maxAttempts}</strong></div></div>
|
||||
<div className="surface mini-status-card"><RotateCcw size={20} /><div><span>人工重新入队</span><strong>{record.manualRetryCount}</strong></div></div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div><span>企业</span><strong>{record.tenant?.name ?? '-'}</strong></div>
|
||||
<div><span>应用</span><strong>{record.application?.name ?? '-'}</strong></div>
|
||||
<div><span>通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong></div>
|
||||
<div><span>通道状态</span><strong>{record.channel?.status ?? '-'}</strong></div>
|
||||
<div><span>手机号</span><strong>{maskPhone(phone)}</strong></div>
|
||||
<div><span>短信状态</span><strong>{record.messageState?.status ?? '-'}</strong></div>
|
||||
<div><span>上游提交状态</span><strong>{record.messageState?.submitStatus ?? '-'}</strong></div>
|
||||
<div><span>回执状态</span><strong>{record.messageState?.receiptStatus ?? '-'}</strong></div>
|
||||
<div><span>MessageId</span><strong>{record.messageId ?? '-'}</strong></div>
|
||||
<div><span>SubmitId</span><strong>{record.submitId ?? '-'}</strong></div>
|
||||
<div><span>TraceId</span><strong>{record.traceId ?? '-'}</strong></div>
|
||||
<div><span>Stream消息</span><strong>{record.streamMessageId}</strong></div>
|
||||
<div><span>异常时间</span><strong>{formatTime(record.createdAt)}</strong></div>
|
||||
<div><span>最近重新入队</span><strong>{formatTime(record.lastRetriedAt)}</strong></div>
|
||||
<div><span>处理时间</span><strong>{formatTime(record.resolvedAt)}</strong></div>
|
||||
<div><span>处理结果</span><strong>{record.resolvedStatus ?? '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>短信内容</span><strong>{content || '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>失败代码</span><strong>{record.failureCode}</strong></div>
|
||||
<div className="detail-grid__wide"><span>失败原因</span><strong>{record.failureMessage}</strong></div>
|
||||
</div>
|
||||
<div className="gateway-exception-command">
|
||||
<div className="section-heading"><h3>脱敏后的Gateway命令</h3><p className="page-inline-hint">密码、密钥和认证数据已由后端移除。</p></div>
|
||||
<pre>{JSON.stringify(record.commandPayload ?? {}, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RequeueModal({ record, submitting, onClose, onSubmit }: {
|
||||
record: GatewaySubmitException;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (reason: string) => void;
|
||||
}) {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
|
||||
const unsafe = record.messageState?.submitStatus === 'accepted'
|
||||
|| ['submitted', 'delivered', 'unknown'].includes(record.messageState?.status ?? '')
|
||||
|| ['delivered', 'unknown'].includes(record.messageState?.receiptStatus ?? '');
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
title={<div className="template-modal-title"><h2>校验并重新入队</h2><p>{maskPhone(phone)}</p></div>}
|
||||
footer={(
|
||||
<div className="modal-footer-actions">
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!confirmed || reason.trim().length < 5 || unsafe || submitting} icon={<RotateCcw size={15} />} onClick={() => onSubmit(reason.trim())}>
|
||||
{submitting ? '正在入队...' : '确认重新入队'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="page-stack gateway-requeue-confirm">
|
||||
<div className={`callout ${unsafe ? 'callout--danger' : 'callout--warning'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
<div>
|
||||
<strong>{unsafe ? '当前记录禁止重新入队' : '重新提交可能产生重复短信'}</strong>
|
||||
<p>{unsafe ? '系统已存在成功或不确定的上游结果。' : '请先向通道或运营商确认原短信从未被接收。'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div><span>当前短信状态</span><strong>{record.messageState?.status ?? '-'}</strong></div>
|
||||
<div><span>原通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>原失败原因</span><strong>{record.failureMessage}</strong></div>
|
||||
</div>
|
||||
<Textarea label="重新入队原因" maxLength={500} onChange={(event) => setReason(event.target.value)} placeholder="至少5个字,例如:已向通道确认该Submit未被接收,连接现已恢复" rows={4} value={reason} />
|
||||
<label className="gateway-requeue-checkbox">
|
||||
<input checked={confirmed} disabled={unsafe} onChange={(event) => setConfirmed(event.target.checked)} type="checkbox" />
|
||||
<span>我已确认运营商未接收该短信,并知晓重复发送风险。</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminGatewaySubmitExceptionsPage() {
|
||||
const [items, setItems] = useState<GatewaySubmitException[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [summary, setSummary] = useState({ pending: 0, requeueing: 0, requeued: 0, resolved: 0, oldestPendingAt: null as string | null });
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [channelId, setChannelId] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<GatewaySubmitException | null>(null);
|
||||
const [requeueRecord, setRequeueRecord] = useState<GatewaySubmitException | null>(null);
|
||||
const pageSize = 10;
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
adminApi.listGatewaySubmitExceptions({ keyword, status, applicationId, channelId, page, pageSize }),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
adminApi.listChannels(),
|
||||
])
|
||||
.then(([response, appItems, channelItems]) => {
|
||||
setItems(response.items);
|
||||
setTotal(response.total);
|
||||
setSummary({ ...response.summary, oldestPendingAt: response.summary.oldestPendingAt ?? null });
|
||||
setApplications(appItems);
|
||||
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setError(failure.message || 'Gateway提交异常加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [applicationId, channelId, keyword, page, status]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<GatewaySubmitException>>>(() => [
|
||||
{ key: 'createdAt', title: '异常时间', width: '170px', render: (record) => formatTime(record.createdAt) },
|
||||
{ key: 'messageId', title: '消息编号', width: '170px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
|
||||
{ key: 'tenant', title: '企业 / 应用', width: '190px', render: (record) => <div><strong>{record.tenant?.name ?? '-'}</strong><small className="table-cell-note">{record.application?.name ?? '-'}</small></div> },
|
||||
{ key: 'channel', title: '通道', width: '180px', render: (record) => <div>{record.channel?.name ?? '-' }<small className="table-cell-note">{record.channel?.code ?? record.channelId ?? '-'}</small></div> },
|
||||
{ key: 'phone', title: '手机号', width: '130px', render: (record) => maskPhone(record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber')) },
|
||||
{ key: 'failure', title: '异常原因', render: (record) => <div><strong>{record.failureCode}</strong><small className="table-cell-note">{record.failureMessage}</small></div> },
|
||||
{ key: 'attempts', title: '尝试', width: '80px', align: 'center', render: (record) => `${record.attempts}/${record.maxAttempts}` },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '105px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
], []);
|
||||
|
||||
async function submitRequeue(reason: string) {
|
||||
if (!requeueRecord) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await adminApi.requeueGatewaySubmitException(requeueRecord.id, {
|
||||
confirmedNotSubmitted: true,
|
||||
reason,
|
||||
});
|
||||
setRequeueRecord(null);
|
||||
setDetail(null);
|
||||
setError('');
|
||||
loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '重新入队失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<section className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['运营概览', 'Gateway提交异常']} /><h1>Gateway提交异常</h1><p className="page-inline-hint">仅处理Gateway连续失败且尚未取得明确上游结果的提交命令。</p></div>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={loadData} variant="secondary">刷新</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface mini-status-card"><AlertTriangle size={22} /><div><span>待处理异常</span><strong>{summary.pending}</strong><small>需要人工判断是否可以重新提交。</small></div></div>
|
||||
<div className="surface mini-status-card"><Clock3 size={22} /><div><span>最早待处理</span><strong className="gateway-exception-time">{formatTime(summary.oldestPendingAt)}</strong><small>等待时间过长应优先处理。</small></div></div>
|
||||
<div className="surface mini-status-card"><RotateCcw size={22} /><div><span>已重新入队</span><strong>{summary.requeued + summary.requeueing}</strong><small>消息继续受Gateway通道TPS约束。</small></div></div>
|
||||
<div className="surface mini-status-card"><CheckCircle2 size={22} /><div><span>已处理</span><strong>{summary.resolved}</strong><small>已取得明确Gateway提交结果。</small></div></div>
|
||||
</div>
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="消息编号 / 错误" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="MessageId、SubmitId、失败原因" value={keyword} />
|
||||
<Select label="状态" options={[{ label: '全部状态', value: 'all' }, { label: '待处理', value: 'pending' }, { label: '正在入队', value: 'requeueing' }, { label: '已重新入队', value: 'requeued' }, { label: '已处理', value: 'resolved' }]} value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }} />
|
||||
<Select label="应用" options={[{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} />
|
||||
<Select label="通道" options={[{ label: '全部通道', value: 'all' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]} value={channelId} onChange={(event) => { setChannelId(event.target.value); setPage(1); }} />
|
||||
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}>查询</Button></div>
|
||||
</div>
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div className="section-heading"><h2>提交异常记录</h2><p className="page-inline-hint">详情中的Gateway命令已由后端脱敏,不返回通道密码。</p></div>
|
||||
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无Gateway提交异常'} pagination={false} rowKey="id" />
|
||||
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
|
||||
</div>
|
||||
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null}
|
||||
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||
|
||||
type Batch = Record<string, unknown> & { id: string; batchNo?: string; status?: string; createdAt?: string; selectedCount?: number; channelCount?: number; exportFiles?: Array<Record<string, unknown>> };
|
||||
|
||||
const statusLabel: Record<string, string> = { completed: '生成完成', partial_failed: '部分资料待补充', failed: '生成失败', processing: '生成中' };
|
||||
|
||||
export function AdminReportMaterialsPage() {
|
||||
const [items, setItems] = useState<ReportMaterialPendingItem[]>([]);
|
||||
const [batches, setBatches] = useState<Batch[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [reportType, setReportType] = useState('all');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
|
||||
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
|
||||
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [reportType]);
|
||||
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
|
||||
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
|
||||
|
||||
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
|
||||
async function createBatch() {
|
||||
const chosen = items.filter((item) => selected.has(item.id));
|
||||
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
|
||||
setBusy(true); setError(''); setMessage('');
|
||||
try {
|
||||
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
|
||||
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <section className="page-stack report-material-page">
|
||||
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;人工勾选后一次生成所有关联通道的任务和 XLSX 文件。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备(${selected.size})`}</Button></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
|
||||
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost">刷新</Button></div>
|
||||
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本</span><span>变更时间</span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
||||
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2>最近生成批次</h2><p>一个批次可按路由展开成多个通道文件,图片直接嵌入 XLSX。</p></div><Tag tone="neutral">{batches.length} 个批次</Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · 选择 {batch.selectedCount ?? 0} 条 · {batch.channelCount ?? 0} 个通道</small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}({String(file.rowCount ?? 0)} 行)</a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty">尚未生成报备批次</div> : null}</div>
|
||||
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type ReportType = 'signature' | 'drainage';
|
||||
type DraftField = {
|
||||
drainageFieldId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
fieldType: string;
|
||||
exportName: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
columnWidth: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
defaultValue: string;
|
||||
transform: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const fieldTypeLabel: Record<string, string> = { string: '文本', image: '图片', file: '文件' };
|
||||
const transformOptions = [
|
||||
{ label: '保持原值', value: '' },
|
||||
{ label: '去除首尾空格', value: 'trim' },
|
||||
{ label: '仅保留数字', value: 'digits' },
|
||||
{ label: '转大写', value: 'uppercase' },
|
||||
{ label: '转小写', value: 'lowercase' },
|
||||
];
|
||||
|
||||
function initialDraft(fields: ChannelReportField[], reportType: ReportType): DraftField[] {
|
||||
return fields
|
||||
.filter((field) => field.reportType === reportType || field.reportType === 'both')
|
||||
.sort((left, right) => (left.sortOrder ?? 100) - (right.sortOrder ?? 100))
|
||||
.map((field, index) => ({
|
||||
drainageFieldId: String(field.drainageFieldId ?? field.drainageField?.id ?? ''),
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
exportName: field.exportName || field.name,
|
||||
required: field.required,
|
||||
description: String(field.description ?? ''),
|
||||
sortOrder: field.sortOrder ?? (index + 1) * 10,
|
||||
columnWidth: field.columnWidth ?? 18,
|
||||
imageWidth: field.imageWidth ?? 120,
|
||||
imageHeight: field.imageHeight ?? 80,
|
||||
defaultValue: String(field.defaultValue ?? ''),
|
||||
transform: String(field.transform ?? ''),
|
||||
status: 'active',
|
||||
}));
|
||||
}
|
||||
|
||||
export function ReportFieldMappingModal({ fields, libraryFields, reportType, onClose, onSave }: {
|
||||
fields: ChannelReportField[];
|
||||
libraryFields: DictionaryItem[];
|
||||
reportType: ReportType;
|
||||
onClose: () => void;
|
||||
onSave: (fields: DraftField[]) => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => initialDraft(fields, reportType));
|
||||
const [search, setSearch] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const selectedIds = useMemo(() => new Set(draft.map((field) => field.drainageFieldId)), [draft]);
|
||||
const available = useMemo(() => libraryFields.filter((field) => !selectedIds.has(String(field.id)) && [field.name, field.code].some((value) => String(value ?? '').toLowerCase().includes(search.trim().toLowerCase()))), [libraryFields, search, selectedIds]);
|
||||
|
||||
function addField(field: DictionaryItem) {
|
||||
setDraft((current) => [...current, {
|
||||
drainageFieldId: String(field.id),
|
||||
code: String(field.code ?? field.id),
|
||||
name: String(field.name ?? field.code ?? '未命名字段'),
|
||||
fieldType: String(field.fieldType ?? 'string'),
|
||||
exportName: String(field.name ?? field.code ?? ''),
|
||||
required: Boolean(field.required),
|
||||
description: String(field.description ?? ''),
|
||||
sortOrder: (current.length + 1) * 10,
|
||||
columnWidth: 18,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
defaultValue: '',
|
||||
transform: '',
|
||||
status: 'active',
|
||||
}]);
|
||||
}
|
||||
|
||||
function patchField(index: number, patch: Partial<DraftField>) {
|
||||
setDraft((current) => current.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field));
|
||||
}
|
||||
|
||||
function move(index: number, offset: number) {
|
||||
setDraft((current) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 }));
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (draft.some((field) => !field.exportName.trim())) { setError('导出表头名称不能为空'); return; }
|
||||
setSaving(true); setError('');
|
||||
try { await onSave(draft.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 }))); onClose(); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '字段配置保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return <Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中...' : '保存配置'}</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="channel-field-config-title"><h2>{reportType === 'signature' ? '配置签名报备字段' : '配置引流信息字段'}</h2><p>配置系统字段到通道Excel表头的映射、顺序和图片布局。</p></div>}
|
||||
>
|
||||
<div className="channel-field-config">
|
||||
<section className="channel-field-pool">
|
||||
<div className="channel-field-section-head"><h3>字段池</h3><Tag tone="neutral">{available.length} 个可选</Tag></div>
|
||||
<Input onChange={(event) => setSearch(event.target.value)} placeholder="搜索标准字段" prefix={<Search size={16} />} value={search} />
|
||||
<div className="channel-field-pool-list">
|
||||
{available.map((field) => <button key={String(field.id)} onClick={() => addField(field)} type="button"><span><strong>{String(field.name ?? field.code)}</strong><Tag tone="neutral">{fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}</Tag></span><span>添加 <Plus size={15} /></span></button>)}
|
||||
{available.length === 0 ? <p>没有可添加的字段</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="channel-selected-fields">
|
||||
<div className="channel-field-section-head"><div><h3>导出字段</h3><p>从上到下对应Excel从左到右的列顺序</p></div><Tag tone="info">{draft.length} 列</Tag></div>
|
||||
<div className="channel-export-preview">{draft.map((field, index) => <span key={field.drainageFieldId}>{String.fromCharCode(65 + index)} · {field.exportName || field.name}</span>)}</div>
|
||||
<div className="channel-selected-field-list">
|
||||
{draft.map((field, index) => <article key={field.drainageFieldId}>
|
||||
<div className="channel-selected-field-head"><span className="channel-selected-field-index">{index + 1}</span><strong>{field.name}</strong><Tag tone="neutral">{fieldTypeLabel[field.fieldType] ?? field.fieldType}</Tag><div className="channel-selected-field-order"><button disabled={index === 0} onClick={() => move(index, -1)} type="button"><ChevronUp size={16} /></button><button disabled={index === draft.length - 1} onClick={() => move(index, 1)} type="button"><ChevronDown size={16} /></button></div></div>
|
||||
<div className="channel-field-mapping-grid">
|
||||
<Input label="通道导出表头" onChange={(event) => patchField(index, { exportName: event.target.value })} value={field.exportName} />
|
||||
<Select label="是否必填" onChange={(event) => patchField(index, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(field.required)} />
|
||||
<Input label="列宽" min="6" onChange={(event) => patchField(index, { columnWidth: Number(event.target.value) })} type="number" value={String(field.columnWidth)} />
|
||||
<Select label="文本转换" onChange={(event) => patchField(index, { transform: event.target.value })} options={transformOptions} value={field.transform} />
|
||||
{field.fieldType !== 'string' ? <><Input label="图片宽度(px)" min="24" onChange={(event) => patchField(index, { imageWidth: Number(event.target.value) })} type="number" value={String(field.imageWidth)} /><Input label="图片高度(px)" min="24" onChange={(event) => patchField(index, { imageHeight: Number(event.target.value) })} type="number" value={String(field.imageHeight)} /></> : <Input label="缺省值" onChange={(event) => patchField(index, { defaultValue: event.target.value })} value={field.defaultValue} />}
|
||||
</div>
|
||||
<Textarea label="通道说明" onChange={(event) => patchField(index, { description: event.target.value })} rows={2} value={field.description} />
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setDraft((current) => current.filter((_, fieldIndex) => fieldIndex !== index))} size="sm" variant="danger">移除字段</Button>
|
||||
</article>)}
|
||||
{draft.length === 0 ? <div className="channel-report-empty">请从左侧添加报备字段</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</Modal>;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FileSpreadsheet, Plus, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type DictionaryItem, type EnterpriseApplication, type ReportImportMapping, type ReportImportProfile, type TenantOption } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
|
||||
type ReportType = 'signature' | 'drainage';
|
||||
type AnalyzeResult = {
|
||||
id: string;
|
||||
sheetName?: string;
|
||||
sheets?: string[];
|
||||
columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>;
|
||||
rows: Array<{ rowNumber: number; values: Record<string, string>; imageColumns: number[] }>;
|
||||
suggestedMappings: ReportImportMapping[];
|
||||
};
|
||||
|
||||
const transforms = [{ label: '保持原值', value: '' }, { label: '去除首尾空格', value: 'trim' }, { label: '仅保留数字', value: 'digits' }, { label: '转大写', value: 'uppercase' }, { label: '转小写', value: 'lowercase' }];
|
||||
|
||||
function coreTargets(reportType: ReportType) {
|
||||
return reportType === 'signature'
|
||||
? [{ label: '短信签名', value: 'signatureName:signature_name:string' }, { label: '签名用途/依据', value: 'purpose:purpose:string' }]
|
||||
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '站点名称', value: 'siteName:site_name:string' }, { label: '引流地址', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
|
||||
}
|
||||
|
||||
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
||||
const [profiles, setProfiles] = useState<ReportImportProfile[]>([]);
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [reportType, setReportType] = useState<ReportType>('signature');
|
||||
const [profileId, setProfileId] = useState('');
|
||||
const [file, setFile] = useState<File>();
|
||||
const [headerRowCount, setHeaderRowCount] = useState(1);
|
||||
const [dataStartRow, setDataStartRow] = useState(2);
|
||||
const [analysis, setAnalysis] = useState<AnalyzeResult>();
|
||||
const [mappings, setMappings] = useState<ReportImportMapping[]>([]);
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [saveProfile, setSaveProfile] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listDrainageFields()])
|
||||
.then(([tenantItems, applicationItems, fieldItems]) => { setTenants(tenantItems); setApplications(applicationItems); setLibraryFields(fieldItems.filter((item) => item.status === 'active')); })
|
||||
.catch((failure: Error) => setError(failure.message || '基础数据加载失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listReportImportProfiles(reportType).then(setProfiles).catch(() => setProfiles([]));
|
||||
setProfileId(''); setAnalysis(undefined); setMappings([]);
|
||||
}, [reportType]);
|
||||
|
||||
const availableApplications = useMemo(() => applications.filter((item) => !tenantId || item.tenantId === tenantId), [applications, tenantId]);
|
||||
const mappingByColumn = useMemo(() => new Map(mappings.map((item) => [item.sourceColumnIndex, item])), [mappings]);
|
||||
|
||||
async function analyze() {
|
||||
if (!tenantId || !file) { setError('请选择企业和 XLSX 文件'); return; }
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const result = await adminApi.analyzeReportMaterialImport(file, { tenantId, applicationId: applicationId || undefined, reportType, headerRowCount, dataStartRow, profileId: profileId || undefined }) as AnalyzeResult;
|
||||
setAnalysis(result); setMappings(result.suggestedMappings ?? []);
|
||||
const selectedProfile = profiles.find((item) => item.id === profileId);
|
||||
if (selectedProfile) setProfileName(selectedProfile.name);
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件解析失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
|
||||
setMappings((current) => {
|
||||
const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex);
|
||||
if (!encoded) return remaining;
|
||||
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [ReportImportMapping['targetKind'], string, ReportImportMapping['fieldType']];
|
||||
return [...remaining, { sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetKind, targetFieldCode, fieldType, required: false, sortOrder: (column.sourceColumnIndex + 1) * 10 }].sort((left, right) => left.sourceColumnIndex - right.sourceColumnIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function patchMapping(columnIndex: number, patch: Partial<ReportImportMapping>) {
|
||||
setMappings((current) => current.map((item) => item.sourceColumnIndex === columnIndex ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!analysis || mappings.length === 0) { setError('请至少配置一个导入字段映射'); return; }
|
||||
if (saveProfile && !profileName.trim()) { setError('请输入映射方案名称'); return; }
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await adminApi.commitReportMaterialImport(analysis.id, {
|
||||
mappings,
|
||||
profile: saveProfile ? { id: profileId || undefined, name: profileName, reportType, tenantId, applicationId: applicationId || null, sheetName: analysis.sheetName, headerRowCount, dataStartRow, columns: mappings } : undefined,
|
||||
});
|
||||
onCompleted(); onClose();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '导入失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const targetOptions = [
|
||||
{ label: '不导入此列', value: '' },
|
||||
...coreTargets(reportType),
|
||||
...libraryFields.map((field) => ({ label: `报备字段 · ${String(field.name ?? field.code)}`, value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}` })),
|
||||
];
|
||||
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button>{analysis ? <Button disabled={busy} onClick={() => void commit()}>{busy ? '导入中...' : '确认导入待报备池'}</Button> : <Button disabled={busy || !file || !tenantId} onClick={() => void analyze()}>{busy ? '解析中...' : '解析文件并配置映射'}</Button>}</>} onClose={onClose} open size="xl" title={<div className="channel-field-config-title"><h2>导入签名与引流报备资料</h2><p>支持 WPS 另存的 XLSX 及单元格内嵌图片;导入只更新待报备资料,不自动生成通道任务。</p></div>}>
|
||||
<div className="report-import-basic-grid">
|
||||
<Select label="资料类型" onChange={(event) => setReportType(event.target.value as ReportType)} options={[{ label: '签名资料', value: 'signature' }, { label: '引流信息资料', value: 'drainage' }]} value={reportType} />
|
||||
<Select label="所属企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: `${item.name}(${item.code})`, value: item.id }))]} value={tenantId} />
|
||||
<Select label="企业应用(可选)" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '不限定应用', value: '' }, ...availableApplications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} />
|
||||
<Select label="复用导入映射(可选)" onChange={(event) => { const id = event.target.value; setProfileId(id); const profile = profiles.find((item) => item.id === id); if (profile) { setHeaderRowCount(profile.headerRowCount); setDataStartRow(profile.dataStartRow); } }} options={[{ label: '新建映射', value: '' }, ...profiles.map((item) => ({ label: item.name, value: item.id }))]} value={profileId} />
|
||||
<Input label="表头行数" max="5" min="1" onChange={(event) => setHeaderRowCount(Number(event.target.value))} type="number" value={String(headerRowCount)} />
|
||||
<Input label="数据起始行" min="2" onChange={(event) => setDataStartRow(Number(event.target.value))} type="number" value={String(dataStartRow)} />
|
||||
</div>
|
||||
<label className="report-import-file"><span><FileSpreadsheet size={22} /><strong>{file?.name ?? '选择 WPS 另存的 .xlsx 文件'}</strong></span><input accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setFile(event.target.files?.[0]); setAnalysis(undefined); }} type="file" /></label>
|
||||
{analysis ? <div className="report-import-mapping">
|
||||
<div className="channel-field-section-head"><div><h3>导入字段映射</h3><p>源列顺序不受限制,每一列明确映射到系统标准字段。</p></div><Tag tone="info">检测到 {analysis.columns.length} 列</Tag></div>
|
||||
<div className="report-import-mapping-table"><div className="report-import-mapping-head"><span>源列/图片</span><span>目标字段</span><span>数据类型</span><span>必填</span><span>转换</span></div>{analysis.columns.map((column) => {
|
||||
const mapping = mappingByColumn.get(column.sourceColumnIndex);
|
||||
const encoded = mapping ? `${mapping.targetKind}:${mapping.targetFieldCode}:${mapping.fieldType}` : '';
|
||||
return <div className="report-import-mapping-row" key={column.sourceColumnIndex}><span><strong>{column.columnLetter} · {column.sourceHeader}</strong><small>{column.sourceHeaderPath}</small>{column.imageCount ? <Tag tone="warning">{column.imageCount} 张图片</Tag> : null}</span><Select onChange={(event) => setTarget(column, event.target.value)} options={targetOptions} value={encoded} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { fieldType: event.target.value as ReportImportMapping['fieldType'] })} options={[{ label: '文本', value: 'string' }, { label: '图片', value: 'image' }, { label: '文件', value: 'file' }]} value={mapping?.fieldType ?? 'string'} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(mapping?.required ?? false)} /><Select disabled={!mapping || mapping.fieldType !== 'string'} onChange={(event) => patchMapping(column.sourceColumnIndex, { transform: event.target.value })} options={transforms} value={mapping?.transform ?? ''} /></div>;
|
||||
})}</div>
|
||||
<div className="report-import-profile"><button className={saveProfile ? 'is-active' : ''} onClick={() => setSaveProfile((value) => !value)} type="button">{saveProfile ? <Trash2 size={15} /> : <Plus size={15} />}{saveProfile ? '本次保存/更新映射方案' : '将本次配置保存为可复用映射方案'}</button>{saveProfile ? <Input label="映射方案名称" onChange={(event) => setProfileName(event.target.value)} placeholder="例如:海南移动签名资料模板" value={profileName} /> : null}</div>
|
||||
{analysis.rows.length ? <details className="report-import-preview"><summary>查看前 {analysis.rows.length} 行解析预览</summary><pre>{JSON.stringify(analysis.rows, null, 2)}</pre></details> : null}
|
||||
</div> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</Modal>;
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
} from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Building2,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
FilePenLine,
|
||||
Gauge,
|
||||
Hash,
|
||||
@@ -88,6 +90,7 @@ export function AdminLayout() {
|
||||
items: [
|
||||
{ label: '运营看板', to: '/admin', icon: Gauge },
|
||||
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
|
||||
{ label: 'Gateway提交异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle },
|
||||
{ label: '数据统计', to: '/admin/analytics', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
@@ -125,6 +128,7 @@ export function AdminLayout() {
|
||||
title: '报备任务',
|
||||
icon: ClipboardList,
|
||||
items: [
|
||||
{ label: '待报备资料', to: '/admin/report-materials', icon: FileSpreadsheet },
|
||||
{ label: '报备任务', to: '/admin/report-tasks', icon: ClipboardList },
|
||||
{ label: '报备记录', to: '/admin/report-records', icon: ListChecks },
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AdminEnterpriseBlacklistPage } from '@/apps/admin/AdminEnterpriseBlackl
|
||||
import { AdminEnterpriseSignaturesPage } from '@/apps/admin/AdminEnterpriseSignaturesPage';
|
||||
import { AdminEnterpriseTemplatesPage } from '@/apps/admin/AdminEnterpriseTemplatesPage';
|
||||
import { AdminGlobalBlacklistPage } from '@/apps/admin/AdminGlobalBlacklistPage';
|
||||
import { AdminGatewaySubmitExceptionsPage } from '@/apps/admin/AdminGatewaySubmitExceptionsPage';
|
||||
import { AdminHome } from '@/apps/admin/AdminHome';
|
||||
import { AdminMonitorPage } from '@/apps/admin/AdminMonitorPage';
|
||||
import { AdminPhoneSegmentsPage } from '@/apps/admin/AdminPhoneSegmentsPage';
|
||||
@@ -24,6 +25,7 @@ import { AdminProfitReportsPage } from '@/apps/admin/AdminProfitReportsPage';
|
||||
import { AdminQualityReportsPage } from '@/apps/admin/AdminQualityReportsPage';
|
||||
import { AdminReportRecordsPage } from '@/apps/admin/AdminReportRecordsPage';
|
||||
import { AdminReportTasksPage } from '@/apps/admin/AdminReportTasksPage';
|
||||
import { AdminReportMaterialsPage } from '@/apps/admin/AdminReportMaterialsPage';
|
||||
import { AdminSensitiveWordsPage } from '@/apps/admin/AdminSensitiveWordsPage';
|
||||
import { AdminSmsAuditPage } from '@/apps/admin/AdminSmsAuditPage';
|
||||
import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFormPage';
|
||||
@@ -83,6 +85,7 @@ export function AppRoutes() {
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<AdminHome />} />
|
||||
<Route path="monitor" element={<AdminMonitorPage />} />
|
||||
<Route path="gateway-submit-exceptions" element={<AdminGatewaySubmitExceptionsPage />} />
|
||||
<Route path="analytics" element={<AdminAnalyticsPage />} />
|
||||
<Route path="customers" element={<AdminCustomersPage />} />
|
||||
<Route path="customers/new" element={<AdminCustomerFormPage />} />
|
||||
@@ -105,6 +108,7 @@ export function AppRoutes() {
|
||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
|
||||
<Route path="report-tasks" element={<AdminReportTasksPage />} />
|
||||
<Route path="report-materials" element={<AdminReportMaterialsPage />} />
|
||||
<Route path="report-records" element={<AdminReportRecordsPage />} />
|
||||
<Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} />
|
||||
<Route path="mms-task-progress" element={<PagePlaceholder />} />
|
||||
|
||||
@@ -2,6 +2,88 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gateway-exception-time {
|
||||
font-size: 1rem !important;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.table-cell-note {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
max-width: 28rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gateway-exception-command {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.gateway-exception-command pre {
|
||||
max-height: 22rem;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout p {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout--warning {
|
||||
border-color: #f5c66f;
|
||||
background: #fff8e8;
|
||||
color: #8a5700;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout--danger {
|
||||
border-color: #f2a4a4;
|
||||
background: #fff0f0;
|
||||
color: #a12222;
|
||||
}
|
||||
|
||||
.gateway-requeue-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gateway-requeue-checkbox input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
html {
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
@@ -6986,6 +7068,84 @@ h3 {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.channel-export-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.channel-export-preview span {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channel-field-mapping-grid,
|
||||
.report-import-basic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-import-basic-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-import-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--primary);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--primary) 5%, var(--surface));
|
||||
}
|
||||
|
||||
.report-import-file span { display: flex; align-items: center; gap: 9px; }
|
||||
.report-import-file input { max-width: 310px; }
|
||||
.report-import-mapping { display: grid; gap: 12px; }
|
||||
.report-import-mapping-table { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.report-import-mapping-head,
|
||||
.report-import-mapping-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1.5fr) 110px 100px 130px; gap: 10px; align-items: center; min-width: 820px; padding: 10px 12px; }
|
||||
.report-import-mapping-head { background: var(--surface-muted); color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||
.report-import-mapping-row { border-top: 1px solid var(--border); }
|
||||
.report-import-mapping-row > span { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
|
||||
.report-import-mapping-row small { width: 100%; color: var(--text-muted); }
|
||||
.report-import-profile { display: flex; align-items: end; gap: 12px; }
|
||||
.report-import-profile button { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); padding: 9px 12px; display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
|
||||
.report-import-profile button.is-active { border-color: var(--primary); color: var(--primary); }
|
||||
.report-import-profile .field { min-width: 320px; }
|
||||
.report-import-preview pre { max-height: 240px; overflow: auto; padding: 12px; background: #111827; color: #d1fae5; border-radius: 8px; font-size: 11px; }
|
||||
|
||||
.report-material-filter { display: grid; grid-template-columns: 220px minmax(280px, 1fr) auto; gap: 14px; align-items: end; }
|
||||
.report-material-pool { overflow: hidden; padding: 0; }
|
||||
.report-material-table-head,
|
||||
.report-material-row { display: grid; grid-template-columns: 28px minmax(260px, 1.5fr) minmax(180px, 1fr) 90px 160px; gap: 12px; align-items: center; padding: 12px 16px; }
|
||||
.report-material-table-head { background: var(--surface-muted); color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||
.report-material-row { border-top: 1px solid var(--border); cursor: pointer; }
|
||||
.report-material-row:hover { background: color-mix(in srgb, var(--primary) 3%, var(--surface)); }
|
||||
.report-material-row > span { display: grid; gap: 3px; }
|
||||
.report-material-row small, .report-material-row em { color: var(--text-muted); font-size: 12px; font-style: normal; }
|
||||
.report-material-batches { display: grid; gap: 0; }
|
||||
.report-material-batches article { display: flex; justify-content: space-between; gap: 24px; padding: 14px 0; border-top: 1px solid var(--border); }
|
||||
.report-material-batches article > div { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
|
||||
.report-material-batches article small { width: 100%; color: var(--text-muted); }
|
||||
.report-material-batches article a { display: inline-flex; align-items: center; gap: 5px; color: var(--primary); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.channel-field-mapping-grid, .report-import-basic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.report-material-table-head, .report-material-row { grid-template-columns: 28px minmax(220px, 1.5fr) minmax(160px, 1fr); }
|
||||
.report-material-table-head > :nth-child(n+4), .report-material-row > :nth-child(n+4) { display: none; }
|
||||
}
|
||||
|
||||
.channel-field-pool {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user