Compare commits
2
Commits
ac6449028c
...
4665079ca3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4665079ca3 | ||
|
|
7f9abe3da0 |
@@ -0,0 +1,10 @@
|
|||||||
|
-- Preserve all legacy rows; independent carrier states require distinct business keys.
|
||||||
|
BEGIN;
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_carrier_key"
|
||||||
|
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId", "carrier")
|
||||||
|
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_legacy_key"
|
||||||
|
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId")
|
||||||
|
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NULL;
|
||||||
|
DROP INDEX "ChannelSignatureReportTask_drainage_target_key";
|
||||||
|
COMMIT;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
@@ -450,17 +451,25 @@ export class ChannelReportingService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
|
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
|
||||||
channels
|
channels.flatMap((channel) =>
|
||||||
.map((channel) => {
|
normalizeChannelCarriers(channel.carriers, channel.carrier).flatMap((carrier) => {
|
||||||
const existing = signature.reportTasks.find(
|
const tasks = signature.reportTasks.filter(
|
||||||
(task) =>
|
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageInfo.id,
|
||||||
task.reportType === 'drainage' &&
|
|
||||||
task.channelId === channel.id &&
|
|
||||||
task.drainageItemId === drainageInfo.id,
|
|
||||||
);
|
);
|
||||||
return existing ? { ...existing, signature } : undefined;
|
const existing = selectDrainageReportTask(tasks, channel.id, carrier);
|
||||||
})
|
return existing
|
||||||
.filter(Boolean),
|
? [
|
||||||
|
{
|
||||||
|
...existing,
|
||||||
|
id: existing.carrier ? existing.id : `virtual:${drainageInfo.id}:${channel.id}:${carrier}`,
|
||||||
|
carrier,
|
||||||
|
virtual: !existing.carrier,
|
||||||
|
signature,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return [...signatureDetails, ...drainageDetails];
|
return [...signatureDetails, ...drainageDetails];
|
||||||
})
|
})
|
||||||
@@ -551,6 +560,9 @@ export class ChannelReportingService {
|
|||||||
throw new BadRequestException('unsupported report task source entry');
|
throw new BadRequestException('unsupported report task source entry');
|
||||||
}
|
}
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const signatureId of [...new Set(data.items.map((item) => item.signatureId))].sort()) {
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
|
||||||
|
}
|
||||||
const signatureIds = [
|
const signatureIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
|
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
|
||||||
@@ -561,6 +573,7 @@ export class ChannelReportingService {
|
|||||||
reportType: 'drainage';
|
reportType: 'drainage';
|
||||||
drainageItemId: string;
|
drainageItemId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
carrier: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
@@ -577,7 +590,7 @@ export class ChannelReportingService {
|
|||||||
if (drainageInfo.auditStatus !== 'approved')
|
if (drainageInfo.auditStatus !== 'approved')
|
||||||
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
||||||
}
|
}
|
||||||
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
|
const carrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
|
||||||
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
||||||
throw new BadRequestException('报备运营商不在通道支持范围内');
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||||
}
|
}
|
||||||
@@ -587,11 +600,23 @@ export class ChannelReportingService {
|
|||||||
channelId: item.channelId,
|
channelId: item.channelId,
|
||||||
reportType,
|
reportType,
|
||||||
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
|
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
|
||||||
carrier: reportType === 'signature' ? carrier : null,
|
carrier,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (reportType === 'drainage' && !existing)
|
if (reportType === 'drainage' && !existing) {
|
||||||
throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
const legacy = carrier
|
||||||
|
? await tx.channelSignatureReportTask.findFirst({
|
||||||
|
where: {
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
channelId: item.channelId,
|
||||||
|
reportType,
|
||||||
|
drainageItemId: item.drainageItemId,
|
||||||
|
carrier: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!legacy) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
||||||
|
}
|
||||||
if (reportType === 'signature' && !carrier && !existing)
|
if (reportType === 'signature' && !carrier && !existing)
|
||||||
throw new BadRequestException('签名报备状态必须指定运营商');
|
throw new BadRequestException('签名报备状态必须指定运营商');
|
||||||
const approvedAt =
|
const approvedAt =
|
||||||
@@ -603,7 +628,7 @@ export class ChannelReportingService {
|
|||||||
const task = existing
|
const task = existing
|
||||||
? await tx.channelSignatureReportTask.update({
|
? await tx.channelSignatureReportTask.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) },
|
data: { status: item.status, reason: data.reason, approvedAt },
|
||||||
})
|
})
|
||||||
: await tx.channelSignatureReportTask.create({
|
: await tx.channelSignatureReportTask.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -638,6 +663,7 @@ export class ChannelReportingService {
|
|||||||
reportType,
|
reportType,
|
||||||
drainageItemId: item.drainageItemId!,
|
drainageItemId: item.drainageItemId!,
|
||||||
channelId: item.channelId,
|
channelId: item.channelId,
|
||||||
|
carrier,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ describe('ChannelsService', () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
const tx = {
|
const tx = {
|
||||||
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||||
channelReportField: {
|
channelReportField: {
|
||||||
findMany: jest
|
findMany: jest
|
||||||
.fn()
|
.fn()
|
||||||
@@ -519,6 +520,7 @@ describe('ChannelsService', () => {
|
|||||||
async (sourceEntry) => {
|
async (sourceEntry) => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||||
@@ -601,6 +603,7 @@ describe('ChannelsService', () => {
|
|||||||
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
|
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||||
@@ -655,6 +658,7 @@ describe('ChannelsService', () => {
|
|||||||
it('changes a drainage report task without overwriting the signature report summary', async () => {
|
it('changes a drainage report task without overwriting the signature report summary', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -699,6 +703,7 @@ describe('ChannelsService', () => {
|
|||||||
reportType: 'drainage',
|
reportType: 'drainage',
|
||||||
drainageItemId: 'drain-1',
|
drainageItemId: 'drain-1',
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
|
carrier: null,
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -1259,6 +1264,7 @@ describe('ChannelsService', () => {
|
|||||||
|
|
||||||
const transactionCallback = prisma.$transaction.mock.calls[0][0];
|
const transactionCallback = prisma.$transaction.mock.calls[0][0];
|
||||||
const tx = {
|
const tx = {
|
||||||
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||||
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
|
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
|
||||||
smsChannelGroup: {
|
smsChannelGroup: {
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** A carrier-specific decision overrides a legacy channel decision, including rejection. */
|
||||||
|
export function selectDrainageReportTask<
|
||||||
|
T extends {
|
||||||
|
channelId: string;
|
||||||
|
carrier?: string | null;
|
||||||
|
approvalScope?: string;
|
||||||
|
},
|
||||||
|
>(tasks: T[], channelId: string, carrier?: string) {
|
||||||
|
return (
|
||||||
|
(carrier ? tasks.find((task) => task.channelId === channelId && task.carrier === carrier) : undefined) ??
|
||||||
|
tasks.find((task) => task.channelId === channelId && !task.carrier && task.approvalScope !== 'carrier_specific')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,47 +1,182 @@
|
|||||||
.http-developer-docs { margin: 0; color: #1f2937; background: #f6f7f9; font: 14px/1.6 system-ui, sans-serif; }
|
.http-developer-docs {
|
||||||
.http-developer-docs * { box-sizing: border-box; }
|
margin: 0;
|
||||||
.http-developer-docs .http-doc-header { padding: 24px; border-bottom: 1px solid #e5e7eb; background: #fff; display: flex; gap: 20px; justify-content: space-between; align-items: center; }
|
color: #1f2937;
|
||||||
.http-developer-docs h1 { font-size: 24px; margin: 8px 0; }
|
background: #f6f7f9;
|
||||||
.http-developer-docs h2 { font-size: 20px; margin: 0 0 16px; }
|
font:
|
||||||
.http-developer-docs h3 { font-size: 16px; margin: 20px 0 12px; }
|
14px/1.6 system-ui,
|
||||||
.http-developer-docs p { overflow-wrap: anywhere; }
|
sans-serif;
|
||||||
.http-developer-docs a { color: #2563eb; text-decoration: none; overflow-wrap: anywhere; }
|
}
|
||||||
.http-developer-docs a:hover { text-decoration: underline; }
|
.http-developer-docs * {
|
||||||
.http-developer-docs .http-doc-actions { display: flex; gap: 16px; flex-wrap: wrap; }
|
box-sizing: border-box;
|
||||||
.http-developer-docs .http-doc-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); }
|
}
|
||||||
.http-developer-docs nav { padding: 20px; position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto; }
|
.http-developer-docs .http-doc-header {
|
||||||
.http-developer-docs nav a { display: block; padding: 7px 0; font-size: 13px; }
|
padding: 24px;
|
||||||
.http-developer-docs nav label { display: block; margin-top: 20px; }
|
border-bottom: 1px solid #e5e7eb;
|
||||||
.http-developer-docs input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font: inherit; }
|
background: #fff;
|
||||||
.http-developer-docs main { min-width: 0; }
|
display: flex;
|
||||||
.http-developer-docs section { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); border-bottom: 1px solid #e5e7eb; scroll-margin-top: 20px; }
|
gap: 20px;
|
||||||
.http-developer-docs section[hidden] { display: none; }
|
justify-content: space-between;
|
||||||
.http-developer-docs .http-doc-body { padding: 24px; background: #fff; min-width: 0; }
|
align-items: center;
|
||||||
.http-developer-docs aside { min-width: 0; padding: 24px 16px; }
|
}
|
||||||
.http-developer-docs .http-doc-sample { margin-bottom: 16px; border: 1px solid #d1d5db; border-radius: 8px; overflow: hidden; background: #fff; }
|
.http-developer-docs h1 {
|
||||||
.http-developer-docs .http-doc-sample-bar { padding: 10px; display: flex; gap: 12px; justify-content: space-between; align-items: center; font-size: 12px; color: #6b7280; }
|
font-size: 24px;
|
||||||
.http-developer-docs button { padding: 5px 12px; border: 1px solid #d1d5db; border-radius: 6px; color: #1f2937; background: #fff; cursor: pointer; flex-shrink: 0; }
|
margin: 8px 0;
|
||||||
.http-developer-docs button:focus-visible, .http-developer-docs a:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
|
}
|
||||||
.http-developer-docs pre { margin: 0; padding: 16px; overflow-x: auto; font-size: 13px; background: #f4f6f8; }
|
.http-developer-docs h2 {
|
||||||
.http-developer-docs code { font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
|
font-size: 20px;
|
||||||
.http-developer-docs .http-doc-table { overflow-x: auto; }
|
margin: 0 0 16px;
|
||||||
.http-developer-docs table { border-collapse: collapse; min-width: 100%; }
|
}
|
||||||
.http-developer-docs td { padding: 9px; border: 1px solid #e5e7eb; min-width: 100px; overflow-wrap: anywhere; }
|
.http-developer-docs h3 {
|
||||||
.http-developer-docs tr:first-child { font-weight: 600; background: #f4f6f8; }
|
font-size: 16px;
|
||||||
.http-developer-docs .http-doc-copy-status { position: fixed; bottom: 12px; right: 12px; max-width: 80vw; background: #fff; border-radius: 6px; padding: 8px; box-shadow: 0 2px 12px #0002; }
|
margin: 20px 0 12px;
|
||||||
.http-developer-docs .http-doc-copy-status:empty { display: none; }
|
}
|
||||||
|
.http-developer-docs p {
|
||||||
@media (width <= 1400px) {
|
overflow-wrap: anywhere;
|
||||||
.http-developer-docs section { grid-template-columns: minmax(0, 1fr); }
|
}
|
||||||
.http-developer-docs aside { padding: 16px 24px; }
|
.http-developer-docs a {
|
||||||
.http-developer-docs aside:empty { display: none; }
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.http-developer-docs a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 210px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.http-developer-docs nav {
|
||||||
|
padding: 20px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.http-developer-docs nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 7px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.http-developer-docs nav label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.http-developer-docs input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.http-developer-docs main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.http-developer-docs section {
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
scroll-margin-top: 20px;
|
||||||
|
}
|
||||||
|
.http-developer-docs section[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-body {
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-sample {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-sample-bar {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.http-developer-docs button {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.http-developer-docs button:focus-visible,
|
||||||
|
.http-developer-docs a:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.http-developer-docs pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 560px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #f4f6f8;
|
||||||
|
}
|
||||||
|
.http-developer-docs code {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.http-developer-docs table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.http-developer-docs td {
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
min-width: 100px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.http-developer-docs tr:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f4f6f8;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-copy-status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
max-width: 80vw;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: 0 2px 12px #0002;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-copy-status:empty {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 700px) {
|
@media (width <= 700px) {
|
||||||
.http-developer-docs .http-doc-header { padding: 16px; display: block; }
|
.http-developer-docs .http-doc-header {
|
||||||
.http-developer-docs .http-doc-layout { grid-template-columns: minmax(0, 1fr); }
|
padding: 16px;
|
||||||
.http-developer-docs nav { position: static; max-height: none; padding: 16px; }
|
display: block;
|
||||||
.http-developer-docs .http-doc-body, .http-developer-docs aside { padding: 16px; }
|
}
|
||||||
|
.http-developer-docs .http-doc-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.http-developer-docs nav {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.http-developer-docs .http-doc-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.http-developer-docs .http-doc-sample-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
|
||||||
.http-developer-docs .http-doc-sample-tabs button[aria-pressed="true"] { background: #eff6ff; color: #2563eb; border-color: #2563eb; }
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
/* global document, navigator, window, Event */
|
/* global document, navigator, window, Event */
|
||||||
const copyStatus = document.getElementById('copy-status');
|
const copyStatus = document.getElementById('copy-status');
|
||||||
document.addEventListener('click', async (event) => {
|
document.addEventListener('click', async (event) => {
|
||||||
const tab = event.target.closest('button[data-show-sample]');
|
|
||||||
if (tab) {
|
|
||||||
const aside = tab.closest('aside');
|
|
||||||
aside.querySelectorAll('.http-doc-sample').forEach((sample, index) => { sample.hidden = index !== Number(tab.dataset.showSample); });
|
|
||||||
aside.querySelectorAll('button[data-show-sample]').forEach((button) => button.setAttribute('aria-pressed', String(button === tab)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const button = event.target.closest('button[data-copy]');
|
const button = event.target.closest('button[data-copy]');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const content = document.getElementById(button.dataset.copy);
|
const content = document.getElementById(button.dataset.copy);
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ function inline(value: string): string {
|
|||||||
}
|
}
|
||||||
export function renderHttpGuide(markdown: string, origin: string) {
|
export function renderHttpGuide(markdown: string, origin: string) {
|
||||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||||
const sections: Array<{ id: string; title: string; body: string[]; samples: string[] }> = [];
|
const sections: Array<{ id: string; title: string; body: string[] }> = [];
|
||||||
let current = { id: 'introduction', title: '接入指南', body: [] as string[], samples: [] as string[] };
|
let current = { id: 'introduction', title: '接入指南', body: [] as string[] };
|
||||||
sections.push(current);
|
sections.push(current);
|
||||||
let code: string[] | null = null;
|
let code: string[] | null = null;
|
||||||
let language = '';
|
let language = '';
|
||||||
@@ -35,7 +35,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (code) {
|
if (code) {
|
||||||
if (/^```/.test(line)) {
|
if (/^```/.test(line)) {
|
||||||
current.samples.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
|
current.body.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制代码</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
|
||||||
code = null;
|
code = null;
|
||||||
} else code.push(line);
|
} else code.push(line);
|
||||||
continue;
|
continue;
|
||||||
@@ -46,7 +46,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
|||||||
closeTable();
|
closeTable();
|
||||||
sampleTitle = heading[2];
|
sampleTitle = heading[2];
|
||||||
if (heading[1].length === 2) {
|
if (heading[1].length === 2) {
|
||||||
current = { id: 'section-' + sections.length, title: heading[2], body: [], samples: [] };
|
current = { id: 'section-' + sections.length, title: heading[2], body: [] };
|
||||||
sections.push(current);
|
sections.push(current);
|
||||||
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
|
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
|
||||||
continue;
|
continue;
|
||||||
@@ -58,8 +58,9 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
closeTable();
|
closeTable();
|
||||||
|
if (line.trim().endsWith(':') && line.trim().length < 70) sampleTitle = line.trim().replace(/:$/, '');
|
||||||
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
|
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
|
||||||
}
|
}
|
||||||
closeTable();
|
closeTable();
|
||||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div><aside aria-label="${escapeHtml(section.title)} 示例">${section.samples.length > 1 ? '<div class="http-doc-sample-tabs" role="group" aria-label="切换示例">' + section.samples.map((_sample, index) => '<button type="button" data-show-sample="' + index + '" aria-pressed="' + (index === 0) + '">示例 ' + (index + 1) + '</button>').join('') + '</div>' : ''}${section.samples.map((sample, index) => sample.replace('class="http-doc-sample"', 'class="http-doc-sample"' + (index ? ' hidden' : ''))).join('')}</aside></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,17 @@ describe('HTTP API remediation boundaries', () => {
|
|||||||
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
|
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
|
||||||
expect(send).not.toHaveBeenCalled();
|
expect(send).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
it('keeps named code examples beside their source paragraphs', () => {
|
||||||
|
const html = renderHttpGuide(
|
||||||
|
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n五行原文:\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明'));
|
||||||
|
expect(html).toContain('五行原文 · text');
|
||||||
|
expect(html).not.toContain('data-show-sample');
|
||||||
|
expect(html).not.toContain('<aside');
|
||||||
|
expect(html).not.toMatch(/>示例 \d+</);
|
||||||
|
});
|
||||||
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
|
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
|
||||||
const html = renderHttpGuide(
|
const html = renderHttpGuide(
|
||||||
'**接口版本:v1 · 2026-09-14**\n## 接入\n<script>alert(1)</script>\n[bad](javascript:alert)\n```html\n<img src=x onerror=alert(1)>\n```',
|
'**接口版本:v1 · 2026-09-14**\n## 接入\n<script>alert(1)</script>\n[bad](javascript:alert)\n```html\n<img src=x onerror=alert(1)>\n```',
|
||||||
|
|||||||
@@ -372,6 +372,8 @@ describe('OperationsService', () => {
|
|||||||
id: true,
|
id: true,
|
||||||
content: true,
|
content: true,
|
||||||
hasDrainageContent: true,
|
hasDrainageContent: true,
|
||||||
|
receiptStatus: true,
|
||||||
|
deliveredAt: true,
|
||||||
tenant: { select: { id: true, name: true } },
|
tenant: { select: { id: true, name: true } },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export class OperationsMessageQueries {
|
|||||||
amountCents: true,
|
amountCents: true,
|
||||||
status: true,
|
status: true,
|
||||||
submitStatus: true,
|
submitStatus: true,
|
||||||
|
receiptStatus: true,
|
||||||
|
deliveredAt: true,
|
||||||
queuedAt: true,
|
queuedAt: true,
|
||||||
tenant: { select: { id: true, name: true } },
|
tenant: { select: { id: true, name: true } },
|
||||||
application: { select: { id: true, name: true } },
|
application: { select: { id: true, name: true } },
|
||||||
|
|||||||
@@ -235,7 +235,10 @@ export class ReportBatchGenerationService {
|
|||||||
continue;
|
continue;
|
||||||
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
|
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
|
||||||
continue;
|
continue;
|
||||||
if (scope.batchItem.reportType === 'signature' && carriers.size && task.carrier && !carriers.has(task.carrier))
|
if (
|
||||||
|
carriers.size &&
|
||||||
|
(task.carrier ? !carriers.has(task.carrier) : !carriers.has('all') && !carriers.has('legacy'))
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
const key = `${scope.batchItem.id}:${task.id}`;
|
const key = `${scope.batchItem.id}:${task.id}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
@@ -664,7 +667,7 @@ export class ReportBatchGenerationService {
|
|||||||
where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } },
|
where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
});
|
});
|
||||||
const targetCarriers = selected.reportType === 'signature' ? [...carriers].sort() : ['all'];
|
const targetCarriers = [...carriers].sort();
|
||||||
for (const carrier of targetCarriers) {
|
for (const carrier of targetCarriers) {
|
||||||
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
|
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
|
||||||
const targetReasons = [...blockedReasons];
|
const targetReasons = [...blockedReasons];
|
||||||
@@ -677,11 +680,13 @@ export class ReportBatchGenerationService {
|
|||||||
if (missing.length)
|
if (missing.length)
|
||||||
targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
|
targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
|
||||||
}
|
}
|
||||||
const existingTask = currentTasks.find(
|
const existingTask = currentTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
|
||||||
(task) => task.channelId === channel.id && (selected.reportType === 'drainage' || task.carrier === carrier),
|
|
||||||
);
|
|
||||||
if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备');
|
if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备');
|
||||||
const duplicateBatchId = priorKeys.get(businessKey);
|
const duplicateBatchId =
|
||||||
|
priorKeys.get(businessKey) ??
|
||||||
|
(selected.reportType === 'drainage'
|
||||||
|
? priorKeys.get(businessKey.replace(/:carrier:[^:]+$/, ':carrier:all'))
|
||||||
|
: undefined);
|
||||||
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
|
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
|
||||||
targets.push({
|
targets.push({
|
||||||
id: `${channel.id}:${carrier}`,
|
id: `${channel.id}:${carrier}`,
|
||||||
|
|||||||
@@ -71,47 +71,48 @@ export class ReportChannelExportService {
|
|||||||
: missing.length
|
: missing.length
|
||||||
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
|
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
|
||||||
: null;
|
: null;
|
||||||
const reportCarriers =
|
const reportCarriers = item.eligibleTargets
|
||||||
reportType === 'signature'
|
.filter((target) => target.channelId === channelId)
|
||||||
? item.eligibleTargets
|
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
|
||||||
.filter((target) => target.channelId === channelId)
|
|
||||||
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom')
|
|
||||||
: [null];
|
|
||||||
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
|
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
|
||||||
[];
|
[];
|
||||||
for (const carrier of reportCarriers) {
|
for (const carrier of reportCarriers) {
|
||||||
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
|
const entry = await this.prisma.$transaction(async (tx) => {
|
||||||
where: {
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`;
|
||||||
signatureId: item.signature.id,
|
const existingTask = await tx.channelSignatureReportTask.findFirst({
|
||||||
channelId,
|
where: {
|
||||||
carrier,
|
signatureId: item.signature.id,
|
||||||
reportType,
|
channelId,
|
||||||
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
|
carrier,
|
||||||
},
|
reportType,
|
||||||
|
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const task = existingTask
|
||||||
|
? await tx.channelSignatureReportTask.update({
|
||||||
|
where: { id: existingTask.id },
|
||||||
|
data: {
|
||||||
|
status: missingReason ? 'waiting_material' : 'exporting',
|
||||||
|
reason: missingReason,
|
||||||
|
approvedAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await tx.channelSignatureReportTask.create({
|
||||||
|
data: {
|
||||||
|
tenantId: item.signature.tenantId,
|
||||||
|
signatureId: item.signature.id,
|
||||||
|
channelId,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
reportType,
|
||||||
|
drainageItemId: item.drainageInfo?.id,
|
||||||
|
status: missingReason ? 'waiting_material' : 'exporting',
|
||||||
|
reason: missingReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { task, existingTask };
|
||||||
});
|
});
|
||||||
const task = existingTask
|
tasks.push(entry);
|
||||||
? await this.prisma.channelSignatureReportTask.update({
|
|
||||||
where: { id: existingTask.id },
|
|
||||||
data: {
|
|
||||||
status: missingReason ? 'waiting_material' : 'exporting',
|
|
||||||
reason: missingReason,
|
|
||||||
...(reportType === 'signature' ? { approvedAt: null } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: await this.prisma.channelSignatureReportTask.create({
|
|
||||||
data: {
|
|
||||||
tenantId: item.signature.tenantId,
|
|
||||||
signatureId: item.signature.id,
|
|
||||||
channelId,
|
|
||||||
carrier,
|
|
||||||
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel',
|
|
||||||
reportType,
|
|
||||||
drainageItemId: item.drainageInfo?.id,
|
|
||||||
status: missingReason ? 'waiting_material' : 'exporting',
|
|
||||||
reason: missingReason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tasks.push({ task, existingTask });
|
|
||||||
}
|
}
|
||||||
const task = tasks[0].task;
|
const task = tasks[0].task;
|
||||||
if (missingReason) {
|
if (missingReason) {
|
||||||
|
|||||||
@@ -128,3 +128,17 @@ describe('drainage authorization', () => {
|
|||||||
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false);
|
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('drainage carrier override', () => {
|
||||||
|
it('uses explicit rejection over legacy approval and keeps other carriers independent', () => {
|
||||||
|
const row = material('m', 'example.com', []);
|
||||||
|
row.reportTasks = [
|
||||||
|
{ id: 'legacy', channelId: 'c', carrier: null, status: 'approved' },
|
||||||
|
{ id: 'mobile', channelId: 'c', carrier: 'mobile', status: 'failed' },
|
||||||
|
{ id: 'unicom', channelId: 'c', carrier: 'unicom', status: 'approved' },
|
||||||
|
];
|
||||||
|
expect(assessDrainage([target('example.com')], [row], 'mobile').allowedChannelIds).toEqual([]);
|
||||||
|
expect(assessDrainage([target('example.com')], [row], 'unicom').allowedChannelIds).toEqual(['c']);
|
||||||
|
expect(assessDrainage([target('example.com')], [row], 'telecom').allowedChannelIds).toEqual(['c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||||
import { isIP } from 'node:net';
|
import { isIP } from 'node:net';
|
||||||
import { parse } from 'tldts';
|
import { parse } from 'tldts';
|
||||||
import type { PrismaService } from '../prisma/prisma.service';
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -19,7 +20,7 @@ export type DrainageMaterial = {
|
|||||||
url: string;
|
url: string;
|
||||||
auditStatus: string;
|
auditStatus: string;
|
||||||
materialVersion: number;
|
materialVersion: number;
|
||||||
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>;
|
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; approvalScope?: string; status: string }>;
|
||||||
};
|
};
|
||||||
export type DrainageAssessment = {
|
export type DrainageAssessment = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -138,8 +139,9 @@ export function assessDrainage(
|
|||||||
}
|
}
|
||||||
const channels = new Set(
|
const channels = new Set(
|
||||||
approved.flatMap((item) =>
|
approved.flatMap((item) =>
|
||||||
item.reportTasks
|
[...new Set(item.reportTasks.map((task) => task.channelId))]
|
||||||
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
|
.map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier))
|
||||||
|
.filter((task): task is NonNullable<typeof task> => task?.status === 'approved')
|
||||||
.map((task) => task.channelId),
|
.map((task) => task.channelId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomInt, randomUUID } from 'node:crypto';
|
|
||||||
import { isIpAllowed } from '../common/ip-allowlist';
|
|
||||||
import { assertMoneyUnits } from '../common/money';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
import type {
|
||||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
CreateSmsDrainageInfoDto,
|
||||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
CreateSmsSignatureOptions,
|
||||||
|
DrainageInfoListQuery,
|
||||||
|
ReviewDto,
|
||||||
|
StatusChangeDto,
|
||||||
|
UpdateSmsDrainageInfoDto,
|
||||||
|
} from './sms-config.contracts';
|
||||||
|
import { isRecord } from './sms-config.helpers';
|
||||||
import { SmsReportValidationService } from './report-validation.service';
|
import { SmsReportValidationService } from './report-validation.service';
|
||||||
import { SmsAuditService } from './audit.service';
|
import { SmsAuditService } from './audit.service';
|
||||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||||
@@ -20,67 +23,98 @@ function normalizeDrainageTarget(value?: string) {
|
|||||||
|
|
||||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||||
export class SmsDrainageService {
|
export class SmsDrainageService {
|
||||||
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly reportValidation: SmsReportValidationService,
|
||||||
|
private readonly audit: SmsAuditService,
|
||||||
|
) {}
|
||||||
|
private async assertUniqueTarget(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
signatureId: string,
|
||||||
|
target: string,
|
||||||
|
excludeId?: string,
|
||||||
|
) {
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
|
||||||
|
const duplicate = await tx.smsDrainageInfo.findFirst({
|
||||||
|
where: {
|
||||||
|
signatureId,
|
||||||
|
url: target,
|
||||||
|
auditStatus: { not: 'deleted' },
|
||||||
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate) throw new BadRequestException('同一签名下已存在相同的引流信息');
|
||||||
|
}
|
||||||
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
|
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
|
||||||
return this.prisma.smsDrainageInfo.findMany({
|
return this.prisma.smsDrainageInfo.findMany({
|
||||||
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
|
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
tenantId: true,
|
tenantId: true,
|
||||||
signatureId: true,
|
signatureId: true,
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
siteName: true,
|
siteName: true,
|
||||||
url: true,
|
url: true,
|
||||||
remark: true,
|
remark: true,
|
||||||
reportValues: true,
|
reportValues: true,
|
||||||
auditStatus: true,
|
auditStatus: true,
|
||||||
rejectReason: true,
|
rejectReason: true,
|
||||||
submittedAt: true,
|
submittedAt: true,
|
||||||
reviewedAt: true,
|
reviewedAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
signature: { select: { id: true, name: true, auditStatus: true } },
|
signature: { select: { id: true, name: true, auditStatus: true } },
|
||||||
application: { select: { id: true, name: true, status: true } },
|
application: { select: { id: true, name: true, status: true } },
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
|
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
|
||||||
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
|
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
|
||||||
if (!item) throw new NotFoundException('Drainage info not found');
|
if (!item) throw new NotFoundException('Drainage info not found');
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
listDrainageInfos(query: DrainageInfoListQuery = {}) {
|
listDrainageInfos(query: DrainageInfoListQuery = {}) {
|
||||||
return this.prisma.smsDrainageInfo.findMany({
|
return this.prisma.smsDrainageInfo.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId: query.tenantId,
|
tenantId: query.tenantId,
|
||||||
signatureId: query.signatureId,
|
signatureId: query.signatureId,
|
||||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||||
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
||||||
OR: query.keyword ? [
|
OR: query.keyword
|
||||||
{ siteName: { contains: query.keyword } },
|
? [
|
||||||
{ url: { contains: query.keyword } },
|
{ siteName: { contains: query.keyword } },
|
||||||
{ signature: { name: { contains: query.keyword } } },
|
{ url: { contains: query.keyword } },
|
||||||
{ tenant: { name: { contains: query.keyword } } },
|
{ signature: { name: { contains: query.keyword } } },
|
||||||
{ application: { name: { contains: query.keyword } } },
|
{ tenant: { name: { contains: query.keyword } } },
|
||||||
] : undefined,
|
{ application: { name: { contains: query.keyword } } },
|
||||||
},
|
]
|
||||||
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
|
: undefined,
|
||||||
orderBy: { updatedAt: 'desc' },
|
},
|
||||||
});
|
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
|
||||||
}
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
|
async createDrainageInfo(
|
||||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
signatureId: string,
|
||||||
if (!signature) throw new NotFoundException('Signature not found');
|
data: CreateSmsDrainageInfoDto,
|
||||||
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
|
options: CreateSmsSignatureOptions = {},
|
||||||
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
|
tenantId?: string,
|
||||||
const target = normalizeDrainageTarget(data.url);
|
) {
|
||||||
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
|
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
if (!signature) throw new NotFoundException('Signature not found');
|
||||||
const item = await this.prisma.smsDrainageInfo.create({
|
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
|
||||||
|
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
|
||||||
|
const target = normalizeDrainageTarget(data.url);
|
||||||
|
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
|
||||||
|
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||||
|
const item = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.assertUniqueTarget(tx, signatureId, target);
|
||||||
|
return tx.smsDrainageInfo.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: signature.tenantId,
|
tenantId: signature.tenantId,
|
||||||
signatureId,
|
signatureId,
|
||||||
@@ -94,28 +128,65 @@ export class SmsDrainageService {
|
|||||||
},
|
},
|
||||||
include: { tenant: true, signature: true, application: true },
|
include: { tenant: true, signature: true, application: true },
|
||||||
});
|
});
|
||||||
await this.audit.createAuditRecord({
|
});
|
||||||
tenantId: item.tenantId,
|
await this.audit.createAuditRecord({
|
||||||
targetType: 'sms_drainage_info',
|
tenantId: item.tenantId,
|
||||||
targetId: item.id,
|
targetType: 'sms_drainage_info',
|
||||||
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
|
targetId: item.id,
|
||||||
statusAfter: auditStatus,
|
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
|
||||||
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
|
statusAfter: auditStatus,
|
||||||
});
|
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
|
||||||
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
|
});
|
||||||
return item;
|
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
|
||||||
}
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
|
async updateDrainageInfo(
|
||||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
|
itemId: string,
|
||||||
if (!current) throw new NotFoundException('Drainage info not found');
|
data: UpdateSmsDrainageInfoDto,
|
||||||
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
options: CreateSmsSignatureOptions = {},
|
||||||
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
|
tenantId?: string,
|
||||||
const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url);
|
) {
|
||||||
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
|
const current = await this.prisma.smsDrainageInfo.findUnique({
|
||||||
await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
|
where: { id: itemId },
|
||||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
include: { signature: true },
|
||||||
const updated = await this.prisma.smsDrainageInfo.update({
|
});
|
||||||
|
if (!current) throw new NotFoundException('Drainage info not found');
|
||||||
|
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
||||||
|
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
|
||||||
|
const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url);
|
||||||
|
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
|
||||||
|
await this.reportValidation.validateDrainageReportValues(
|
||||||
|
applicationId,
|
||||||
|
data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}),
|
||||||
|
);
|
||||||
|
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${current.signatureId}, 910))`;
|
||||||
|
const latest = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||||
|
if (!latest || latest.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
|
||||||
|
if (target !== undefined && target !== latest.url)
|
||||||
|
await this.assertUniqueTarget(tx, current.signatureId, target, itemId);
|
||||||
|
const priorTasks = await tx.channelSignatureReportTask.findMany({
|
||||||
|
where: { drainageItemId: itemId, reportType: 'drainage' },
|
||||||
|
});
|
||||||
|
for (const task of priorTasks) {
|
||||||
|
await tx.channelSignatureReportTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: { status: 'waiting_review', approvedAt: null, reason: '引流资料修改,原报备失效' },
|
||||||
|
});
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
channelId: task.channelId,
|
||||||
|
action: 'material_changed',
|
||||||
|
statusBefore: task.status,
|
||||||
|
statusAfter: 'waiting_review',
|
||||||
|
reason: '引流资料修改,原报备失效',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.smsDrainageInfo.update({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
data: {
|
data: {
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -133,37 +204,51 @@ export class SmsDrainageService {
|
|||||||
},
|
},
|
||||||
include: { tenant: true, signature: true, application: true },
|
include: { tenant: true, signature: true, application: true },
|
||||||
});
|
});
|
||||||
await this.audit.createAuditRecord({
|
});
|
||||||
tenantId: current.tenantId,
|
await this.audit.createAuditRecord({
|
||||||
targetType: 'sms_drainage_info',
|
tenantId: current.tenantId,
|
||||||
targetId: itemId,
|
targetType: 'sms_drainage_info',
|
||||||
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
|
targetId: itemId,
|
||||||
statusBefore: current.auditStatus,
|
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
|
||||||
statusAfter: auditStatus,
|
statusBefore: current.auditStatus,
|
||||||
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
|
statusAfter: auditStatus,
|
||||||
});
|
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
|
||||||
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
|
});
|
||||||
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
|
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
|
||||||
return updated;
|
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
|
||||||
}
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
approveDrainageInfo(itemId: string, data: ReviewDto) {
|
approveDrainageInfo(itemId: string, data: ReviewDto) {
|
||||||
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
|
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
rejectDrainageInfo(itemId: string, data: ReviewDto) {
|
rejectDrainageInfo(itemId: string, data: ReviewDto) {
|
||||||
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
|
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
|
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
|
||||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||||
if (!current) throw new NotFoundException('Drainage info not found');
|
if (!current) throw new NotFoundException('Drainage info not found');
|
||||||
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
||||||
const status = data.status ?? 'deleted';
|
const status = data.status ?? 'deleted';
|
||||||
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
|
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
|
||||||
const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
if (status === 'deleted') await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
|
// Restoration must obey the same uniqueness lock as create and edit.
|
||||||
await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
|
if (status !== 'deleted') await this.assertUniqueTarget(tx, current.signatureId, current.url, itemId);
|
||||||
return updated;
|
return tx.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
|
||||||
}
|
});
|
||||||
|
if (status === 'deleted')
|
||||||
|
await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
|
||||||
|
await this.audit.createAuditRecord({
|
||||||
|
tenantId: current.tenantId,
|
||||||
|
targetType: 'sms_drainage_info',
|
||||||
|
targetId: itemId,
|
||||||
|
action: status,
|
||||||
|
statusBefore: current.auditStatus,
|
||||||
|
statusAfter: status,
|
||||||
|
reason: data.reason,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,186 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||||
import { Prisma } from '@prisma/client';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { randomInt, randomUUID } from 'node:crypto';
|
|
||||||
import { isIpAllowed } from '../common/ip-allowlist';
|
|
||||||
import { assertMoneyUnits } from '../common/money';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
import { hasReportValue, isRecord, reportValueParts } from './sms-config.helpers';
|
||||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
|
||||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
|
||||||
import { SmsApplicationConfigService } from './application-config.service';
|
import { SmsApplicationConfigService } from './application-config.service';
|
||||||
|
|
||||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||||
export class SmsReportValidationService {
|
export class SmsReportValidationService {
|
||||||
constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly applications: SmsApplicationConfigService,
|
||||||
|
) {}
|
||||||
async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||||
if (!drainageInfo) return drainageInfo;
|
if (!drainageInfo) return drainageInfo;
|
||||||
const fields = await this.applications.getApplicationReportFields(applicationId);
|
const fields = await this.applications.getApplicationReportFields(applicationId);
|
||||||
return {
|
return {
|
||||||
...drainageInfo,
|
...drainageInfo,
|
||||||
reportRequirementSnapshot: {
|
reportRequirementSnapshot: {
|
||||||
capturedAt: new Date().toISOString(),
|
capturedAt: new Date().toISOString(),
|
||||||
applicationId,
|
applicationId,
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
id: field.id,
|
id: field.id,
|
||||||
code: field.code,
|
code: field.code,
|
||||||
name: field.name,
|
name: field.name,
|
||||||
fieldType: field.fieldType,
|
fieldType: field.fieldType,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
reportTypes: field.reportTypes,
|
reportTypes: field.reportTypes,
|
||||||
commonReportTypes: field.commonReportTypes,
|
commonReportTypes: field.commonReportTypes,
|
||||||
channels: field.channels,
|
channels: field.channels,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||||
if (!drainageInfo) return;
|
if (!drainageInfo) return;
|
||||||
const fields = await this.applications.getApplicationReportFields(applicationId);
|
const fields = await this.applications.getApplicationReportFields(applicationId);
|
||||||
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||||
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
|
for (const field of fields.filter((item) =>
|
||||||
const value = reportValueParts(signatureValues[field.code]);
|
item.reportTypes.some((type) => type === 'signature' || type === 'both'),
|
||||||
for (const channel of field.channels) {
|
)) {
|
||||||
await this.prisma.signatureReportMaterial.upsert({
|
const value = reportValueParts(signatureValues[field.code]);
|
||||||
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
|
for (const channel of field.channels) {
|
||||||
update: value,
|
await this.prisma.signatureReportMaterial.upsert({
|
||||||
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
|
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
|
||||||
});
|
update: value,
|
||||||
}
|
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||||
const fields = await this.applications.getApplicationReportFields(applicationId, 'signature');
|
const fields = await this.applications.getApplicationReportFields(applicationId, 'signature');
|
||||||
const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||||
const missingSignature = fields
|
const missingSignature = fields
|
||||||
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
|
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
|
||||||
.filter((field) => !hasReportValue(signatureValues[field.code]));
|
.filter((field) => !hasReportValue(signatureValues[field.code]));
|
||||||
if (missingSignature.length > 0) {
|
if (missingSignature.length > 0) {
|
||||||
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
|
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
|
async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
|
||||||
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
|
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
|
||||||
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
|
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
|
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async activateDrainageReporting(itemId: string) {
|
async activateDrainageReporting(itemId: string) {
|
||||||
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
|
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
|
||||||
if (!item) throw new NotFoundException('Drainage info not found');
|
if (!item) throw new NotFoundException('Drainage info not found');
|
||||||
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||||
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
|
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
|
||||||
if (!applicationId) return;
|
if (!applicationId) return;
|
||||||
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage'))
|
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage')).filter((field) =>
|
||||||
.filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both'));
|
field.reportTypes.some((type) => type === 'drainage' || type === 'both'),
|
||||||
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
|
);
|
||||||
const values = isRecord(item.reportValues) ? item.reportValues : {};
|
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
|
||||||
await this.prisma.$transaction(async (tx) => {
|
const values = isRecord(item.reportValues) ? item.reportValues : {};
|
||||||
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
await this.prisma.$transaction(async (tx) => {
|
||||||
for (const field of fields) {
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signatureId}, 910))`;
|
||||||
const value = reportValueParts(values[field.code]);
|
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
||||||
for (const channel of field.channels) {
|
for (const field of fields) {
|
||||||
await tx.drainageReportMaterial.create({
|
const value = reportValueParts(values[field.code]);
|
||||||
data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value },
|
for (const channel of field.channels) {
|
||||||
});
|
await tx.drainageReportMaterial.create({
|
||||||
}
|
data: {
|
||||||
}
|
signatureId: item.signatureId,
|
||||||
const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
|
drainageItemId: item.id,
|
||||||
const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task]));
|
channelId: channel.id,
|
||||||
for (const channel of channels.values()) {
|
fieldCode: field.code,
|
||||||
const existing = existingByChannel.get(channel.id);
|
...value,
|
||||||
const task = existing
|
},
|
||||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } })
|
|
||||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } });
|
|
||||||
await tx.channelSignatureReportRecord.create({
|
|
||||||
data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) {
|
}
|
||||||
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } });
|
const existingTasks = await tx.channelSignatureReportTask.findMany({
|
||||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } });
|
where: { drainageItemId: item.id, reportType: 'drainage' },
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
const configuredChannels = await tx.smsChannel.findMany({
|
||||||
|
where: { id: { in: [...channels.keys()] }, status: { not: 'deleted' } },
|
||||||
|
});
|
||||||
|
const activeKeys = new Set<string>();
|
||||||
|
for (const channel of configuredChannels) {
|
||||||
|
for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) {
|
||||||
|
const key = `${channel.id}:${carrier}`;
|
||||||
|
activeKeys.add(key);
|
||||||
|
const existing = existingTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
|
||||||
|
const task = existing
|
||||||
|
? await tx.channelSignatureReportTask.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { status: 'pending', reason: null, approvedAt: null },
|
||||||
|
})
|
||||||
|
: await tx.channelSignatureReportTask.create({
|
||||||
|
data: {
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
channelId: channel.id,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
reportType: 'drainage',
|
||||||
|
drainageItemId: item.id,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
action: existing ? 'audit_approved_reset' : 'audit_approved_create',
|
||||||
|
statusBefore: existing?.status,
|
||||||
|
statusAfter: 'pending',
|
||||||
|
reason: '引流信息运营审核通过,按运营商重新报备',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const task of existingTasks.filter(
|
||||||
|
(current) => !activeKeys.has(`${current.channelId}:${current.carrier}`) && current.status !== 'abandoned',
|
||||||
|
)) {
|
||||||
|
const reason = '资料版本更新或应用路由已不包含此通道运营商';
|
||||||
|
await tx.channelSignatureReportTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: { status: 'abandoned', reason, approvedAt: null },
|
||||||
|
});
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
channelId: task.channelId,
|
||||||
|
action: 'route_removed',
|
||||||
|
statusBefore: task.status,
|
||||||
|
statusAfter: 'abandoned',
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
|
async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||||
if (!item) throw new NotFoundException('Drainage info not found');
|
if (!item) throw new NotFoundException('Drainage info not found');
|
||||||
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
||||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
|
const tasks = await tx.channelSignatureReportTask.findMany({
|
||||||
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
|
where: { drainageItemId: item.id, reportType: 'drainage' },
|
||||||
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
|
|
||||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
|
||||||
|
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
channelId: task.channelId,
|
||||||
|
action: 'audit_suspended',
|
||||||
|
statusBefore: task.status,
|
||||||
|
statusAfter,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,15 @@
|
|||||||
import {
|
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||||
BadRequestException,
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
OnModuleDestroy,
|
|
||||||
OnModuleInit,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomInt, randomUUID } from 'node:crypto';
|
|
||||||
import { isIpAllowed } from '../common/ip-allowlist';
|
|
||||||
import { assertMoneyUnits } from '../common/money';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
|
||||||
import type {
|
import type {
|
||||||
ApplicationListQuery,
|
|
||||||
CreateSignatureMaterialDto,
|
CreateSignatureMaterialDto,
|
||||||
CreateSmsApplicationDto,
|
|
||||||
CreateSmsDrainageInfoDto,
|
|
||||||
CreateSmsSignatureDto,
|
CreateSmsSignatureDto,
|
||||||
CreateSmsSignatureOptions,
|
CreateSmsSignatureOptions,
|
||||||
CreateSmsTemplateDto,
|
|
||||||
CreateSmsTemplateOptions,
|
|
||||||
DrainageInfoListQuery,
|
|
||||||
GatewayDownstreamConnectionEventDto,
|
|
||||||
ReplaceApplicationRouteRulesDto,
|
|
||||||
ReviewDto,
|
|
||||||
SignatureListQuery,
|
SignatureListQuery,
|
||||||
StatusChangeDto,
|
|
||||||
TemplateListQuery,
|
|
||||||
UpdateSmsApplicationDto,
|
|
||||||
UpdateSmsDrainageInfoDto,
|
|
||||||
UpdateSmsSignatureDto,
|
UpdateSmsSignatureDto,
|
||||||
UpdateSmsTemplateDto,
|
|
||||||
} from './sms-config.contracts';
|
} from './sms-config.contracts';
|
||||||
import {
|
import { isRecord, normalizeSmsSignature, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||||
APPLICATION_DISABLE_GRACE_MS,
|
|
||||||
DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS,
|
|
||||||
DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS,
|
|
||||||
UNRESOLVED_DOWNSTREAM_STATUSES,
|
|
||||||
type TemplateVariableInput,
|
|
||||||
estimateBillingUnits,
|
|
||||||
generateApplicationPassword,
|
|
||||||
getPositiveInteger,
|
|
||||||
getPositiveIntegerEnv,
|
|
||||||
hasReportValue,
|
|
||||||
inferTemplateVariables,
|
|
||||||
isRecord,
|
|
||||||
normalizeApplicationCmppStatus,
|
|
||||||
normalizeApplicationInterfaceType,
|
|
||||||
normalizeApplicationPassword,
|
|
||||||
normalizeApplicationQueuePriority,
|
|
||||||
normalizeCmppAccessNumberConfig,
|
|
||||||
normalizeSmsSignature,
|
|
||||||
parseGatewayDate,
|
|
||||||
reportValueParts,
|
|
||||||
startOfToday,
|
|
||||||
validateAndNormalizeTemplateVariables,
|
|
||||||
validateCompleteSmsSignature,
|
|
||||||
} from './sms-config.helpers';
|
|
||||||
import { SmsReportValidationService } from './report-validation.service';
|
import { SmsReportValidationService } from './report-validation.service';
|
||||||
import { SmsAuditService } from './audit.service';
|
import { SmsAuditService } from './audit.service';
|
||||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||||
@@ -202,6 +153,7 @@ export class SmsSignatureService {
|
|||||||
.then((count) => count > 0);
|
.then((count) => count > 0);
|
||||||
return signatures.map((signature) => {
|
return signatures.map((signature) => {
|
||||||
const { reportBatchItems: _reportBatchItems, ...signatureView } = signature;
|
const { reportBatchItems: _reportBatchItems, ...signatureView } = signature;
|
||||||
|
void _reportBatchItems;
|
||||||
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||||
const applicationChannels = [
|
const applicationChannels = [
|
||||||
...new Map(
|
...new Map(
|
||||||
@@ -305,64 +257,70 @@ export class SmsSignatureService {
|
|||||||
pendingReportBlockedReason,
|
pendingReportBlockedReason,
|
||||||
drainageReportTargets: Object.fromEntries(
|
drainageReportTargets: Object.fromEntries(
|
||||||
signature.drainageItems.map((drainageItem) => {
|
signature.drainageItems.map((drainageItem) => {
|
||||||
const drainageItemId = drainageItem.id;
|
const tasks = (signature.reportTasks ?? []).filter(
|
||||||
const channels = routes
|
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id,
|
||||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
);
|
||||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
const targets = applicationChannels
|
||||||
.filter(
|
.filter(
|
||||||
(channel) =>
|
(channel) =>
|
||||||
channel.status !== 'deleted' &&
|
hasCommonDrainageFields ||
|
||||||
(hasCommonDrainageFields ||
|
channel.reportFields.some(
|
||||||
channel.reportFields.some(
|
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
|
||||||
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
|
),
|
||||||
)),
|
)
|
||||||
|
.flatMap((channel) =>
|
||||||
|
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
|
||||||
|
const task = selectDrainageReportTask(tasks, channel.id, carrier);
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
channelId: channel.id,
|
||||||
|
carrier,
|
||||||
|
status: task?.status ?? 'pending',
|
||||||
|
taskId: task?.id,
|
||||||
|
approvedAt: task?.approvedAt,
|
||||||
|
approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific',
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const taskByChannel = new Map(
|
return [drainageItem.id, targets];
|
||||||
(signature.reportTasks ?? [])
|
|
||||||
.filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId)
|
|
||||||
.map((task) => [task.channelId, task]),
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
drainageItemId,
|
|
||||||
[...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
|
|
||||||
const task = taskByChannel.get(channel.id);
|
|
||||||
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
drainageCarrierReportSummary: Object.fromEntries(
|
drainageCarrierReportSummary: Object.fromEntries(
|
||||||
signature.drainageItems.map((drainageItem) => {
|
signature.drainageItems.map((drainageItem) => {
|
||||||
const drainageItemId = drainageItem.id;
|
const tasks = (signature.reportTasks ?? []).filter(
|
||||||
const channels = routes
|
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id,
|
||||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
);
|
||||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
const targets = applicationChannels
|
||||||
.filter(
|
.filter(
|
||||||
(channel) =>
|
(channel) =>
|
||||||
channel.status !== 'deleted' &&
|
hasCommonDrainageFields ||
|
||||||
(hasCommonDrainageFields ||
|
channel.reportFields.some(
|
||||||
channel.reportFields.some(
|
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
|
||||||
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
|
),
|
||||||
)),
|
)
|
||||||
);
|
.flatMap((channel) =>
|
||||||
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
|
||||||
const taskByChannel = new Map(
|
const task = selectDrainageReportTask(tasks, channel.id, carrier);
|
||||||
(signature.reportTasks ?? [])
|
return {
|
||||||
.filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId)
|
channel,
|
||||||
.map((task) => [task.channelId, task]),
|
channelId: channel.id,
|
||||||
);
|
carrier,
|
||||||
return [
|
status: task?.status ?? 'pending',
|
||||||
drainageItemId,
|
taskId: task?.id,
|
||||||
Object.fromEntries(
|
approvedAt: task?.approvedAt,
|
||||||
['mobile', 'unicom', 'telecom'].map((carrier) => {
|
approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific',
|
||||||
const carrierTargets = targets.filter((channel) =>
|
};
|
||||||
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
|
|
||||||
);
|
|
||||||
const statuses = carrierTargets.flatMap((channel) =>
|
|
||||||
taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : [],
|
|
||||||
);
|
|
||||||
return [carrier, summarizeReportStatuses(statuses)];
|
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
drainageItem.id,
|
||||||
|
Object.fromEntries(
|
||||||
|
['mobile', 'unicom', 'telecom'].map((carrier) => [
|
||||||
|
carrier,
|
||||||
|
summarizeReportStatuses(
|
||||||
|
targets.filter((target) => target.carrier === carrier).map((target) => target.status),
|
||||||
|
),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
@@ -400,10 +358,14 @@ export class SmsSignatureService {
|
|||||||
drainageReportTargets: _drainageReportTargets,
|
drainageReportTargets: _drainageReportTargets,
|
||||||
...summary
|
...summary
|
||||||
} = view;
|
} = view;
|
||||||
|
void [_materials, _reportTasks, _reportTargets, _drainageReportTargets];
|
||||||
return {
|
return {
|
||||||
...summary,
|
...summary,
|
||||||
drainageInfo: {
|
drainageInfo: {
|
||||||
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link),
|
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => {
|
||||||
|
void _reportValues;
|
||||||
|
return link;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -508,7 +470,10 @@ export class SmsSignatureService {
|
|||||||
for (const value of businessKeys) {
|
for (const value of businessKeys) {
|
||||||
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
|
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean))
|
for (const carrier of match[2]
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean))
|
||||||
generatedTargets.add(`${match[1]}:${carrier}`);
|
generatedTargets.add(`${match[1]}:${carrier}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,7 +499,8 @@ export class SmsSignatureService {
|
|||||||
candidate.approvalScope === 'legacy_channel',
|
candidate.approvalScope === 'legacy_channel',
|
||||||
);
|
);
|
||||||
if (task?.status === 'abandoned') continue;
|
if (task?.status === 'abandoned') continue;
|
||||||
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
|
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`))
|
||||||
|
continue;
|
||||||
detailTotal += 1;
|
detailTotal += 1;
|
||||||
hasPendingTarget = true;
|
hasPendingTarget = true;
|
||||||
}
|
}
|
||||||
@@ -552,7 +518,7 @@ export class SmsSignatureService {
|
|||||||
|
|
||||||
async getSignatureReportTargets(id: string) {
|
async getSignatureReportTargets(id: string) {
|
||||||
const item = await this.getSignature(id);
|
const item = await this.getSignature(id);
|
||||||
return 'reportTargets' in item ? item.reportTargets ?? [] : [];
|
return 'reportTargets' in item ? (item.reportTargets ?? []) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDrainageReportTargets(id: string) {
|
async getDrainageReportTargets(id: string) {
|
||||||
@@ -562,7 +528,7 @@ export class SmsSignatureService {
|
|||||||
});
|
});
|
||||||
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found');
|
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found');
|
||||||
const signature = await this.getSignature(drainage.signatureId);
|
const signature = await this.getSignature(drainage.signatureId);
|
||||||
return 'drainageReportTargets' in signature ? signature.drainageReportTargets?.[id] ?? [] : [];
|
return 'drainageReportTargets' in signature ? (signature.drainageReportTargets?.[id] ?? []) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
listSignatureOptions(tenantId?: string) {
|
listSignatureOptions(tenantId?: string) {
|
||||||
@@ -876,10 +842,7 @@ export class SmsSignatureService {
|
|||||||
updated.applicationId ?? undefined,
|
updated.applicationId ?? undefined,
|
||||||
drainageInfo,
|
drainageInfo,
|
||||||
);
|
);
|
||||||
if (
|
if (options.initialAuditStatus === 'approved' && (materialChanged || signature.auditStatus !== 'approved')) {
|
||||||
options.initialAuditStatus === 'approved' &&
|
|
||||||
(materialChanged || signature.auditStatus !== 'approved')
|
|
||||||
) {
|
|
||||||
await this.audit.createAuditRecord({
|
await this.audit.createAuditRecord({
|
||||||
tenantId: signature.tenantId,
|
tenantId: signature.tenantId,
|
||||||
targetType: 'sms_signature',
|
targetType: 'sms_signature',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+504
-502
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
# 客户端页面与接口文档整改
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 客户端页面与接口文档整理设计
|
||||||
|
|
||||||
|
本轮按用户要求提交、推送并部署测试环境;不操作预生产,不发送或重新入队短信。基于 main 7f9abe3 实施,发布包含上一轮引流运营商迁移,须独立复核兼容。
|
||||||
|
|
||||||
|
- 模板页添加按钮按内容宽度,工具栏使用输入+查询操作+新增三列,窄屏堆叠;正文固定 132px、14px 字体和纵向滚动,覆盖本需求对常规三行截断的例外。页脚使用公共 Button/DeleteRiskAction,移除本页对旧通用 footer 按钮样式的依赖,保留删除风险确认。编辑顺序为应用、名称、签名、内容;不再展示或发送 category,旧数据库分类保留,不清空历史数据。
|
||||||
|
- 接口文档新增独立客户端路由 /client/http-docs 和导航,从接口配置页移除文档 Tab。共享服务端 /api/client-docs 仍为唯一内容源;示例紧随所属正文,使用说明名称,不按数字分组。第 11 节示例按鉴权、发送、查询、上行、回调、错误处理归并,不修改签名协议/GET 兼容摘要、认证或安全边界。
|
||||||
|
- 工作台复用批次统一中文状态映射;最近批次列改为短信内容并读取 content,避免使用 category;保留详情入口及批次范围。
|
||||||
|
- 回执 Bug:测试库已送达记录存在 receiptStatus/deliveredAt,而分页投影未查询两字段,客户白名单视图输出 null。分页补两个主记录标量,不全量加载回执关联、不改回执消费/计费/数据。展示以消息最终聚合状态为准,时间统一北京时间;真实未收到回执保留空时间并明确显示暂无回执。
|
||||||
|
- 验收覆盖真实 API/PG 的有回执、无回执、失败回执与分页/租户边界;UI 三尺寸、首次进入/刷新/跨路由、长正文滚动、按钮 hover、编辑顺序、文档示例归属及检索/复制;完整测试、类型、构建、样式、安全/标准发布门禁。
|
||||||
@@ -177,3 +177,19 @@ Gateway 每个分片等待可用连接后通过仅本机直连的 POST /api/gate
|
|||||||
新增迁移 20260910130000_drainage_send_gate 只加列、索引、决策表及锁触发器,不改既有批准/客户/余额/短信记录。测试发布按标准工具执行,包含此前九项运营修复提交;回退旧程序会失去本门禁,不能未经评估恢复发送。原治理工具草稿和备份/候选均保留。
|
新增迁移 20260910130000_drainage_send_gate 只加列、索引、决策表及锁触发器,不改既有批准/客户/余额/短信记录。测试发布按标准工具执行,包含此前九项运营修复提交;回退旧程序会失去本门禁,不能未经评估恢复发送。原治理工具草稿和备份/候选均保留。
|
||||||
|
|
||||||
验证:独立本机 PostgreSQL 克隆库完成新迁移,真实规则/API验证 NFKC号码、全部目标交集、审核撤销、报备撤销、并发锁等待、URL三种伪装拒绝、决策持久化及报备SQL。真实浏览器连接该API验证拦截详情、刷新、路由切换和1600×1000、1366×768、390×844;无Browser插件,使用既有Playwright/Edge。发送Worker与Gateway传输未启动,不以这些证据替代供应商零Submit、客户回执ACK、长短信物理发送、费用对账或容量测试,以上须专项发送授权后验证。自动回归及发布结果以 testing-progress.md 最新记录为准。
|
验证:独立本机 PostgreSQL 克隆库完成新迁移,真实规则/API验证 NFKC号码、全部目标交集、审核撤销、报备撤销、并发锁等待、URL三种伪装拒绝、决策持久化及报备SQL。真实浏览器连接该API验证拦截详情、刷新、路由切换和1600×1000、1366×768、390×844;无Browser插件,使用既有Playwright/Edge。发送Worker与Gateway传输未启动,不以这些证据替代供应商零Submit、客户回执ACK、长短信物理发送、费用对账或容量测试,以上须专项发送授权后验证。自动回归及发布结果以 testing-progress.md 最新记录为准。
|
||||||
|
|
||||||
|
|
||||||
|
## 11. 2026-09-14 引流唯一性与通道运营商报备设计
|
||||||
|
|
||||||
|
状态:本地实现与隔离验收完成;线上未部署,版本状态以 testing-progress.md 本轮记录为准。此节替代引流报备只按channelId及carrier=null通配的新增配置方式;域名匹配、平台审核、多目标交集、计费和Gateway复核协议保持。
|
||||||
|
|
||||||
|
1. 新增/修改及恢复引流:同一signatureId下未删除资料的引流值不得重复。按登记值trim比较,不把不同URL路径、协议或电话号码格式擅自合并;不同签名可相同。修改自身原值允许,已有重复记录不自动删除或合并,变更为其他已占用值拒绝。共同使用既有签名advisory事务锁,检查和写入同事务,覆盖管理端/客户端及并发请求;返回可读400。
|
||||||
|
2. 新引流报备任务键为signatureId+drainageItemId+channelId+carrier,carrier为通道支持的mobile/unicom/telecom;复用已有carrier/approvalScope字段,无新表。页面与签名一样三网分组,按通道与运营商编辑;后端校验通道范围、引流归属及审核状态,返回相同维度并参与统计。新增或修改材料后,各适用运营商独立回到pending,移出通道/运营商及旧无运营商任务不继续保留旧授权。
|
||||||
|
3. 历史carrier=null任务不批量迁移或猜测运营商;在尚无明确运营商任务时保留原通道级兼容读法,并在页面标记历史通道级继承。某运营商已有明确任务时,无论通过/失败/未报备均优先,不回落旧通过状态;运营人员保存后建立明确三网任务。旧记录保留审计,跨运营商不能互相覆盖。
|
||||||
|
4. 路由、最终Gateway授权、签名卡片汇总、通道报备明细、批次目标/导出及按批次状态更新共用运营商语义。每个引流目标的通道交集按本条短信运营商计算;显式失败不得被旧carrier=null通过记录放行。批次按carrier业务键生成,旧all/legacy批次只保留原范围兼容,不把单运营商导出/状态结果扩散到其他运营商。
|
||||||
|
5. 本次另外核验HTTP IP白名单英文逗号已受支持,补输入说明和回归;发送详情仅展示敏感词命中/明确异常,不展示正常零命中快照。数据库审计不删除,实际失败原因保持。
|
||||||
|
6. 验收:并发同值新增/修改、自身/其他签名/已删除值、混合三网状态与历史覆盖、材料修改全部状态失效、真实PG/API与浏览器三尺寸、API/前端定向与全量、类型构建/质量门禁。无发送、重投、线上配置修改、推送或部署授权;本地隔离真实后端可验证配置及只读路由判断,物理短信链路不冒称通过。
|
||||||
|
|
||||||
|
### 11.1 实际数据库约束补核
|
||||||
|
|
||||||
|
真实隔离库复现原ChannelSignatureReportTask_drainage_target_key是签名+引流+通道部分唯一索引(Prisma模型未声明此部分约束)。必须新增20260914093000_drainage_carrier_reports,在同事务建立carrier非空四维唯一索引及carrier为空历史三维唯一索引,再移除旧三维索引;不改旧数据/状态。新索引同样防止状态保存与导出并发产生重复任务。迁移仅在独立验收库执行,发布后方能启用新代码;不能回退旧程序继续发送并把三网任务当通道级读取。应用回退需暂停发送并评估三网事实,不能删除新任务或直接重建旧唯一索引。
|
||||||
|
|||||||
@@ -2293,3 +2293,17 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
|||||||
### HTTP公共接口整改验收状态(2026-09-14)
|
### HTTP公共接口整改验收状态(2026-09-14)
|
||||||
|
|
||||||
上述Webhook DNS、IPv6 URL、严格日历与正文错误边界已在测试版本97d1334完成真实验收;不改变既定业务规则、计费或租户边界。高精度小数秒输入保留兼容,上行详情仅客户业务字段,禁止通道和内部匹配字段。详见 [验收报告](http-api-full-acceptance-20260914.md),生产环境状态不可由测试结论替代。
|
上述Webhook DNS、IPv6 URL、严格日历与正文错误边界已在测试版本97d1334完成真实验收;不改变既定业务规则、计费或租户边界。高精度小数秒输入保留兼容,上行详情仅客户业务字段,禁止通道和内部匹配字段。详见 [验收报告](http-api-full-acceptance-20260914.md),生产环境状态不可由测试结论替代。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 引流信息与运营商报备补充
|
||||||
|
|
||||||
|
- HTTP 接口 IP 白名单支持英文逗号、中文逗号和空白分隔多个 IP/CIDR,编辑说明必须明确。
|
||||||
|
- 同一签名下未删除引流资料的登记值(去除首尾空白)唯一;新增、编辑及状态恢复均不可绕过,并发请求最多一条成功。不同签名可使用相同值,自身原值可保留,既有重复不自动清理。
|
||||||
|
- 引流报备按引流资料、通道、运营商独立配置,只有本运营商通过的通道可进入对应短信路由;显式未通过不能继承旧通道级通过状态。材料变化使旧审批失效。
|
||||||
|
- 发送详情保留敏感词命中及明确异常,不显示正常的零命中检查;审计数据保留。
|
||||||
|
- 兼容、迁移和验收以 [引流门禁方案第 11 节](drainage-send-gating-plan-20260910.md#11-2026-09-14-引流唯一性与通道运营商报备设计) 为准。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 客户端页面与文档调整
|
||||||
|
|
||||||
|
模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。
|
||||||
|
|||||||
@@ -5488,3 +5488,36 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
|||||||
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
|
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
|
||||||
|
|
||||||
新增覆盖:TC-HTTP-FULL-DATE(非法日历/query/cursor与时区、高精度兼容)、TC-HTTP-FULL-BODY(畸形/非对象/超限/字符集/编码的400/413/415及关联ID)、TC-HTTP-FULL-IPV6(回环/ULA/link-local/mapped私网拒绝且配置不变)、TC-HTTP-FULL-ROTATE(新密钥真实签名成功);记录与断言名称逐项见报告附录。
|
新增覆盖:TC-HTTP-FULL-DATE(非法日历/query/cursor与时区、高精度兼容)、TC-HTTP-FULL-BODY(畸形/非对象/超限/字符集/编码的400/413/415及关联ID)、TC-HTTP-FULL-IPV6(回环/ULA/link-local/mapped私网拒绝且配置不变)、TC-HTTP-FULL-ROTATE(新密钥真实签名成功);记录与断言名称逐项见报告附录。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 引流唯一性、三网报备及详情用例
|
||||||
|
|
||||||
|
| 编号 | 场景与预期 |
|
||||||
|
|---|---|
|
||||||
|
| DRN-CARRIER-01 | HTTP 白名单混合英文逗号、中文逗号、换行及空格分隔 IP/CIDR,保存后逐项正确回读。 |
|
||||||
|
| DRN-CARRIER-02 | 同签名重复新增及编辑撞值返回 400;前后空白不绕过;跨签名同值、自身原值和已删除值可使用。 |
|
||||||
|
| DRN-CARRIER-03 | 同签名五请求并发新增同值只有一条成功;两条资料并发改为同值只有一条成功;恢复已删除资料不得造成重复。 |
|
||||||
|
| DRN-CARRIER-04 | 单个三网通道分别保存移动通过、联通失败、电信未报备,刷新/报备明细/汇总维度一致;仅移动路由放行。 |
|
||||||
|
| DRN-CARRIER-05 | 有旧 carrier=null 通过记录时,明确运营商失败仍拒绝;无明确任务的运营商继承历史状态并标注来源。 |
|
||||||
|
| DRN-CARRIER-06 | 材料更新立即失效所有旧审批,各适用运营商回到未报备;旧通道级审批不可继续授权。 |
|
||||||
|
| DRN-CARRIER-07 | 批次目标、导出任务及批次状态更新保留运营商,单运营商结果不得覆盖其他运营商;旧 all/legacy 批次仅作用原范围。 |
|
||||||
|
| DRN-CARRIER-08 | 实际旧索引迁移保留旧审批所有字段,允许三网独立记录,拒绝同运营商和旧通道级重复;不自动改线上数据。 |
|
||||||
|
| DRN-CARRIER-09 | 发送详情正常零命中快照不可见,真实命中和明确失败仍可见,数据库快照数量不变。 |
|
||||||
|
| DRN-CARRIER-10 | 三尺寸首次打开、保存、刷新、切换详情;检查真实响应及数据库,不把隔离服务适配器视为完整认证/Gateway 验收。 |
|
||||||
|
|
||||||
|
执行结果与未执行边界见 testing-progress.md 本日记录。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 客户端整改验收
|
||||||
|
|
||||||
|
| 编号 | 场景及预期 |
|
||||||
|
|---|---|
|
||||||
|
| CLIENT-0914-01 | 模板新增按钮宽度适配文字;长短正文均高 132px,长内容纵向滚动;删除 hover 沿用公共危险按钮,风险确认流程保留。 |
|
||||||
|
| CLIENT-0914-02 | 新增/编辑依次显示应用、名称、签名、内容;无分类输入,提交不覆写历史 category;签名切换与变量插入保留。 |
|
||||||
|
| CLIENT-0914-03 | /client/http-docs 独立导航、刷新可达;原接口对接页保留概览/凭据/回调/日志;无应用时仍能读文档。 |
|
||||||
|
| CLIENT-0914-04 | 3.2 三步签名说明与五行原文/公式相邻;各接口参数后有具名示例,无编号示例切换或独立汇总章节;检索与复制正常且不执行请求。 |
|
||||||
|
| CLIENT-0914-05 | 工作台列名短信内容,显示 content 而非 category;各批次状态使用共享中文映射。 |
|
||||||
|
| CLIENT-0914-06 | 分页 API 返回 PG 主记录 receiptStatus/deliveredAt,租户条件保持,不加载全量回执关系;成功、失败、未回执三种展示正确;UTC 07:34:41 显示北京时间 15:34:41。 |
|
||||||
|
| CLIENT-0914-07 | 最终聚合回执优先于旧尝试回执;无回执显示暂无回执且时间为空;三尺寸、刷新、路由切换和查询/分页语义回归。 |
|
||||||
|
|
||||||
|
执行证据与在线验收边界见 testing-progress.md 本轮记录。
|
||||||
|
|||||||
@@ -4971,3 +4971,41 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
|||||||
截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。
|
截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。
|
||||||
|
|
||||||
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
|
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 四项配置与引流运营商报备修复(本地提交范围)
|
||||||
|
|
||||||
|
### 范围与只读证据
|
||||||
|
|
||||||
|
- 起点本地 main 与实际远端 main 均为 ac6449028c4ab072ef16a219f61d592e2d453a7e,起始暂存为空。保护既有 metrics、tools/release、HTTP 接入及需求/测试/发布草稿,不推送、不部署、不发送/补发/重投/入队,不修改线上配置或业务数据。
|
||||||
|
- 2026-09-14 17:11:47(北京时间)只读核验预生产应用 d13ca0713abd6afbea5a62af39bcbb876b8bb186。号码 188****3795 的 MSG-b3b529de-602c-44a8-9cc9-bce3a5a8360f 在 15:39:30 至 15:41:51 有 10 条路由敏感词快照,全部 hits=0、reason=null;多轮候选选择均保存快照,详情逐条显示正常结果导致噪声。只过滤展示,审计/短信状态保持。首次只读查询使用不存在的 createdAt 后改用 queuedAt;未进行数据修复或发送。
|
||||||
|
- HTTP 白名单原解析已支持英文逗号,本轮明确输入说明并补混合分隔符回归。引流新增/编辑此前缺少同签名重复校验;通过同签名 advisory 事务锁将检查与写入串行化,状态恢复亦校验。
|
||||||
|
- 原引流任务按通道级 carrier=null 管理,现按通道×运营商管理,并更新路由、签名汇总、报备明细、批次/导出和状态更新。明确运营商结果优先于旧通道级状态,材料变化使旧审批失效。设计先更新于 drainage-send-gating-plan-20260910.md 第 11 节。
|
||||||
|
- 真实 PostgreSQL 首轮创建暴露旧部分唯一索引仍限制三维键,新增 20260914093000_drainage_carrier_reports,保留全部旧记录,改为明确运营商与历史通道级两个部分唯一索引。新迁移仅在本轮独立本地库执行。
|
||||||
|
|
||||||
|
### 已执行验证
|
||||||
|
|
||||||
|
- API 全量:74 suites / 811 tests;API TypeScript 生产构建通过。
|
||||||
|
- 前端全量最终:30 files / 144 tests(npx vitest run --maxWorkers=2);TypeScript 与 production 构建通过。最初新增三网测试发现 Select 未传递 aria 名称,修复公共组件并删除关闭时无用的 portalStyle 状态重置,覆盖三网选择与重新打开。另一轮并行构建/测试发生 17 项超时及关联断言失败,保留原日志;限制 worker 后及最终稳定代码两次全量通过,未提高超时或删除断言。
|
||||||
|
- npm run lint、format:check、quality:verify、style:check、css:verify(15 tests)、security:verify、bundle:verify 通过;lint 留存 3 条非阻断提示(原报备页 effect 依赖、IP 解析函数导出、测试 any)。git diff --check 通过。原未格式化测试/服务文件随当前格式门禁格式化,无业务扩展。
|
||||||
|
- tools/testing/verify-drainage-carriers.mjs:独立 loopback PostgreSQL 16414 / cmpp_qa_carriers_20260914,实际服务 HTTP 适配器 16416,25 项通过。覆盖迁移旧行完全保留及两类唯一约束、trim 重复、跨签名、自身修改、5 请求并发新增、并发改值、删除值复用及恢复防绕过、三网保存/汇总/路由、旧审批不覆盖明确失败、材料修改审批失效、批次目标与列表及 HTTP 白名单落库回读。一次新增数据扩充后列表断言受默认 10 条分页影响,限定验收签名并读取 100 条后通过。
|
||||||
|
- 浏览器连接器本轮仍为 nodeRepl.fetch request failed;使用已安装 Playwright + Chrome。本地 Vite dev 入口加载超时后,改用 production 构建 + preview 16418,真实业务组件连接上述服务与 PG;1600×1000、1366×768、390×844 无横向溢出。三网分别保存(移动通过、联通失败、电信未报备)后刷新读取一致,重复值 HTTP 400,发送详情不显示零命中快照,切换弹窗与重新打开选择器通过。无框架错误;控制台仅验收入口 favicon 404 和故意触发的重复值 400。
|
||||||
|
- 证据目录:%TEMP%/cmpp-drainage-carriers-20260914(preprod-records.json、api-full.log、frontend-final-stable.log、real-http-final2.log、real-fixture-final.json、ui-evidence.json、carrier-1600.png / carrier-1366.png / carrier-final-390.png 及质量日志)。保留初次失败和最终结果。
|
||||||
|
|
||||||
|
### 交付边界与遗留
|
||||||
|
|
||||||
|
- 本轮仅本地修改、文档和本地提交;未推送、未部署测试、未部署预生产。迁移和代码未在两套线上环境生效。提交号见本条记录所在提交;最终汇报提供精确 SHA。
|
||||||
|
- 隔离 HTTP 适配器直接调用真实业务服务与 PG,不包含完整 Nest 全局认证、生产反向代理和 worker;不得将其当作在线全功能验收。测试环境/预生产完整登录页面、权限与租户隔离在线回归、MinIO 报备文件导出/导入实物、Redis/Gateway 实际短信发送与计费闭环本轮未执行。原链路单元回归通过不替代物理发送专项验收。
|
||||||
|
- 53 项已有保护文件在最终核对中保持摘要(仅本轮文档采用追加并精确暂存);其余脏文件和草稿不纳入提交。历史重复资料不自动清理,历史通道级审批不批量重写。上线须先按标准发布流程执行新索引迁移,回退不可直接删除三网任务或重建旧索引。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 客户端模板、接口文档、工作台与回执整改(发布前)
|
||||||
|
|
||||||
|
- 起点 main 7f9abe3,实际远端 ac64490,测试机 97d133442350b9725422ed4e55386f47e37004fd。用户明确要求修改、提交、推送、测试部署;预生产、短信发送/重投、客户配置变更不在范围。测试发布包含上一轮引流三网报备提交和 20260914093000_drainage_carrier_reports 兼容迁移。既有 metrics、tools/release 和文档草稿全部保护。
|
||||||
|
- 只读根因:测试库 http-cmu0xgl6o000k5mle3hrw4avd 为 delivered,receiptStatus=delivered、deliveredAt=2026-09-14T07:34:41.935Z,回执表原码 DELIVRD;另外两条送达样本一致。旧 listClientMessagesPage 投影三条均 receiptStatus=null、deliveredAt=null、receiptRecords=[],因 listMessagesPage 未选择回执标量。仅补主记录两个标量,不加载全量尝试关系,不改消息状态/回执消费/账务。页面优先聚合最终回执并格式化北京时间。
|
||||||
|
- 模板 toolbar 改为输入/查询操作/添加三列、窄屏堆叠;正文 132px 纵向滚动;页脚脱离 legacy button 规则,使用公共按钮和原风险确认流程;表单应用、名称、签名、内容,不提交 category,历史值保留。
|
||||||
|
- /client/http-docs 独立路由与菜单;原接口配置页移除文档 Tab,保留其余功能。读者把示例移回所属正文,取消编号侧栏切换;第 11 节示例分配回鉴权与各接口章节,3.2 重写为三个步骤,37 段非 text 示例哈希逐一保持。新页面与 CSS 同目录登记所有权。
|
||||||
|
- 工作台复用 batchTaskStatusMeta/normalizeBatchTaskStatus,短信内容列读取 content。设计见 client-ui-remediation-20260914.md,用例 CLIENT-0914-01 至 07。
|
||||||
|
- 本地 API 74 suites / 812 tests 通过,API 构建通过;前端最终 31 files / 146 tests(maxWorkers=2)通过,类型/生产构建通过;代码 Lint 无错误(3 条旧依赖/any 提示),Stylelint、CSS治理15项、格式、包体及安全门禁通过。保留初始 worker 启动超时、回执标签断言、类型错误、CSS所有权/语法失败,后续修正和重测分开记录。精确发布候选仍需标准 validate。
|
||||||
|
- 浏览器连接器仍 nodeRepl.fetch request failed;使用已安装 Playwright/Chrome。独立本地 PG 16414 + 真实服务 HTTP 适配器16419 + production preview16420,未启动 Gateway/发送Worker:模板长短正文高度均132px、按钮143px,三尺寸1600×1000/1366×768/390×844无横向溢出;编辑顺序、删除hover、真实回执成功/失败/空值及15:34:41时间、工作台正文/已完成、文档三步示例、检索与复制通过。复制未执行请求。此为真实服务组件验收,非完整在线登录/鉴权验收。
|
||||||
|
- 本机证据 %TEMP%/cmpp-client-polish-20260914:api-full.log、frontend-final.log、css-final.log、style-final.log、format-final.log、browser-results.json 与 templates/receipts/home/docs 三尺寸截图。隔离库仅使用本轮专用样本;测试线上只读抽样。客户端登录账号尚待用户提供,不恢复旧账号、不造认证会话;在线页面与发布结果后续追加。
|
||||||
|
|||||||
@@ -297,7 +297,14 @@ export type ClientSmsSignature = {
|
|||||||
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
|
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
|
||||||
drainageReportTargets?: Record<
|
drainageReportTargets?: Record<
|
||||||
string,
|
string,
|
||||||
Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>
|
Array<{
|
||||||
|
channel: AdminChannel;
|
||||||
|
channelId: string;
|
||||||
|
carrier: 'mobile' | 'unicom' | 'telecom';
|
||||||
|
status: string;
|
||||||
|
taskId?: string;
|
||||||
|
approvalScope?: string;
|
||||||
|
}>
|
||||||
>;
|
>;
|
||||||
drainageCarrierReportSummary?: Record<
|
drainageCarrierReportSummary?: Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@@ -347,11 +347,9 @@ export function AdminReportTasksPage() {
|
|||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div>
|
<div>
|
||||||
<strong>{record.channel?.name ?? record.channelId}</strong>
|
<strong>{record.channel?.name ?? record.channelId}</strong>
|
||||||
{record.reportType !== 'drainage' ? (
|
<div className="muted">
|
||||||
<div className="muted">
|
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
|
||||||
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage';
|
import { AdminSmsApplicationFormPage, parseIpAllowlist } from './AdminSmsApplicationFormPage';
|
||||||
|
|
||||||
vi.mock('@/api/adminApi', () => ({
|
vi.mock('@/api/adminApi', () => ({
|
||||||
adminApi: {
|
adminApi: {
|
||||||
@@ -44,3 +44,12 @@ describe('application form feedback', () => {
|
|||||||
expect(dialog).toBeInTheDocument();
|
expect(dialog).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts comma separated HTTP IP entries including IPv6 and CIDR', () => {
|
||||||
|
expect(parseIpAllowlist('203.0.113.1, 203.0.113.0/24,2001:db8::1\n2001:db8::/64')).toEqual([
|
||||||
|
'203.0.113.1',
|
||||||
|
'203.0.113.0/24',
|
||||||
|
'2001:db8::1',
|
||||||
|
'2001:db8::/64',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
<Input
|
<Input
|
||||||
label="HTTP IP 白名单"
|
label="HTTP IP 白名单"
|
||||||
onChange={(event) => setHttpIpAddress(event.target.value)}
|
onChange={(event) => setHttpIpAddress(event.target.value)}
|
||||||
placeholder="多个 IP/CIDR 可换行填写,留空表示不限制"
|
placeholder="多个 IP/CIDR 可用英文逗号、中文逗号或空白分隔,留空表示不限制"
|
||||||
value={httpIpAddress}
|
value={httpIpAddress}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -779,7 +779,7 @@ function getRouteGroupId(routeRules: DictionaryItem[], carrier: Carrier) {
|
|||||||
return typeof rule?.groupId === 'string' ? rule.groupId : '';
|
return typeof rule?.groupId === 'string' ? rule.groupId : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIpAllowlist(value: string) {
|
export function parseIpAllowlist(value: string) {
|
||||||
return value
|
return value
|
||||||
.split(/[\s,,]+/)
|
.split(/[\s,,]+/)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
|
||||||
|
import { DrainageReportStatusModal } from './SignatureReportModals';
|
||||||
|
import type { DrainageInfo } from './signature.types';
|
||||||
|
vi.mock('@/api/adminApi', () => ({ adminApi: { changeReportTaskStatuses: vi.fn().mockResolvedValue([]) } }));
|
||||||
|
describe('drainage report carrier form', () => {
|
||||||
|
it('submits three separate carrier decisions and keeps failures visible', async () => {
|
||||||
|
const item = { id: 'd', url: 'example.com' } as DrainageInfo;
|
||||||
|
const signature = {
|
||||||
|
id: 's',
|
||||||
|
name: '【测试】',
|
||||||
|
drainageReportTargets: {
|
||||||
|
d: ['mobile', 'unicom', 'telecom'].map((carrier) => ({
|
||||||
|
channelId: 'c',
|
||||||
|
channel: { id: 'c', name: '三网通道' },
|
||||||
|
carrier,
|
||||||
|
status: 'pending',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
} as unknown as ClientSmsSignature;
|
||||||
|
const saved = vi.fn();
|
||||||
|
render(<DrainageReportStatusModal item={item} signature={signature} onClose={() => {}} onSaved={saved} />);
|
||||||
|
fireEvent.click(screen.getByLabelText('三网通道移动报备状态'));
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: '报备通过' }));
|
||||||
|
fireEvent.click(screen.getByLabelText('三网通道联通报备状态'));
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: '报备失败' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存状态' }));
|
||||||
|
await waitFor(() => expect(saved).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(adminApi.changeReportTaskStatuses).mock.calls[0][0].items).toEqual([
|
||||||
|
{
|
||||||
|
signatureId: 's',
|
||||||
|
drainageItemId: 'd',
|
||||||
|
reportType: 'drainage',
|
||||||
|
channelId: 'c',
|
||||||
|
carrier: 'mobile',
|
||||||
|
status: 'approved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signatureId: 's',
|
||||||
|
drainageItemId: 'd',
|
||||||
|
reportType: 'drainage',
|
||||||
|
channelId: 'c',
|
||||||
|
carrier: 'unicom',
|
||||||
|
status: 'failed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signatureId: 's',
|
||||||
|
drainageItemId: 'd',
|
||||||
|
reportType: 'drainage',
|
||||||
|
channelId: 'c',
|
||||||
|
carrier: 'telecom',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,69 +2,280 @@ import { useState } from 'react';
|
|||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
|
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
|
||||||
import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
|
import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
|
||||||
import { carrierLabel, CarrierReportTag } from './signature.helpers';
|
import { carrierLabel } from './signature.helpers';
|
||||||
import type { DrainageInfo } from './signature.types';
|
import type { DrainageInfo } from './signature.types';
|
||||||
|
|
||||||
const reportStatusOptions = [
|
const reportStatusOptions = [
|
||||||
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
|
{ label: '未报备', value: 'pending' },
|
||||||
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
|
{ label: '资料待补充', value: 'waiting_material' },
|
||||||
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
|
{ label: '报备中', value: 'reporting' },
|
||||||
|
{ label: '报备通过', value: 'approved' },
|
||||||
|
{ label: '报备失败', value: 'failed' },
|
||||||
|
{ label: '放弃报备', value: 'abandoned' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
|
export function ChannelReportStatusModal({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
item: ClientSmsSignature;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
const targets = item.reportTargets ?? [];
|
const targets = item.reportTargets ?? [];
|
||||||
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
||||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])));
|
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
||||||
|
);
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, carrier: target.carrier, status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
await adminApi.changeReportTaskStatuses({
|
||||||
|
items: targets.map((target) => ({
|
||||||
|
signatureId: item.id,
|
||||||
|
channelId: target.channelId,
|
||||||
|
carrier: target.carrier,
|
||||||
|
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
|
||||||
|
})),
|
||||||
|
reason,
|
||||||
|
sourceEntry: 'enterprise_signature',
|
||||||
|
});
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="修改签名报备状态">
|
|
||||||
<div className="signature-report-status"><div className="signature-report-status__context"><strong>{item.name}</strong><span>{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}</span></div><div className="signature-alert"><Info size={18} /><span>修改具体通道的报备状态;保存后同步通道详情、报备任务和企业签名三网状态。</span></div>
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
|
||||||
{targets.length ? <div className="signature-report-status__carriers">{carriers.map((carrier) => { const carrierTargets = targets.filter((target) => target.carrier === carrier); return <section className="signature-report-status__carrier" key={carrier}><header><CarrierTag carrier={carrier} /><span>{carrierTargets.length} 个通道</span></header><div className="signature-report-status__list">{carrierTargets.length ? carrierTargets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="signature-report-status__row" key={key}><strong title={target.channel.name}>{target.channel.name}</strong><Select aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`} onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>}</div></section>; })}</div> : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
|
||||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
|
||||||
</div>
|
|
||||||
</Modal>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
|
|
||||||
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
|
||||||
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
|
|
||||||
const [reason, setReason] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
async function save() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
|
|
||||||
onSaved();
|
|
||||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
|
|
||||||
}
|
|
||||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
|
||||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
|
||||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
|
||||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
|
||||||
</div>
|
|
||||||
</Modal>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
<Button onClick={onClose} variant="ghost">
|
||||||
<Button onClick={onConfirm} variant="danger">确认删除</Button>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!targets.length || saving} onClick={() => void save()}>
|
||||||
|
{saving ? '保存中...' : '保存状态'}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title="修改签名报备状态"
|
||||||
|
>
|
||||||
|
<div className="signature-report-status">
|
||||||
|
<div className="signature-report-status__context">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>
|
||||||
|
{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="signature-alert">
|
||||||
|
<Info size={18} />
|
||||||
|
<span>修改具体通道的报备状态;保存后同步通道详情、报备任务和企业签名三网状态。</span>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
{targets.length ? (
|
||||||
|
<div className="signature-report-status__carriers">
|
||||||
|
{carriers.map((carrier) => {
|
||||||
|
const carrierTargets = targets.filter((target) => target.carrier === carrier);
|
||||||
|
return (
|
||||||
|
<section className="signature-report-status__carrier" key={carrier}>
|
||||||
|
<header>
|
||||||
|
<CarrierTag carrier={carrier} />
|
||||||
|
<span>{carrierTargets.length} 个通道</span>
|
||||||
|
</header>
|
||||||
|
<div className="signature-report-status__list">
|
||||||
|
{carrierTargets.length ? (
|
||||||
|
carrierTargets.map((target) => {
|
||||||
|
const key = `${target.channelId}:${target.carrier}`;
|
||||||
|
return (
|
||||||
|
<div className="signature-report-status__row" key={key}>
|
||||||
|
<strong title={target.channel.name}>{target.channel.name}</strong>
|
||||||
|
<Select
|
||||||
|
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
|
||||||
|
onChange={(event) =>
|
||||||
|
setStatuses((current) => ({ ...current, [key]: event.target.value }))
|
||||||
|
}
|
||||||
|
options={reportStatusOptions}
|
||||||
|
value={statuses[key] ?? target.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">该企业应用当前没有配置目标通道。</div>
|
||||||
|
)}
|
||||||
|
<Textarea
|
||||||
|
label="修改原因"
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
placeholder="请输入运营商工单、确认依据或人工处理说明"
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrainageReportStatusModal({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
signature,
|
||||||
|
}: {
|
||||||
|
item: DrainageInfo;
|
||||||
|
signature: ClientSmsSignature;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const targets = signature.drainageReportTargets?.[item.id] ?? [];
|
||||||
|
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
|
||||||
|
);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await adminApi.changeReportTaskStatuses({
|
||||||
|
items: targets.map((target) => ({
|
||||||
|
signatureId: signature.id,
|
||||||
|
reportType: 'drainage',
|
||||||
|
drainageItemId: item.id,
|
||||||
|
channelId: target.channelId,
|
||||||
|
carrier: target.carrier,
|
||||||
|
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
|
||||||
|
})),
|
||||||
|
reason,
|
||||||
|
sourceEntry: 'enterprise_signature',
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!targets.length || saving} onClick={() => void save()}>
|
||||||
|
{saving ? '保存中...' : '保存状态'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title="修改引流报备状态"
|
||||||
|
>
|
||||||
|
<div className="signature-report-status">
|
||||||
|
<div className="signature-report-status__context">
|
||||||
|
<strong>{item.url}</strong>
|
||||||
|
<span>
|
||||||
|
{signature.name} · {signature.application?.name ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="signature-alert">
|
||||||
|
<Info size={18} />
|
||||||
|
<span>分别修改各通道的移动、联通、电信报备状态;历史通道级状态在保存后按运营商独立管理。</span>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
{targets.length ? (
|
||||||
|
<div className="signature-report-status__carriers">
|
||||||
|
{carriers.map((carrier) => {
|
||||||
|
const carrierTargets = targets.filter((target) => target.carrier === carrier);
|
||||||
|
return (
|
||||||
|
<section className="signature-report-status__carrier" key={carrier}>
|
||||||
|
<header>
|
||||||
|
<CarrierTag carrier={carrier} />
|
||||||
|
<span>{carrierTargets.length} 个通道</span>
|
||||||
|
</header>
|
||||||
|
<div className="signature-report-status__list">
|
||||||
|
{carrierTargets.length ? (
|
||||||
|
carrierTargets.map((target) => {
|
||||||
|
const key = `${target.channelId}:${target.carrier}`;
|
||||||
|
return (
|
||||||
|
<div className="signature-report-status__row" key={key}>
|
||||||
|
<strong title={target.channel.name}>
|
||||||
|
{target.channel.name}
|
||||||
|
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
|
||||||
|
</strong>
|
||||||
|
<Select
|
||||||
|
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
|
||||||
|
onChange={(event) =>
|
||||||
|
setStatuses((current) => ({ ...current, [key]: event.target.value }))
|
||||||
|
}
|
||||||
|
options={reportStatusOptions}
|
||||||
|
value={statuses[key] ?? target.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="signature-report-status__empty">暂无{carrierLabel(carrier)}目标通道</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">该企业应用当前没有配置目标通道。</div>
|
||||||
|
)}
|
||||||
|
<Textarea
|
||||||
|
label="修改原因"
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
placeholder="请输入运营商工单、确认依据或人工处理说明"
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({
|
||||||
|
message,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onCancel} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} variant="danger">
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
open
|
open
|
||||||
title="删除确认"
|
title="删除确认"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type SendDetailModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
|
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
|
||||||
|
const visibleWordDecisions = record.channelWordDecisions?.filter(
|
||||||
|
(decision) => decision.snapshot.hits.length > 0 || Boolean(decision.snapshot.reason),
|
||||||
|
);
|
||||||
const routeRows = buildRouteRows(record, segmentAudits);
|
const routeRows = buildRouteRows(record, segmentAudits);
|
||||||
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
||||||
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
||||||
@@ -47,15 +50,13 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="admin-sms-send-detail">
|
<div className="admin-sms-send-detail">
|
||||||
<section aria-label="通道筛选原因">
|
{visibleWordDecisions?.length ? (
|
||||||
<h3>通道筛选原因</h3>
|
<section aria-label="通道筛选原因">
|
||||||
{record.channelWordDecisions?.length ? (
|
<h3>通道筛选原因</h3>
|
||||||
record.channelWordDecisions.map((decision) => (
|
{visibleWordDecisions.map((decision) => (
|
||||||
<div key={decision.id}>
|
<div key={decision.id}>
|
||||||
<p>
|
<p>
|
||||||
{getTime(decision.decidedAt)} ·{' '}
|
{getTime(decision.decidedAt)} · {decision.snapshot.reason || '已排除命中通道,按剩余候选选路'}
|
||||||
{decision.snapshot.reason ||
|
|
||||||
(decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')}
|
|
||||||
</p>
|
</p>
|
||||||
{decision.snapshot.hits.map((hit) => (
|
{decision.snapshot.hits.map((hit) => (
|
||||||
<p key={hit.channelId}>
|
<p key={hit.channelId}>
|
||||||
@@ -65,11 +66,9 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
) : (
|
</section>
|
||||||
<p className="muted">{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}</p>
|
) : null}
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
<section aria-label="引流发送资格">
|
<section aria-label="引流发送资格">
|
||||||
<h3>引流发送资格</h3>
|
<h3>引流发送资格</h3>
|
||||||
{record.drainageGate ? (
|
{record.drainageGate ? (
|
||||||
|
|||||||
@@ -5,17 +5,39 @@ import { SmsRecordList } from './SmsRecordList';
|
|||||||
import { SendDetailModal } from './SendDetailModal';
|
import { SendDetailModal } from './SendDetailModal';
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
id: 'record-1', messageId: 'message-1', content: '请访问example.com查询', hasDrainageContent: true,
|
id: 'record-1',
|
||||||
|
messageId: 'message-1',
|
||||||
|
content: '请访问example.com查询',
|
||||||
|
hasDrainageContent: true,
|
||||||
drainageDetection: { matches: [{ start: 3, end: 14, value: 'example.com' }] },
|
drainageDetection: { matches: [{ start: 3, end: 14, value: 'example.com' }] },
|
||||||
queuedAt: '2026-08-31T01:00:00Z', deliveredAt: '2026-08-31T01:00:05Z', status: 'delivered',
|
queuedAt: '2026-08-31T01:00:00Z',
|
||||||
amountCents: 5, billingUnits: 1, phoneNumber: '13800138000', submitRecords: [], receiptRecords: [],
|
deliveredAt: '2026-08-31T01:00:05Z',
|
||||||
|
status: 'delivered',
|
||||||
|
amountCents: 5,
|
||||||
|
billingUnits: 1,
|
||||||
|
phoneNumber: '13800138000',
|
||||||
|
submitRecords: [],
|
||||||
|
receiptRecords: [],
|
||||||
} as unknown as SmsMessageRecord;
|
} as unknown as SmsMessageRecord;
|
||||||
|
|
||||||
describe('SMS drainage and final receipt presentation', () => {
|
describe('SMS drainage and final receipt presentation', () => {
|
||||||
it('shows only a positive drainage badge under status and removes receipt time from list', () => {
|
it('shows only a positive drainage badge under status and removes receipt time from list', () => {
|
||||||
const { container } = render(<SmsRecordList currentPage={1} loading={false} records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]} total={2} totalPages={1} onExport={() => {}} onOpenDetail={() => {}} onPageChange={() => {}} />);
|
const { container } = render(
|
||||||
|
<SmsRecordList
|
||||||
|
currentPage={1}
|
||||||
|
loading={false}
|
||||||
|
records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]}
|
||||||
|
total={2}
|
||||||
|
totalPages={1}
|
||||||
|
onExport={() => {}}
|
||||||
|
onOpenDetail={() => {}}
|
||||||
|
onPageChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
expect(screen.getAllByText('含引流')).toHaveLength(1);
|
expect(screen.getAllByText('含引流')).toHaveLength(1);
|
||||||
expect(container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge')).toHaveTextContent('含引流');
|
expect(
|
||||||
|
container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge'),
|
||||||
|
).toHaveTextContent('含引流');
|
||||||
expect(screen.queryByText('不含引流')).not.toBeInTheDocument();
|
expect(screen.queryByText('不含引流')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('回执时间')).not.toBeInTheDocument();
|
expect(screen.queryByText('回执时间')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('09:00:05')).not.toBeInTheDocument();
|
expect(screen.queryByText('09:00:05')).not.toBeInTheDocument();
|
||||||
@@ -30,10 +52,49 @@ describe('SMS drainage and final receipt presentation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows negative drainage only in details and does not mislabel untested historical records', () => {
|
it('shows negative drainage only in details and does not mislabel untested historical records', () => {
|
||||||
const { rerender } = render(<SendDetailModal record={{ ...record, hasDrainageContent: false }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
|
const { rerender } = render(
|
||||||
|
<SendDetailModal
|
||||||
|
record={{ ...record, hasDrainageContent: false }}
|
||||||
|
segmentAudits={[]}
|
||||||
|
segmentLoading={false}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
expect(screen.getByText('不含引流')).toBeVisible();
|
expect(screen.getByText('不含引流')).toBeVisible();
|
||||||
expect(document.querySelector('mark')).toBeNull();
|
expect(document.querySelector('mark')).toBeNull();
|
||||||
rerender(<SendDetailModal record={{ ...record, hasDrainageContent: undefined }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
|
rerender(
|
||||||
|
<SendDetailModal
|
||||||
|
record={{ ...record, hasDrainageContent: undefined }}
|
||||||
|
segmentAudits={[]}
|
||||||
|
segmentLoading={false}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
expect(screen.getByText('未检测')).toBeVisible();
|
expect(screen.getByText('未检测')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides no-hit routing snapshots while retaining sensitive-word failures', () => {
|
||||||
|
const clean = { id: 'clean', decidedAt: '2026-09-14T07:41:51Z', snapshot: { hits: [], reason: null } };
|
||||||
|
const props = { segmentAudits: [], segmentLoading: false, onClose: () => {} };
|
||||||
|
const { rerender } = render(
|
||||||
|
<SendDetailModal {...props} record={{ ...record, channelWordDecisions: [clean] } as unknown as SmsMessageRecord} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('region', { name: '通道筛选原因' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/候选通道未命中/)).not.toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<SendDetailModal
|
||||||
|
{...props}
|
||||||
|
record={
|
||||||
|
{
|
||||||
|
...record,
|
||||||
|
channelWordDecisions: [
|
||||||
|
clean,
|
||||||
|
{ ...clean, id: 'blocked', snapshot: { hits: [], reason: '可用通道均命中通道敏感词' } },
|
||||||
|
],
|
||||||
|
} as unknown as SmsMessageRecord
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/可用通道均命中通道敏感词/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { BadgeCheck, ClipboardList, FileText, PenLine, Plus, ReceiptText, Send, WalletCards } from 'lucide-react';
|
||||||
BadgeCheck,
|
|
||||||
ClipboardList,
|
|
||||||
FileText,
|
|
||||||
PenLine,
|
|
||||||
Plus,
|
|
||||||
ReceiptText,
|
|
||||||
Send,
|
|
||||||
WalletCards,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { Chart } from '@/components/ui/Chart';
|
import { Chart } from '@/components/ui/Chart';
|
||||||
import { clientApi, type DashboardResponse } from '@/api/adminApi';
|
import { clientApi, type DashboardResponse } from '@/api/adminApi';
|
||||||
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus';
|
||||||
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
|
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
|
||||||
|
|
||||||
type RecentTaskRow = {
|
type RecentTaskRow = {
|
||||||
@@ -28,10 +20,23 @@ type RecentTaskRow = {
|
|||||||
|
|
||||||
const columns: Array<TableColumn<RecentTaskRow>> = [
|
const columns: Array<TableColumn<RecentTaskRow>> = [
|
||||||
{ key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo },
|
{ key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo },
|
||||||
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
{
|
||||||
|
key: 'scene',
|
||||||
|
title: '短信内容',
|
||||||
|
width: '300px',
|
||||||
|
render: (record) => <span className="ui-table__long-text">{record.scene}</span>,
|
||||||
|
},
|
||||||
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
||||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '状态',
|
||||||
|
render: (record) => (
|
||||||
|
<Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>
|
||||||
|
{batchTaskStatusMeta[normalizeBatchTaskStatus(record.status)].label}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ClientHome() {
|
export function ClientHome() {
|
||||||
@@ -40,7 +45,8 @@ export function ClientHome() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clientApi.getDashboard()
|
clientApi
|
||||||
|
.getDashboard()
|
||||||
.then(setDashboard)
|
.then(setDashboard)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
|
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
|
||||||
@@ -51,33 +57,42 @@ export function ClientHome() {
|
|||||||
const account = dashboard?.accounts[0];
|
const account = dashboard?.accounts[0];
|
||||||
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
|
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
|
||||||
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0);
|
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0);
|
||||||
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
|
const recentMessages = useMemo<RecentTaskRow[]>(
|
||||||
id: String(task.id ?? task.taskNo),
|
() =>
|
||||||
taskNo: String(task.taskNo ?? task.id),
|
(dashboard?.recentTasks ?? []).map((task) => ({
|
||||||
scene: String(task.category ?? task.content ?? '短信发送'),
|
id: String(task.id ?? task.taskNo),
|
||||||
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
taskNo: String(task.taskNo ?? task.id),
|
||||||
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
scene: String(task.content ?? '-'),
|
||||||
status: String(task.status ?? 'unknown'),
|
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
||||||
})), [dashboard]);
|
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
||||||
|
status: String(task.status ?? 'unknown'),
|
||||||
|
})),
|
||||||
|
[dashboard],
|
||||||
|
);
|
||||||
const latestRecharge = dashboard?.recentRecharges[0];
|
const latestRecharge = dashboard?.recentRecharges[0];
|
||||||
|
|
||||||
const sendTrendOption = useMemo(
|
const sendTrendOption = useMemo(
|
||||||
() => createLineOption({
|
() =>
|
||||||
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
createLineOption({
|
||||||
series: [
|
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
||||||
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
series: [
|
||||||
{ name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
||||||
],
|
{ name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
||||||
}),
|
],
|
||||||
|
}),
|
||||||
[dashboard],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const channelShareOption = useMemo(() => createPieOption({
|
const channelShareOption = useMemo(
|
||||||
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
|
() =>
|
||||||
name: item.status,
|
createPieOption({
|
||||||
value: item._sum.currentConnections ?? item._count._all,
|
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
|
||||||
})),
|
name: item.status,
|
||||||
}), [dashboard]);
|
value: item._sum.currentConnections ?? item._count._all,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
[dashboard],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
@@ -91,7 +106,9 @@ export function ClientHome() {
|
|||||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
|
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
|
||||||
新建模板
|
新建模板
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}>发送短信</Button>
|
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}>
|
||||||
|
发送短信
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,7 +196,11 @@ export function ClientHome() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overview-grid overview-grid--three">
|
<div className="overview-grid overview-grid--three">
|
||||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/templates')} type="button">
|
<button
|
||||||
|
className="surface mini-status-card mini-status-card--action"
|
||||||
|
onClick={() => navigate('/client/templates')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<BadgeCheck size={22} />
|
<BadgeCheck size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>模板状态</span>
|
<span>模板状态</span>
|
||||||
@@ -187,7 +208,11 @@ export function ClientHome() {
|
|||||||
<small>点击进入模板明细</small>
|
<small>点击进入模板明细</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/signatures')} type="button">
|
<button
|
||||||
|
className="surface mini-status-card mini-status-card--action"
|
||||||
|
onClick={() => navigate('/client/signatures')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<PenLine size={22} />
|
<PenLine size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>签名状态</span>
|
<span>签名状态</span>
|
||||||
@@ -195,7 +220,11 @@ export function ClientHome() {
|
|||||||
<small>点击进入签名明细</small>
|
<small>点击进入签名明细</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/batch-tasks')} type="button">
|
<button
|
||||||
|
className="surface mini-status-card mini-status-card--action"
|
||||||
|
onClick={() => navigate('/client/batch-tasks')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<ClipboardList size={22} />
|
<ClipboardList size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>批量任务</span>
|
<span>批量任务</span>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { HttpDeveloperDocs } from './http-docs/HttpDeveloperDocs';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
|
import { Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -269,8 +268,6 @@ export function ClientHttpApiPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const docsPanel = <HttpDeveloperDocs config={config} loading={loading} applicationId={applicationId} />;
|
|
||||||
|
|
||||||
const logsPanel = (
|
const logsPanel = (
|
||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
<div className="surface" style={{ padding: 16 }}>
|
<div className="surface" style={{ padding: 16 }}>
|
||||||
@@ -316,7 +313,6 @@ export function ClientHttpApiPage() {
|
|||||||
{ label: '接口概览', value: 'overview', content: overview },
|
{ label: '接口概览', value: 'overview', content: overview },
|
||||||
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
|
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
|
||||||
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
|
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
|
||||||
{ label: '接口文档', value: 'docs', content: docsPanel },
|
|
||||||
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
|
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -325,7 +321,7 @@ export function ClientHttpApiPage() {
|
|||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1>接口对接</h1>
|
<h1>接口对接</h1>
|
||||||
<p>管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。</p>
|
<p>管理 HTTP 访问凭据、回调地址及真实投递记录。接入说明请查看「接口文档」。</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label="企业应用"
|
label="企业应用"
|
||||||
@@ -360,7 +356,11 @@ export function ClientHttpApiPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{applicationId ? <Tabs items={tabs} /> : docsPanel}
|
{applicationId ? (
|
||||||
|
<Tabs items={tabs} />
|
||||||
|
) : (
|
||||||
|
<p className="muted">暂无可选应用;可从左侧「接口文档」查看通用接入说明。</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,39 +5,102 @@ import { ClientBatchTasksPage } from './ClientBatchTasksPage';
|
|||||||
import { ClientSendDetailPage } from './ClientSendDetailPage';
|
import { ClientSendDetailPage } from './ClientSendDetailPage';
|
||||||
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
|
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
|
||||||
|
|
||||||
const { clientApi } = vi.hoisted(() => ({ clientApi: {
|
const { clientApi } = vi.hoisted(() => ({
|
||||||
listApplicationOptions: vi.fn(), listBatchTasksPage: vi.fn(), listMessages: vi.fn(), listUplinkMessagesPage: vi.fn(),
|
clientApi: {
|
||||||
} }));
|
listApplicationOptions: vi.fn(),
|
||||||
|
listBatchTasksPage: vi.fn(),
|
||||||
|
listMessages: vi.fn(),
|
||||||
|
listUplinkMessagesPage: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock('@/api/adminApi', () => ({ clientApi }));
|
vi.mock('@/api/adminApi', () => ({ clientApi }));
|
||||||
|
|
||||||
describe('explicit client queries', () => {
|
describe('explicit client queries', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.values(clientApi).forEach((mock) => mock.mockReset());
|
Object.values(clientApi).forEach((mock) => mock.mockReset());
|
||||||
clientApi.listApplicationOptions.mockResolvedValue([]);
|
clientApi.listApplicationOptions.mockResolvedValue([]);
|
||||||
for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage]) method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
|
for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage])
|
||||||
|
method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows canonical receipt state and Beijing receipt time while keeping missing receipts empty', async () => {
|
||||||
|
clientApi.listMessages.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'delivered',
|
||||||
|
content: '有回执',
|
||||||
|
phoneNumber: '13800138000',
|
||||||
|
billingUnits: 1,
|
||||||
|
status: 'delivered',
|
||||||
|
queuedAt: '2026-09-14T06:00:00Z',
|
||||||
|
receiptStatus: 'delivered',
|
||||||
|
deliveredAt: '2026-09-14T07:34:41.935Z',
|
||||||
|
receiptRecords: [{ rawStatus: 'UNDELIV', deliveredAt: '2026-09-13T01:00:00Z' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pending',
|
||||||
|
content: '等待回执',
|
||||||
|
phoneNumber: '13800138001',
|
||||||
|
billingUnits: 1,
|
||||||
|
status: 'submitted',
|
||||||
|
queuedAt: '2026-09-14T06:00:00Z',
|
||||||
|
receiptStatus: null,
|
||||||
|
deliveredAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
});
|
||||||
|
render(<ClientSendDetailPage />);
|
||||||
|
expect(await screen.findByText('送达成功')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('15:34:41')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('暂无回执')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('未送达')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
it.each([
|
it.each([
|
||||||
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
|
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
|
||||||
{ Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' },
|
{ Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' },
|
||||||
{ Component: ClientUplinkMessagesPage, method: clientApi.listUplinkMessagesPage, label: '上行内容', key: 'keyword' },
|
{
|
||||||
])('$label only applies filters on Query or Reset, including pagination back to page one', async ({ Component, method, label, key }) => {
|
Component: ClientUplinkMessagesPage,
|
||||||
render(<MemoryRouter><Component /></MemoryRouter>);
|
method: clientApi.listUplinkMessagesPage,
|
||||||
await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
|
label: '上行内容',
|
||||||
fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
|
key: 'keyword',
|
||||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)); });
|
},
|
||||||
expect(method).toHaveBeenCalledTimes(1);
|
])(
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
'$label only applies filters on Query or Reset, including pagination back to page one',
|
||||||
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })));
|
async ({ Component, method, label, key }) => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
render(
|
||||||
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
|
<MemoryRouter>
|
||||||
fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
|
<Component />
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
</MemoryRouter>,
|
||||||
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })));
|
);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
|
||||||
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
|
fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
await act(async () => {
|
||||||
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })));
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
expect(screen.getByLabelText(label)).toHaveValue('');
|
});
|
||||||
});
|
expect(method).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText(label)).toHaveValue('');
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { Fragment, startTransition, useEffect, useMemo, useState } from 'react';
|
||||||
import { FileText, Search, Smartphone } from 'lucide-react';
|
import { FileText, Search, Smartphone } from 'lucide-react';
|
||||||
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
type DateRangeValue,
|
type DateRangeValue,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { recentBeijingDateRange } from '@/utils/dateTime';
|
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
|
||||||
|
|
||||||
const statusLabelMap: Record<string, string> = {
|
const statusLabelMap: Record<string, string> = {
|
||||||
delivered: '成功',
|
delivered: '成功',
|
||||||
@@ -44,25 +44,34 @@ const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> =
|
|||||||
timeout: 'danger',
|
timeout: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDate(value?: string | null) {
|
|
||||||
return value ? value.slice(0, 10) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReceipt(record: SmsMessageRecord) {
|
function getReceipt(record: SmsMessageRecord) {
|
||||||
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
|
const latest = record.receiptRecords?.[0] as
|
||||||
|
{ rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
|
status: receiptStatusLabel(record.receiptStatus ?? latest?.rawStatus ?? latest?.receiptStatus),
|
||||||
time: latest?.deliveredAt ?? record.deliveredAt,
|
time: record.deliveredAt ?? latest?.deliveredAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function receiptStatusLabel(status?: string | null) {
|
function receiptStatusLabel(status?: string | null) {
|
||||||
if (!status) return '-';
|
if (!status) return '暂无回执';
|
||||||
const normalized = status.trim().toUpperCase();
|
const normalized = status.trim().toUpperCase();
|
||||||
return {
|
return (
|
||||||
DELIVRD: '送达成功', ACCEPTD: '已受理', UNDELIV: '未送达', REJECTD: '已拒绝',
|
{
|
||||||
EXPIRED: '已过期', DELETED: '已删除', UNKNOWN: '状态未知',
|
DELIVERED: '送达成功',
|
||||||
}[normalized] ?? statusLabelMap[status.toLowerCase()] ?? '状态未知';
|
FAILED: '送达失败',
|
||||||
|
TIMEOUT: '回执超时',
|
||||||
|
DELIVRD: '送达成功',
|
||||||
|
ACCEPTD: '已受理',
|
||||||
|
UNDELIV: '未送达',
|
||||||
|
REJECTD: '已拒绝',
|
||||||
|
EXPIRED: '已过期',
|
||||||
|
DELETED: '已删除',
|
||||||
|
UNKNOWN: '状态未知',
|
||||||
|
}[normalized] ??
|
||||||
|
statusLabelMap[status.toLowerCase()] ??
|
||||||
|
'状态未知'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientSendDetailPage() {
|
export function ClientSendDetailPage() {
|
||||||
@@ -81,16 +90,17 @@ export function ClientSendDetailPage() {
|
|||||||
|
|
||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clientApi.listMessages({
|
clientApi
|
||||||
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
|
.listMessages({
|
||||||
phoneNumber: applied.phoneKeyword.trim() || undefined,
|
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
|
||||||
status: applied.status === 'all' ? undefined : applied.status,
|
phoneNumber: applied.phoneKeyword.trim() || undefined,
|
||||||
contentKeyword: applied.contentKeyword.trim() || undefined,
|
status: applied.status === 'all' ? undefined : applied.status,
|
||||||
queuedAtFrom: applied.dateRange.start || undefined,
|
contentKeyword: applied.contentKeyword.trim() || undefined,
|
||||||
queuedAtTo: applied.dateRange.end || undefined,
|
queuedAtFrom: applied.dateRange.start || undefined,
|
||||||
page: targetPage,
|
queuedAtTo: applied.dateRange.end || undefined,
|
||||||
pageSize: 10,
|
page: targetPage,
|
||||||
})
|
pageSize: 10,
|
||||||
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setRecords(result.items);
|
setRecords(result.items);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
@@ -101,20 +111,18 @@ export function ClientSendDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(page);
|
startTransition(() => loadData(page));
|
||||||
}, [applied, page]);
|
}, [applied, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clientApi.listApplicationOptions()
|
clientApi
|
||||||
|
.listApplicationOptions()
|
||||||
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
|
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
|
||||||
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
|
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applicationOptions = useMemo(() => {
|
const applicationOptions = useMemo(() => {
|
||||||
return [
|
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))];
|
||||||
{ label: '全部应用', value: 'all' },
|
|
||||||
...applications.map((item) => ({ label: item.name, value: item.id })),
|
|
||||||
];
|
|
||||||
}, [applications]);
|
}, [applications]);
|
||||||
|
|
||||||
const filteredRows = records;
|
const filteredRows = records;
|
||||||
@@ -129,7 +137,13 @@ export function ClientSendDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
const defaults = { applicationId: 'all', status: 'all', dateRange: recentBeijingDateRange(7), contentKeyword: '', phoneKeyword: '' };
|
const defaults = {
|
||||||
|
applicationId: 'all',
|
||||||
|
status: 'all',
|
||||||
|
dateRange: recentBeijingDateRange(7),
|
||||||
|
contentKeyword: '',
|
||||||
|
phoneKeyword: '',
|
||||||
|
};
|
||||||
setApplicationId(defaults.applicationId);
|
setApplicationId(defaults.applicationId);
|
||||||
setStatus(defaults.status);
|
setStatus(defaults.status);
|
||||||
setDateRange(defaults.dateRange);
|
setDateRange(defaults.dateRange);
|
||||||
@@ -150,9 +164,18 @@ export function ClientSendDetailPage() {
|
|||||||
|
|
||||||
<QueryPanel
|
<QueryPanel
|
||||||
title="查询条件"
|
title="查询条件"
|
||||||
summary={<>共找到 <strong>{total}</strong> 条发送记录</>}
|
summary={
|
||||||
|
<>
|
||||||
|
共找到 <strong>{total}</strong> 条发送记录
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select label="应用名称" onChange={(event) => setApplicationId(event.target.value)} options={applicationOptions} value={applicationId} />
|
<Select
|
||||||
|
label="应用名称"
|
||||||
|
onChange={(event) => setApplicationId(event.target.value)}
|
||||||
|
options={applicationOptions}
|
||||||
|
value={applicationId}
|
||||||
|
/>
|
||||||
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
||||||
<Select
|
<Select
|
||||||
label="发送状态"
|
label="发送状态"
|
||||||
@@ -202,53 +225,79 @@ export function ClientSendDetailPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td className="ui-table__empty" colSpan={9}>正在加载真实发送记录...</td></tr>
|
<tr>
|
||||||
|
<td className="ui-table__empty" colSpan={9}>
|
||||||
|
正在加载真实发送记录...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
<tr>
|
||||||
) : visibleRows.map((record) => {
|
<td className="ui-table__empty" colSpan={9}>
|
||||||
const receipt = getReceipt(record);
|
暂无发送记录
|
||||||
const region = record.province ?? '-';
|
</td>
|
||||||
return (
|
</tr>
|
||||||
<Fragment key={record.id}>
|
) : (
|
||||||
<tr className="send-detail-main-row">
|
visibleRows.map((record) => {
|
||||||
<td><strong className="send-detail-app-name">{record.application?.name ?? record.applicationId ?? '-'}</strong></td>
|
const receipt = getReceipt(record);
|
||||||
<td>
|
const region = record.province ?? '-';
|
||||||
<span className="send-detail-time">
|
return (
|
||||||
{record.queuedAt.slice(0, 10)}
|
<Fragment key={record.id}>
|
||||||
<small>{record.queuedAt.slice(11, 19)}</small>
|
<tr className="send-detail-main-row">
|
||||||
</span>
|
<td>
|
||||||
</td>
|
<strong className="send-detail-app-name">
|
||||||
<td style={{ textAlign: 'center' }}>
|
{record.application?.name ?? record.applicationId ?? '-'}
|
||||||
<span className="send-detail-count">
|
</strong>
|
||||||
<strong>{[...record.content].length}字</strong>
|
</td>
|
||||||
<small>{record.billingUnits}条</small>
|
<td>
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td><strong>{record.phoneNumber}</strong></td>
|
|
||||||
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
|
|
||||||
<td><span className="send-detail-region">{region}</span></td>
|
|
||||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? '状态未知'}</Tag></td>
|
|
||||||
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
|
|
||||||
<td>
|
|
||||||
{receipt.time ? (
|
|
||||||
<span className="send-detail-time">
|
<span className="send-detail-time">
|
||||||
{receipt.time.slice(0, 10)}
|
{formatDateTime(record.queuedAt).slice(0, 10)}
|
||||||
<small>{receipt.time.slice(11, 19)}</small>
|
<small>{formatDateTime(record.queuedAt).slice(11, 19)}</small>
|
||||||
</span>
|
</span>
|
||||||
) : <span className="muted">-</span>}
|
</td>
|
||||||
</td>
|
<td style={{ textAlign: 'center' }}>
|
||||||
</tr>
|
<span className="send-detail-count">
|
||||||
<tr className="send-detail-content-row">
|
<strong>{[...record.content].length}字</strong>
|
||||||
<td colSpan={9}>
|
<small>{record.billingUnits}条</small>
|
||||||
<div className="send-detail-content-block">
|
</span>
|
||||||
<span>短信内容</span>
|
</td>
|
||||||
<p>{record.content}</p>
|
<td>
|
||||||
</div>
|
<strong>{record.phoneNumber}</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
|
||||||
</Fragment>
|
<td>
|
||||||
);
|
<span className="send-detail-region">{region}</span>
|
||||||
})}
|
</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>
|
||||||
|
<Tag tone={statusToneMap[record.status] ?? 'info'}>
|
||||||
|
{statusLabelMap[record.status] ?? '状态未知'}
|
||||||
|
</Tag>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>
|
||||||
|
<strong className="send-detail-receipt-code">{receipt.status}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{receipt.time ? (
|
||||||
|
<span className="send-detail-time">
|
||||||
|
{formatDateTime(receipt.time).slice(0, 10)}
|
||||||
|
<small>{formatDateTime(receipt.time).slice(11, 19)}</small>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="send-detail-content-row">
|
||||||
|
<td colSpan={9}>
|
||||||
|
<div className="send-detail-content-block">
|
||||||
|
<span>短信内容</span>
|
||||||
|
<p>{record.content}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.client-templates-page .client-template-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-templates-page .client-template-content {
|
||||||
|
height: 132px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-templates-page .client-template-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-templates-page .client-template-footer > span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-templates-page .client-template-footer > div {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 700px) {
|
||||||
|
.client-templates-page .client-template-toolbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-templates-page .client-template-toolbar > .ui-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { ClientTemplatesPage } from './ClientTemplatesPage';
|
||||||
|
vi.mock('@/api/adminApi', () => ({
|
||||||
|
clientApi: {
|
||||||
|
listTemplatesPage: vi.fn().mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 't',
|
||||||
|
name: '测试模板',
|
||||||
|
content: '【签名】正文',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
applicationId: 'a',
|
||||||
|
signatureId: 's',
|
||||||
|
variables: [],
|
||||||
|
updatedAt: '2026-09-14T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
}),
|
||||||
|
listApplicationOptions: vi.fn().mockResolvedValue([{ id: 'a', name: '应用', status: 'active' }]),
|
||||||
|
listSignatureOptions: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 's', name: '【签名】', applicationId: 'a', auditStatus: 'approved' }]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
describe('client template form', () => {
|
||||||
|
it('orders requested fields and keeps standard deletion action', async () => {
|
||||||
|
render(<ClientTemplatesPage />);
|
||||||
|
await screen.findByText('测试模板');
|
||||||
|
expect(screen.getByRole('button', { name: '删除' })).toHaveClass('ui-button');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '编辑' }));
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
const labels = Array.from(dialog.querySelectorAll('.ui-field__label')).map((x) =>
|
||||||
|
x.textContent?.replace(/\*/g, ''),
|
||||||
|
);
|
||||||
|
expect(labels.slice(0, 4)).toEqual(['短信应用', '模板名称', '短信签名', '模板内容']);
|
||||||
|
expect(within(dialog).queryByText('模板分类')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { startTransition, useEffect, useRef, useState } from 'react';
|
||||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, MessageSquare, Plus, Search } from 'lucide-react';
|
||||||
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
|
import {
|
||||||
|
clientApi,
|
||||||
|
type ClientSmsApplication,
|
||||||
|
type ClientSmsSignatureView,
|
||||||
|
type ClientSmsTemplate,
|
||||||
|
} from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||||
|
import './ClientTemplatesPage.css';
|
||||||
|
|
||||||
type TemplateVariable = {
|
type TemplateVariable = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,7 +21,6 @@ type TemplateFormState = {
|
|||||||
applicationId: string;
|
applicationId: string;
|
||||||
signatureId: string;
|
signatureId: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
variables: TemplateVariable[];
|
variables: TemplateVariable[];
|
||||||
};
|
};
|
||||||
@@ -49,8 +54,10 @@ const recommendedVariables = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function extractVariables(content: string): TemplateVariable[] {
|
function extractVariables(content: string): TemplateVariable[] {
|
||||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
|
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]))).map((name) => ({
|
||||||
.map((name) => ({ name, required: true }));
|
name,
|
||||||
|
required: true,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function billingUnits(content: string) {
|
function billingUnits(content: string) {
|
||||||
@@ -77,23 +84,28 @@ function TemplateModal({
|
|||||||
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
||||||
const initialContent = item?.signatureId
|
const initialContent = item?.signatureId
|
||||||
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
|
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
|
||||||
: item?.content ?? '';
|
: (item?.content ?? '');
|
||||||
const [form, setForm] = useState<TemplateFormState>({
|
const [form, setForm] = useState<TemplateFormState>({
|
||||||
applicationId: item?.applicationId ?? '',
|
applicationId: item?.applicationId ?? '',
|
||||||
signatureId: item?.signatureId ?? '',
|
signatureId: item?.signatureId ?? '',
|
||||||
name: item?.name ?? '',
|
name: item?.name ?? '',
|
||||||
category: item?.category ?? '行业通知',
|
|
||||||
content: initialContent,
|
content: initialContent,
|
||||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
variables:
|
||||||
|
item?.variables?.map((variable) => ({
|
||||||
|
name: variable.name,
|
||||||
|
example: variable.example ?? undefined,
|
||||||
|
required: variable.required ?? true,
|
||||||
|
})) ?? [],
|
||||||
});
|
});
|
||||||
const initialForm = useRef(form).current;
|
const [initialForm] = useState(form);
|
||||||
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||||
const availableSignatures = signatures.filter((signature) => (
|
const availableSignatures = signatures.filter(
|
||||||
signature.auditStatus === 'approved'
|
(signature) =>
|
||||||
&& (!application || signature.tenantId === application.tenantId)
|
signature.auditStatus === 'approved' &&
|
||||||
&& (!signature.applicationId || signature.applicationId === form.applicationId)
|
(!application || signature.tenantId === application.tenantId) &&
|
||||||
));
|
(!signature.applicationId || signature.applicationId === form.applicationId),
|
||||||
|
);
|
||||||
const variables = form.variables.length ? form.variables : extractVariables(form.content);
|
const variables = form.variables.length ? form.variables : extractVariables(form.content);
|
||||||
|
|
||||||
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
||||||
@@ -127,7 +139,10 @@ function TemplateModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateVariableExample(name: string, example: string) {
|
function updateVariableExample(name: string, example: string) {
|
||||||
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
|
update(
|
||||||
|
'variables',
|
||||||
|
variables.map((variable) => (variable.name === name ? { ...variable, example } : variable)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,8 +150,15 @@ function TemplateModal({
|
|||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
footer={({ requestClose }) => (
|
footer={({ requestClose }) => (
|
||||||
<>
|
<>
|
||||||
<Button onClick={requestClose} variant="ghost">取消</Button>
|
<Button onClick={requestClose} variant="ghost">
|
||||||
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
|
||||||
|
onClick={() => onSubmit({ ...form, variables })}
|
||||||
|
>
|
||||||
|
提交审核
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -144,22 +166,32 @@ function TemplateModal({
|
|||||||
size="xl"
|
size="xl"
|
||||||
title={item ? '编辑短信模板' : '添加短信模板'}
|
title={item ? '编辑短信模板' : '添加短信模板'}
|
||||||
>
|
>
|
||||||
<div className="template-form">
|
<div className="template-form client-template-form">
|
||||||
<Select
|
<Select
|
||||||
label="短信应用"
|
label="短信应用"
|
||||||
onChange={(event) => update('applicationId', event.target.value)}
|
onChange={(event) => update('applicationId', event.target.value)}
|
||||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
|
options={[
|
||||||
|
{ label: '请选择应用', value: '' },
|
||||||
|
...applications.map((app) => ({ label: app.name, value: app.id })),
|
||||||
|
]}
|
||||||
value={form.applicationId}
|
value={form.applicationId}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label="模板名称"
|
||||||
|
onChange={(event) => update('name', event.target.value)}
|
||||||
|
placeholder="请输入模板名称"
|
||||||
|
value={form.name}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="短信签名"
|
label="短信签名"
|
||||||
onChange={(event) => selectSignature(event.target.value)}
|
onChange={(event) => selectSignature(event.target.value)}
|
||||||
options={[{ label: '请选择签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
|
options={[
|
||||||
|
{ label: '请选择签名', value: '' },
|
||||||
|
...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
|
||||||
|
]}
|
||||||
required
|
required
|
||||||
value={form.signatureId}
|
value={form.signatureId}
|
||||||
/>
|
/>
|
||||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
|
|
||||||
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
|
||||||
<Textarea
|
<Textarea
|
||||||
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
||||||
label="模板内容"
|
label="模板内容"
|
||||||
@@ -174,34 +206,53 @@ function TemplateModal({
|
|||||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||||
</button>
|
</button>
|
||||||
<span>{form.content.length} 字符,计费 {billingUnits(form.content)} 条</span>
|
<span>
|
||||||
|
{form.content.length} 字符,计费 {billingUnits(form.content)} 条
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{variablesOpen ? (
|
{variablesOpen ? (
|
||||||
<div className="template-variable-panel">
|
<div className="template-variable-panel">
|
||||||
<h3>推荐变量</h3>
|
<h3>推荐变量</h3>
|
||||||
<div className="template-variable-buttons">
|
<div className="template-variable-buttons">
|
||||||
{recommendedVariables.map(([label, value]) => (
|
{recommendedVariables.map(([label, value]) => (
|
||||||
<button key={value} onClick={() => insertVariable(value)} type="button">{label} ({value})</button>
|
<button key={value} onClick={() => insertVariable(value)} type="button">
|
||||||
|
{label} ({value})
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h3>自定义变量</h3>
|
<h3>自定义变量</h3>
|
||||||
<div className="template-custom-variable">
|
<div className="template-custom-variable">
|
||||||
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
|
<Input
|
||||||
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}>插入</Button>
|
onChange={(event) => setCustomVariable(event.target.value)}
|
||||||
|
placeholder="英文字符或数字"
|
||||||
|
value={customVariable}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
insertVariable(customVariable);
|
||||||
|
setCustomVariable('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
插入
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="template-variable-panel">
|
<div className="template-variable-panel">
|
||||||
<h3>变量示例</h3>
|
<h3>变量示例</h3>
|
||||||
{variables.length ? variables.map((variable) => (
|
{variables.length ? (
|
||||||
<Input
|
variables.map((variable) => (
|
||||||
key={variable.name}
|
<Input
|
||||||
label={`\${${variable.name}}`}
|
key={variable.name}
|
||||||
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
|
label={`\${${variable.name}}`}
|
||||||
placeholder="请输入变量示例值"
|
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
|
||||||
value={variable.example ?? ''}
|
placeholder="请输入变量示例值"
|
||||||
/>
|
value={variable.example ?? ''}
|
||||||
)) : <p className="muted">模板内容中暂无变量。</p>}
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="muted">模板内容中暂无变量。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -225,19 +276,26 @@ export function ClientTemplatesPage() {
|
|||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
const sequence = ++requestSequence.current;
|
const sequence = ++requestSequence.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clientApi.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
|
clientApi
|
||||||
|
.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
|
||||||
.then((templateResult) => {
|
.then((templateResult) => {
|
||||||
if (sequence !== requestSequence.current) return;
|
if (sequence !== requestSequence.current) return;
|
||||||
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
setTemplates(
|
||||||
|
templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'),
|
||||||
|
);
|
||||||
setTotal(templateResult.total);
|
setTotal(templateResult.total);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
|
.catch((reason: Error) => {
|
||||||
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
|
if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(page);
|
startTransition(() => loadData(page));
|
||||||
}, [appliedKeyword, page]);
|
}, [appliedKeyword, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,7 +309,9 @@ export function ClientTemplatesPage() {
|
|||||||
.catch((reason: Error) => {
|
.catch((reason: Error) => {
|
||||||
if (!cancelled) setError(reason.message || '模板选项加载失败');
|
if (!cancelled) setError(reason.message || '模板选项加载失败');
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredTemplates = templates;
|
const filteredTemplates = templates;
|
||||||
@@ -267,7 +327,6 @@ export function ClientTemplatesPage() {
|
|||||||
signatureId: state.signatureId || undefined,
|
signatureId: state.signatureId || undefined,
|
||||||
name: state.name,
|
name: state.name,
|
||||||
content: state.content,
|
content: state.content,
|
||||||
category: state.category,
|
|
||||||
variables: state.variables,
|
variables: state.variables,
|
||||||
};
|
};
|
||||||
const template = existing
|
const template = existing
|
||||||
@@ -282,7 +341,7 @@ export function ClientTemplatesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack client-templates-page">
|
||||||
<div className="template-page-header">
|
<div className="template-page-header">
|
||||||
<div className="sms-send-title">
|
<div className="sms-send-title">
|
||||||
<span className="sms-send-title__icon">
|
<span className="sms-send-title__icon">
|
||||||
@@ -292,7 +351,7 @@ export function ClientTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="template-toolbar">
|
<div className="client-template-toolbar">
|
||||||
<Input
|
<Input
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
placeholder="搜索模板名称、应用、签名或内容"
|
placeholder="搜索模板名称、应用、签名或内容"
|
||||||
@@ -300,35 +359,72 @@ export function ClientTemplatesPage() {
|
|||||||
value={keyword}
|
value={keyword}
|
||||||
/>
|
/>
|
||||||
<div className="ui-query-actions">
|
<div className="ui-query-actions">
|
||||||
<Button icon={<Search size={17} />} onClick={() => { setPage(1); setAppliedKeyword(keyword.trim()); }}>查询</Button>
|
<Button
|
||||||
<Button onClick={() => { setKeyword(''); setPage(1); setAppliedKeyword(''); }} variant="ghost">重置</Button>
|
icon={<Search size={17} />}
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
setAppliedKeyword(keyword.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword('');
|
||||||
|
setPage(1);
|
||||||
|
setAppliedKeyword('');
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>添加短信模板</Button>
|
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>
|
||||||
|
添加短信模板
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<div className="template-card-grid">
|
<div className="template-card-grid">
|
||||||
{visibleTemplates.map((template) => {
|
{visibleTemplates.map((template) => {
|
||||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
|
const variables =
|
||||||
|
template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
|
||||||
return (
|
return (
|
||||||
<article className="template-card template-card--green" key={template.id}>
|
<article className="template-card template-card--green" key={template.id}>
|
||||||
<h2>{template.name}</h2>
|
<h2>{template.name}</h2>
|
||||||
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
|
<p className="muted">
|
||||||
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
|
{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}
|
||||||
<p className="template-content">{template.content}</p>
|
</p>
|
||||||
|
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>
|
||||||
|
{statusLabel[template.auditStatus] ?? template.auditStatus}
|
||||||
|
</Tag>
|
||||||
|
<p className="client-template-content">{template.content}</p>
|
||||||
<div className="template-vars">
|
<div className="template-vars">
|
||||||
<span>变量:</span>
|
<span>变量:</span>
|
||||||
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
{variables.length > 0 ? (
|
||||||
|
variables.map((item) => <strong key={item}>${`{${item}}`}</strong>)
|
||||||
|
) : (
|
||||||
|
<span className="muted">无变量</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="template-card-footer">
|
<div className="client-template-footer">
|
||||||
<span>{formatDateTime(template.updatedAt)}</span>
|
<span>{formatDateTime(template.updatedAt)}</span>
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => setModalTemplate(template)} type="button">
|
<Button
|
||||||
<Edit3 size={14} />
|
icon={<Edit3 size={14} />}
|
||||||
|
onClick={() => setModalTemplate(template)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</Button>
|
||||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={template.id} targetType="template" />
|
<DeleteRiskAction
|
||||||
|
onCompleted={() => void loadData()}
|
||||||
|
portal="client"
|
||||||
|
targetId={template.id}
|
||||||
|
targetType="template"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -352,7 +448,9 @@ export function ClientTemplatesPage() {
|
|||||||
applications={applications}
|
applications={applications}
|
||||||
item={modalTemplate === 'new' ? undefined : modalTemplate}
|
item={modalTemplate === 'new' ? undefined : modalTemplate}
|
||||||
onClose={() => setModalTemplate(null)}
|
onClose={() => setModalTemplate(null)}
|
||||||
onSubmit={(state) => { void saveTemplate(state); }}
|
onSubmit={(state) => {
|
||||||
|
void saveTemplate(state);
|
||||||
|
}}
|
||||||
signatures={signatures}
|
signatures={signatures}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import './HttpDeveloperDocs.css';
|
||||||
|
export function ClientHttpDocsPage() {
|
||||||
|
return (
|
||||||
|
<section className="page-stack client-http-docs">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>接口文档</h1>
|
||||||
|
<p>按接入步骤查看签名方法、接口参数和对应示例。</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/client/http-api">接口配置</Link>
|
||||||
|
</div>
|
||||||
|
<iframe className="client-http-docs-reader" title="HTTP接口接入文档" src="/api/client-docs" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,7 +66,11 @@ export function Select({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handlePointerDown(event: PointerEvent) {
|
function handlePointerDown(event: PointerEvent) {
|
||||||
if (rootRef.current && !rootRef.current.contains(event.target as Node) && !dropdownRef.current?.contains(event.target as Node)) {
|
if (
|
||||||
|
rootRef.current &&
|
||||||
|
!rootRef.current.contains(event.target as Node) &&
|
||||||
|
!dropdownRef.current?.contains(event.target as Node)
|
||||||
|
) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearchKeyword('');
|
setSearchKeyword('');
|
||||||
}
|
}
|
||||||
@@ -77,10 +81,8 @@ export function Select({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open || !dropdownPortal) {
|
// Closed/non-portal menus do not consume portalStyle; opening measures it before paint.
|
||||||
setPortalStyle(null);
|
if (!open || !dropdownPortal) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePosition() {
|
function updatePosition() {
|
||||||
const trigger = rootRef.current?.querySelector<HTMLElement>('.ui-select');
|
const trigger = rootRef.current?.querySelector<HTMLElement>('.ui-select');
|
||||||
@@ -122,16 +124,28 @@ export function Select({
|
|||||||
className={['ui-select__dropdown', dropdownPortal ? 'ui-select__dropdown--portal' : ''].filter(Boolean).join(' ')}
|
className={['ui-select__dropdown', dropdownPortal ? 'ui-select__dropdown--portal' : ''].filter(Boolean).join(' ')}
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
style={dropdownPortal ? portalStyle ?? { visibility: 'hidden' } : undefined}
|
style={dropdownPortal ? (portalStyle ?? { visibility: 'hidden' }) : undefined}
|
||||||
>
|
>
|
||||||
{searchEnabled ? (
|
{searchEnabled ? (
|
||||||
<label className="ui-select__search">
|
<label className="ui-select__search">
|
||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
|
<input
|
||||||
|
autoFocus
|
||||||
|
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
placeholder={searchPlaceholder ?? '输入名称搜索'}
|
||||||
|
value={searchKeyword}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
{visibleOptions.map((option) => (
|
{visibleOptions.map((option) => (
|
||||||
<button aria-selected={option.value === selectedValue} key={option.value} onClick={() => selectOption(option.value)} role="option" type="button">
|
<button
|
||||||
|
aria-selected={option.value === selectedValue}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => selectOption(option.value)}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -140,15 +154,15 @@ export function Select({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={selectId} ref={rootRef}>
|
||||||
className={['ui-field', className].filter(Boolean).join(' ')}
|
|
||||||
htmlFor={selectId}
|
|
||||||
ref={rootRef}
|
|
||||||
>
|
|
||||||
{label ? (
|
{label ? (
|
||||||
<span className="ui-field__label">
|
<span className="ui-field__label">
|
||||||
{label}
|
{label}
|
||||||
{required ? <span aria-label="必填" className="ui-field__required">*</span> : null}
|
{required ? (
|
||||||
|
<span aria-label="必填" className="ui-field__required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
@@ -157,17 +171,23 @@ export function Select({
|
|||||||
open ? 'ui-select--open' : '',
|
open ? 'ui-select--open' : '',
|
||||||
error ? 'ui-select--error' : '',
|
error ? 'ui-select--error' : '',
|
||||||
disabled ? 'ui-select--disabled' : '',
|
disabled ? 'ui-select--disabled' : '',
|
||||||
].filter(Boolean).join(' ')}
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label={props['aria-label']}
|
||||||
|
aria-labelledby={props['aria-labelledby']}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
id={selectId}
|
id={selectId}
|
||||||
onClick={() => setOpen((current) => {
|
onClick={() =>
|
||||||
if (current) setSearchKeyword('');
|
setOpen((current) => {
|
||||||
return !current;
|
if (current) setSearchKeyword('');
|
||||||
})}
|
return !current;
|
||||||
|
})
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
|
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
|
|||||||
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
||||||
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
||||||
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
||||||
|
{ label: '接口文档', to: '/client/http-docs', icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ const ClientEnterpriseAuthPage = lazyNamed(
|
|||||||
'ClientEnterpriseAuthPage',
|
'ClientEnterpriseAuthPage',
|
||||||
);
|
);
|
||||||
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
|
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
|
||||||
|
const ClientHttpDocsPage = lazyNamed(() => import('@/apps/client/http-docs/ClientHttpDocsPage'), 'ClientHttpDocsPage');
|
||||||
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
|
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
|
||||||
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
|
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
|
||||||
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
|
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
|
||||||
@@ -185,6 +186,7 @@ export function AppRoutes() {
|
|||||||
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
|
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
|
||||||
<Route path="applications" element={<ClientApplicationsPage />} />
|
<Route path="applications" element={<ClientApplicationsPage />} />
|
||||||
<Route path="http-api" element={<ClientHttpApiPage />} />
|
<Route path="http-api" element={<ClientHttpApiPage />} />
|
||||||
|
<Route path="http-docs" element={<ClientHttpDocsPage />} />
|
||||||
<Route path="templates" element={<ClientTemplatesPage />} />
|
<Route path="templates" element={<ClientTemplatesPage />} />
|
||||||
<Route path="signatures" element={<ClientSignaturesPage />} />
|
<Route path="signatures" element={<ClientSignaturesPage />} />
|
||||||
<Route path="mms-signatures" element={<PagePlaceholder />} />
|
<Route path="mms-signatures" element={<PagePlaceholder />} />
|
||||||
|
|||||||
@@ -290,9 +290,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
|
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
|
||||||
"owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx"],
|
"owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx", "src/apps/client/http-docs/ClientHttpDocsPage.tsx"],
|
||||||
"stylelintLegacy": false,
|
"stylelintLegacy": false,
|
||||||
"roots": ["client-http-docs"]
|
"roots": ["client-http-docs"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/client/ClientTemplatesPage.css",
|
||||||
|
"owners": ["src/apps/client/ClientTemplatesPage.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["client-templates-page"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||||
|
const url = new URL(process.env.DRAINAGE_TEST_DATABASE_URL || '');
|
||||||
|
assert(
|
||||||
|
['localhost', '127.0.0.1'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'),
|
||||||
|
'Dedicated loopback QA database required',
|
||||||
|
);
|
||||||
|
process.env.DATABASE_URL = url.toString();
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
const { PrismaService } = require('./dist/prisma/prisma.service.js');
|
||||||
|
const { SmsConfigService } = require('./dist/sms-config/sms-config.service.js');
|
||||||
|
const { ChannelReportingService } = require('./dist/channels/channel-reporting.service.js');
|
||||||
|
const { OpenApiService } = require('./dist/open-api/open-api.service.js');
|
||||||
|
const { ReportBatchGenerationService } = require('./dist/report-materials/batch-generation.service.js');
|
||||||
|
const { OperationsMessageQueries } = require('./dist/operations/queries/messages.queries.js');
|
||||||
|
const { assessDrainage } = require('./dist/send-chain/drainage-authorization.js');
|
||||||
|
const express = require('express');
|
||||||
|
const db = new PrismaService();
|
||||||
|
const sms = new SmsConfigService(db);
|
||||||
|
const reporting = new ChannelReportingService(db);
|
||||||
|
// No onModuleInit: transport, workers, reconciliation and authentication are outside this loopback service harness.
|
||||||
|
const httpConfig = new OpenApiService(db, undefined);
|
||||||
|
const batch = new ReportBatchGenerationService(db, undefined, sms, undefined, undefined);
|
||||||
|
const messages = new OperationsMessageQueries(db);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.set('json replacer', (_, value) => (typeof value === 'bigint' ? value.toString() : value));
|
||||||
|
const wrap = (fn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await fn(req));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(e.getStatus?.() || 500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app.get(
|
||||||
|
'/api/admin/enterprise-signatures/:id',
|
||||||
|
wrap((r) => sms.getSignature(r.params.id)),
|
||||||
|
);
|
||||||
|
app.post(
|
||||||
|
'/api/admin/enterprise-signatures/:id/drainage-infos',
|
||||||
|
wrap((r) => sms.createDrainageInfo(r.params.id, r.body, { initialAuditStatus: 'approved' })),
|
||||||
|
);
|
||||||
|
app.put(
|
||||||
|
'/api/admin/drainage-infos/:id',
|
||||||
|
wrap((r) => sms.updateDrainageInfo(r.params.id, r.body, { initialAuditStatus: 'approved' })),
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
'/api/admin/enterprise-applications/:id/report-fields',
|
||||||
|
wrap((r) => sms.getApplicationReportFields(r.params.id)),
|
||||||
|
);
|
||||||
|
app.post(
|
||||||
|
'/api/admin/drainage-infos/:id/status',
|
||||||
|
wrap((r) => sms.changeDrainageInfoStatus(r.params.id, r.body)),
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
'/api/admin/drainage-infos/:id/report-targets',
|
||||||
|
wrap((r) => sms.getDrainageReportTargets(r.params.id)),
|
||||||
|
);
|
||||||
|
app.post(
|
||||||
|
'/api/admin/report-tasks/status-change',
|
||||||
|
wrap((r) => reporting.changeReportTaskStatuses(r.body)),
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
'/api/admin/enterprise-applications/:id/http-api',
|
||||||
|
wrap((r) => httpConfig.getConfig(r.params.id)),
|
||||||
|
);
|
||||||
|
app.put(
|
||||||
|
'/api/admin/enterprise-applications/:id/http-api',
|
||||||
|
wrap((r) => httpConfig.updateConfig(r.params.id, r.body)),
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
'/api/admin/messages/:id',
|
||||||
|
wrap((r) => messages.getMessage(r.params.id)),
|
||||||
|
);
|
||||||
|
const port = Number(process.env.DRAINAGE_TEST_PORT || 16416);
|
||||||
|
const server = await new Promise((resolve) => {
|
||||||
|
const listener = app.listen(port, '127.0.0.1', () => resolve(listener));
|
||||||
|
});
|
||||||
|
const base = 'http://127.0.0.1:' + port;
|
||||||
|
const checks = [];
|
||||||
|
const check = (name, value) => {
|
||||||
|
assert(value, name);
|
||||||
|
checks.push(name);
|
||||||
|
};
|
||||||
|
const request = async (method, path, body) => {
|
||||||
|
const r = await fetch(base + '/api/admin/' + path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: r.status, data: await r.json() };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const tag = randomUUID().slice(0, 8);
|
||||||
|
const { Client } = require('pg');
|
||||||
|
const migrationDb = new Client({ connectionString: url.toString() });
|
||||||
|
await migrationDb.connect();
|
||||||
|
try {
|
||||||
|
await migrationDb.query('CREATE SCHEMA qa_migration_' + tag);
|
||||||
|
await migrationDb.query('SET search_path TO qa_migration_' + tag);
|
||||||
|
await migrationDb.query(
|
||||||
|
'CREATE TABLE "ChannelSignatureReportTask" ("id" text, "signatureId" text, "drainageItemId" text, "channelId" text, "carrier" text, "reportType" text, "status" text)',
|
||||||
|
);
|
||||||
|
await migrationDb.query(
|
||||||
|
`CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_target_key" ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId") WHERE "reportType"='drainage' AND "drainageItemId" IS NOT NULL`,
|
||||||
|
);
|
||||||
|
await migrationDb.query(
|
||||||
|
`INSERT INTO "ChannelSignatureReportTask" VALUES ('old','s','d','c',NULL,'drainage','approved')`,
|
||||||
|
);
|
||||||
|
const oldRow = (await migrationDb.query('SELECT * FROM "ChannelSignatureReportTask"')).rows;
|
||||||
|
await migrationDb.query(
|
||||||
|
fs.readFileSync(
|
||||||
|
new URL('../../api/prisma/migrations/20260914093000_drainage_carrier_reports/migration.sql', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual((await migrationDb.query('SELECT * FROM "ChannelSignatureReportTask"')).rows, oldRow);
|
||||||
|
check('migration preserves legacy approval exactly', true);
|
||||||
|
for (const carrier of ['mobile', 'unicom', 'telecom'])
|
||||||
|
await migrationDb.query(
|
||||||
|
`INSERT INTO "ChannelSignatureReportTask" VALUES ($1,'s','d','c',$1,'drainage','pending')`,
|
||||||
|
[carrier],
|
||||||
|
);
|
||||||
|
for (const carrier of ['mobile', null])
|
||||||
|
await assert.rejects(
|
||||||
|
migrationDb.query(
|
||||||
|
`INSERT INTO "ChannelSignatureReportTask" VALUES ('dup','s','d','c',$1,'drainage','pending')`,
|
||||||
|
[carrier],
|
||||||
|
),
|
||||||
|
{ code: '23505' },
|
||||||
|
);
|
||||||
|
check('migration permits independent carriers and rejects carrier and legacy duplicates', true);
|
||||||
|
} finally {
|
||||||
|
await migrationDb.end();
|
||||||
|
}
|
||||||
|
const tenant = await db.tenant.create({ data: { name: '隔离引流验收', code: 'QA-' + tag } });
|
||||||
|
const application = await db.smsApplication.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
name: '隔离引流应用',
|
||||||
|
cmppAccount: 'qa-' + tag,
|
||||||
|
cmppEnterpriseCode: 'QA',
|
||||||
|
secretHash: randomUUID(),
|
||||||
|
interfaceEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const signature = await db.smsSignature.create({
|
||||||
|
data: { tenantId: tenant.id, applicationId: application.id, name: '【引流三网验收】', auditStatus: 'approved' },
|
||||||
|
});
|
||||||
|
const second = await db.smsSignature.create({
|
||||||
|
data: { tenantId: tenant.id, applicationId: application.id, name: '【其他签名】', auditStatus: 'approved' },
|
||||||
|
});
|
||||||
|
const channel = await db.smsChannel.create({
|
||||||
|
data: {
|
||||||
|
code: 'QA-' + tag,
|
||||||
|
name: '隔离三网通道',
|
||||||
|
carriers: ['mobile', 'unicom', 'telecom'],
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 1,
|
||||||
|
account: 'unused',
|
||||||
|
passwordCipher: 'unused',
|
||||||
|
srcId: '1069',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const carrier of ['mobile', 'unicom', 'telecom']) {
|
||||||
|
const group = await db.smsChannelGroup.create({
|
||||||
|
data: {
|
||||||
|
code: 'QA-' + tag + '-' + carrier,
|
||||||
|
name: carrier,
|
||||||
|
carrier,
|
||||||
|
items: { create: { channelId: channel.id, carrier } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.channelRouteRule.create({
|
||||||
|
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const field = await db.drainageField.create({ data: { code: 'qa_' + tag, name: '引流信息', fieldType: 'string' } });
|
||||||
|
await db.channelReportField.create({
|
||||||
|
data: {
|
||||||
|
channelId: channel.id,
|
||||||
|
drainageFieldId: field.id,
|
||||||
|
code: field.code,
|
||||||
|
name: field.name,
|
||||||
|
reportType: 'drainage',
|
||||||
|
exportName: field.name,
|
||||||
|
fieldType: 'string',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
|
||||||
|
url: 'example.com',
|
||||||
|
});
|
||||||
|
if (created.status !== 200) console.error('create response', created);
|
||||||
|
check('create', created.status === 200);
|
||||||
|
const drainage = created.data;
|
||||||
|
const duplicate = await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
|
||||||
|
url: ' example.com ',
|
||||||
|
});
|
||||||
|
check('duplicate trimmed value returns 400', duplicate.status === 400);
|
||||||
|
const other = await request('POST', 'enterprise-signatures/' + second.id + '/drainage-infos', { url: 'example.com' });
|
||||||
|
check('same value in another signature allowed', other.status === 200);
|
||||||
|
const parallel = await Promise.all(
|
||||||
|
Array.from({ length: 5 }, () =>
|
||||||
|
request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'parallel.example.com' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'five concurrent creates commit once',
|
||||||
|
parallel.filter((x) => x.status === 200).length === 1 && parallel.filter((x) => x.status === 400).length === 4,
|
||||||
|
);
|
||||||
|
const parallelItem = parallel.find((x) => x.status === 200).data;
|
||||||
|
const conflicting = await request('PUT', 'drainage-infos/' + parallelItem.id, { url: 'example.com' });
|
||||||
|
check('edit cannot collide', conflicting.status === 400);
|
||||||
|
const editA = (
|
||||||
|
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'a.example.net' })
|
||||||
|
).data;
|
||||||
|
const editB = (
|
||||||
|
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'b.example.net' })
|
||||||
|
).data;
|
||||||
|
const racingEdits = await Promise.all(
|
||||||
|
[editA, editB].map((x) => request('PUT', 'drainage-infos/' + x.id, { url: 'race.example.net' })),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'concurrent edits commit one target',
|
||||||
|
racingEdits.filter((x) => x.status === 200).length === 1 &&
|
||||||
|
racingEdits.filter((x) => x.status === 400).length === 1,
|
||||||
|
);
|
||||||
|
await db.smsDrainageInfo.update({ where: { id: parallelItem.id }, data: { auditStatus: 'deleted' } });
|
||||||
|
check(
|
||||||
|
'deleted target can be reused',
|
||||||
|
(
|
||||||
|
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
|
||||||
|
url: 'parallel.example.com',
|
||||||
|
})
|
||||||
|
).status === 200,
|
||||||
|
);
|
||||||
|
const unchanged = await request('PUT', 'drainage-infos/' + drainage.id, {
|
||||||
|
url: 'example.com',
|
||||||
|
remark: 'unchanged value',
|
||||||
|
});
|
||||||
|
check('edit self allowed', unchanged.status === 200);
|
||||||
|
check(
|
||||||
|
'restoration cannot bypass uniqueness',
|
||||||
|
(await request('POST', `drainage-infos/${parallelItem.id}/status`, { status: 'approved' })).status === 400,
|
||||||
|
);
|
||||||
|
const targets = (await request('GET', 'drainage-infos/' + drainage.id + '/report-targets')).data;
|
||||||
|
check('one channel has three targets', targets.length === 3 && new Set(targets.map((x) => x.carrier)).size === 3);
|
||||||
|
const states = { mobile: 'approved', unicom: 'failed', telecom: 'pending' };
|
||||||
|
const body = {
|
||||||
|
items: targets.map((t) => ({
|
||||||
|
signatureId: signature.id,
|
||||||
|
drainageItemId: drainage.id,
|
||||||
|
reportType: 'drainage',
|
||||||
|
channelId: channel.id,
|
||||||
|
carrier: t.carrier,
|
||||||
|
status: states[t.carrier],
|
||||||
|
})),
|
||||||
|
sourceEntry: 'enterprise_signature',
|
||||||
|
};
|
||||||
|
check('save three carrier states', (await request('POST', 'report-tasks/status-change', body)).status === 200);
|
||||||
|
const rows = await db.smsDrainageInfo.findMany({ where: { id: drainage.id }, include: { reportTasks: true } });
|
||||||
|
const target = {
|
||||||
|
key: 'url:example.com',
|
||||||
|
category: 'url',
|
||||||
|
value: 'example.com',
|
||||||
|
text: 'example.com',
|
||||||
|
start: 0,
|
||||||
|
end: 11,
|
||||||
|
};
|
||||||
|
check('mobile route approved', assessDrainage([target], rows, 'mobile').allowedChannelIds.includes(channel.id));
|
||||||
|
check('unicom route denied', assessDrainage([target], rows, 'unicom').allowedChannelIds.length === 0);
|
||||||
|
check('telecom pending denied', assessDrainage([target], rows, 'telecom').allowedChannelIds.length === 0);
|
||||||
|
await db.channelSignatureReportTask.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
signatureId: signature.id,
|
||||||
|
drainageItemId: drainage.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
reportType: 'drainage',
|
||||||
|
carrier: null,
|
||||||
|
status: 'approved',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const legacyRows = await db.smsDrainageInfo.findMany({ where: { id: drainage.id }, include: { reportTasks: true } });
|
||||||
|
check(
|
||||||
|
'legacy approval cannot override explicit rejection',
|
||||||
|
assessDrainage([target], legacyRows, 'unicom').allowedChannelIds.length === 0,
|
||||||
|
);
|
||||||
|
const view = (await request('GET', 'enterprise-signatures/' + signature.id)).data;
|
||||||
|
check(
|
||||||
|
'summary follows carrier',
|
||||||
|
view.drainageCarrierReportSummary[drainage.id].mobile.approved === 1 &&
|
||||||
|
view.drainageCarrierReportSummary[drainage.id].unicom.approved === 0,
|
||||||
|
);
|
||||||
|
const preview = await batch.inspectBatchItem({
|
||||||
|
reportType: 'drainage',
|
||||||
|
signatureId: signature.id,
|
||||||
|
drainageItemId: drainage.id,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'batch targets preserve carriers',
|
||||||
|
preview.targets.length === 3 && preview.targets.every((t) => t.carrier !== 'all'),
|
||||||
|
);
|
||||||
|
const details = await reporting.listReportDetailsPage({
|
||||||
|
reportType: 'drainage',
|
||||||
|
channelId: channel.id,
|
||||||
|
signatureId: signature.id,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'report details unique carrier rows',
|
||||||
|
details.items.filter((t) => t.drainageItemId === drainage.id).length === 3,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'material update',
|
||||||
|
(await request('PUT', 'drainage-infos/' + drainage.id, { url: 'example.com', remark: 'invalidate reports' }))
|
||||||
|
.status === 200,
|
||||||
|
);
|
||||||
|
const reset = await db.channelSignatureReportTask.findMany({ where: { drainageItemId: drainage.id } });
|
||||||
|
check(
|
||||||
|
'material update invalidates all approvals',
|
||||||
|
reset.every((t) => t.status !== 'approved'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'material update resets three carrier tasks',
|
||||||
|
reset.filter((t) => t.carrier && t.status === 'pending').length === 3,
|
||||||
|
);
|
||||||
|
const whitelist = ['203.0.113.1', '203.0.113.0/24', '2001:db8::1'];
|
||||||
|
check(
|
||||||
|
'HTTP whitelist persists',
|
||||||
|
(
|
||||||
|
await request('PUT', 'enterprise-applications/' + application.id + '/http-api', {
|
||||||
|
enabled: false,
|
||||||
|
ipAllowlist: whitelist,
|
||||||
|
})
|
||||||
|
).status === 200,
|
||||||
|
);
|
||||||
|
const config = (await request('GET', 'enterprise-applications/' + application.id + '/http-api')).data;
|
||||||
|
check(
|
||||||
|
'HTTP whitelist readback',
|
||||||
|
JSON.stringify([...config.ipAllowlist].sort()) === JSON.stringify([...whitelist].sort()),
|
||||||
|
);
|
||||||
|
const message = await db.smsMessageRecord.create({
|
||||||
|
data: {
|
||||||
|
messageId: 'QA-' + tag,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
applicationId: application.id,
|
||||||
|
signatureId: signature.id,
|
||||||
|
phoneNumber: '13800001000',
|
||||||
|
content: '【引流三网验收】只读展示',
|
||||||
|
status: 'failed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.smsChannelSensitiveDecision.create({
|
||||||
|
data: {
|
||||||
|
messageRecordId: message.id,
|
||||||
|
routeAttemptId: randomUUID(),
|
||||||
|
snapshot: { hits: [], reason: null, candidateChannelIds: [], selectedChannelId: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fixture = {
|
||||||
|
tag,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
applicationId: application.id,
|
||||||
|
signatureId: signature.id,
|
||||||
|
drainageId: drainage.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
messageId: message.id,
|
||||||
|
base,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
if (process.env.DRAINAGE_TEST_EVIDENCE)
|
||||||
|
fs.writeFileSync(process.env.DRAINAGE_TEST_EVIDENCE, JSON.stringify(fixture, null, 2));
|
||||||
|
console.log(JSON.stringify({ passed: checks.length, checks, fixture }));
|
||||||
|
if (process.env.DRAINAGE_TEST_KEEP_SERVER !== 'true') {
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
await db.$disconnect();
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
await db.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user