feat: optimize routing and operations views
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DictionariesController } from './dictionaries.controller';
|
import { DictionariesController } from './dictionaries.controller';
|
||||||
import { DictionariesService } from './dictionaries.service';
|
import { DictionariesService } from './dictionaries.service';
|
||||||
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [DictionariesController],
|
controllers: [DictionariesController],
|
||||||
providers: [DictionariesService],
|
providers: [DictionariesService, PhoneRoutingLookupService],
|
||||||
exports: [DictionariesService],
|
exports: [DictionariesService, PhoneRoutingLookupService],
|
||||||
})
|
})
|
||||||
export class DictionariesModule {}
|
export class DictionariesModule {}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ function createPrismaMock() {
|
|||||||
phoneCarrierRule: {
|
phoneCarrierRule: {
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'rule-1', ...data })),
|
||||||
|
delete: jest.fn().mockResolvedValue({ id: 'rule-1' }),
|
||||||
},
|
},
|
||||||
sensitiveWord: {
|
sensitiveWord: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
@@ -210,6 +212,17 @@ describe('DictionariesService', () => {
|
|||||||
expect(prisma.phoneCarrierRule.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } });
|
expect(prisma.phoneCarrierRule.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('invalidates the sending cache after carrier rule changes', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const phoneRoutingLookup = { invalidateCarrierRules: jest.fn() };
|
||||||
|
const service = new DictionariesService(prisma as never, phoneRoutingLookup as never);
|
||||||
|
|
||||||
|
await service.createPhoneCarrierRule({ carrier: 'mobile', pattern: '^138' });
|
||||||
|
await service.deletePhoneCarrierRule('rule-1');
|
||||||
|
|
||||||
|
expect(phoneRoutingLookup.invalidateCarrierRules).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new DictionariesService(prisma as never);
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, Optional } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
|
|
||||||
export interface CreatePhoneSegmentDto {
|
export interface CreatePhoneSegmentDto {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -83,7 +84,10 @@ export interface DictionaryListQuery {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DictionariesService {
|
export class DictionariesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
||||||
const page = Math.max(1, Number(query.page ?? 1));
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
@@ -129,7 +133,7 @@ export class DictionariesService {
|
|||||||
return { items, total, page, pageSize };
|
return { items, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
|
async createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
|
||||||
if (!data.carrier || !data.pattern) {
|
if (!data.carrier || !data.pattern) {
|
||||||
throw new BadRequestException('carrier and pattern are required');
|
throw new BadRequestException('carrier and pattern are required');
|
||||||
}
|
}
|
||||||
@@ -138,7 +142,7 @@ export class DictionariesService {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException('pattern must be a valid regular expression');
|
throw new BadRequestException('pattern must be a valid regular expression');
|
||||||
}
|
}
|
||||||
return this.prisma.phoneCarrierRule.create({
|
const created = await this.prisma.phoneCarrierRule.create({
|
||||||
data: {
|
data: {
|
||||||
carrier: data.carrier,
|
carrier: data.carrier,
|
||||||
pattern: data.pattern,
|
pattern: data.pattern,
|
||||||
@@ -147,10 +151,14 @@ export class DictionariesService {
|
|||||||
remark: data.remark,
|
remark: data.remark,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.phoneRoutingLookup?.invalidateCarrierRules();
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
deletePhoneCarrierRule(id: string) {
|
async deletePhoneCarrierRule(id: string) {
|
||||||
return this.prisma.phoneCarrierRule.delete({ where: { id } });
|
const deleted = await this.prisma.phoneCarrierRule.delete({ where: { id } });
|
||||||
|
this.phoneRoutingLookup?.invalidateCarrierRules();
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
listSensitiveWords(query: DictionaryListQuery = {}) {
|
listSensitiveWords(query: DictionaryListQuery = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
|
|
||||||
|
function createPrismaMock() {
|
||||||
|
return {
|
||||||
|
phoneCarrierRule: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{ carrier: 'telecom', pattern: '^133' },
|
||||||
|
{ carrier: 'mobile', pattern: '^13[5-9]' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
phoneSegment: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{ prefix: '1380000', province: '山东' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PhoneRoutingLookupService', () => {
|
||||||
|
it('compiles and caches active carrier rules across concurrent lookups', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new PhoneRoutingLookupService(prisma as never);
|
||||||
|
|
||||||
|
await expect(Promise.all([
|
||||||
|
service.identifyCarrier('13800000001'),
|
||||||
|
service.identifyCarrier('13300000001'),
|
||||||
|
service.identifyCarrier('13800000002'),
|
||||||
|
])).resolves.toEqual(['mobile', 'telecom', 'mobile']);
|
||||||
|
|
||||||
|
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'active' },
|
||||||
|
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
select: { carrier: true, pattern: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads carrier rules immediately after explicit invalidation', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new PhoneRoutingLookupService(prisma as never);
|
||||||
|
|
||||||
|
await service.identifyCarrier('13800000001');
|
||||||
|
service.invalidateCarrierRules();
|
||||||
|
await service.identifyCarrier('13800000001');
|
||||||
|
|
||||||
|
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let an in-flight stale load overwrite an invalidated cache', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
let resolveFirstLoad: ((rows: Array<{ carrier: string; pattern: string }>) => void) | undefined;
|
||||||
|
prisma.phoneCarrierRule.findMany
|
||||||
|
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirstLoad = resolve; }))
|
||||||
|
.mockResolvedValueOnce([{ carrier: 'telecom', pattern: '^138' }]);
|
||||||
|
const service = new PhoneRoutingLookupService(prisma as never);
|
||||||
|
|
||||||
|
const staleLookup = service.identifyCarrier('13800000001');
|
||||||
|
service.invalidateCarrierRules();
|
||||||
|
await expect(service.identifyCarrier('13800000001')).resolves.toBe('telecom');
|
||||||
|
resolveFirstLoad?.([{ carrier: 'mobile', pattern: '^138' }]);
|
||||||
|
await expect(staleLookup).resolves.toBe('mobile');
|
||||||
|
await expect(service.identifyCarrier('13800000001')).resolves.toBe('telecom');
|
||||||
|
|
||||||
|
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the longest phone prefix with one database query', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||||
|
{ prefix: '138', province: '全国' },
|
||||||
|
{ prefix: '1380000', province: '山东' },
|
||||||
|
{ prefix: '13800', province: '华东' },
|
||||||
|
]);
|
||||||
|
const service = new PhoneRoutingLookupService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.identifyProvince('13800000001')).resolves.toBe('山东');
|
||||||
|
|
||||||
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { prefix: { in: ['1380000', '138000', '13800', '1380', '138'] } },
|
||||||
|
select: { prefix: true, province: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null after one query when no phone prefix exists', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.phoneSegment.findMany.mockResolvedValue([]);
|
||||||
|
const service = new PhoneRoutingLookupService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.identifyProvince('00000000000')).resolves.toBeNull();
|
||||||
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const DEFAULT_CARRIER_RULE_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
interface CompiledCarrierRule {
|
||||||
|
carrier: string;
|
||||||
|
pattern: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PhoneRoutingLookupService {
|
||||||
|
private carrierRuleCache?: { expiresAt: number; rules: CompiledCarrierRule[] };
|
||||||
|
private carrierRuleLoad?: { generation: number; promise: Promise<CompiledCarrierRule[]> };
|
||||||
|
private carrierRuleGeneration = 0;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async identifyCarrier(phoneNumber: string) {
|
||||||
|
const rules = await this.getCarrierRules();
|
||||||
|
return rules.find((rule) => rule.pattern.test(phoneNumber))?.carrier;
|
||||||
|
}
|
||||||
|
|
||||||
|
async identifyProvince(phoneNumber: string) {
|
||||||
|
const prefixes = phonePrefixes(phoneNumber);
|
||||||
|
if (prefixes.length === 0) return null;
|
||||||
|
const segments = await this.prisma.phoneSegment.findMany({
|
||||||
|
where: { prefix: { in: prefixes } },
|
||||||
|
select: { prefix: true, province: true },
|
||||||
|
});
|
||||||
|
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const province = provinceByPrefix.get(prefix);
|
||||||
|
if (province) return province;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateCarrierRules() {
|
||||||
|
this.carrierRuleGeneration += 1;
|
||||||
|
this.carrierRuleCache = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCarrierRules() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.carrierRuleCache && this.carrierRuleCache.expiresAt > now) {
|
||||||
|
return this.carrierRuleCache.rules;
|
||||||
|
}
|
||||||
|
const generation = this.carrierRuleGeneration;
|
||||||
|
if (this.carrierRuleLoad?.generation === generation) return this.carrierRuleLoad.promise;
|
||||||
|
const promise = this.loadCarrierRules(generation);
|
||||||
|
this.carrierRuleLoad = { generation, promise };
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
if (this.carrierRuleLoad?.promise === promise) {
|
||||||
|
this.carrierRuleLoad = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCarrierRules(generation: number) {
|
||||||
|
const rows = await this.prisma.phoneCarrierRule.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
select: { carrier: true, pattern: true },
|
||||||
|
});
|
||||||
|
const rules = rows.flatMap((row) => {
|
||||||
|
try {
|
||||||
|
return [{ carrier: row.carrier, pattern: new RegExp(row.pattern) }];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (generation === this.carrierRuleGeneration) {
|
||||||
|
this.carrierRuleCache = {
|
||||||
|
expiresAt: Date.now() + carrierRuleCacheTtlMs(),
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function phonePrefixes(phoneNumber: string) {
|
||||||
|
const maxLength = Math.min(7, phoneNumber.length);
|
||||||
|
return Array.from({ length: Math.max(0, maxLength - 2) }, (_, index) =>
|
||||||
|
phoneNumber.slice(0, maxLength - index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function carrierRuleCacheTtlMs() {
|
||||||
|
const configured = Number(process.env.PHONE_CARRIER_RULE_CACHE_TTL_MS);
|
||||||
|
return Number.isFinite(configured) && configured > 0
|
||||||
|
? Math.floor(configured)
|
||||||
|
: DEFAULT_CARRIER_RULE_CACHE_TTL_MS;
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export class AdminOperationsController {
|
|||||||
@Query('phoneNumber') phoneNumber?: string,
|
@Query('phoneNumber') phoneNumber?: string,
|
||||||
@Query('contentKeyword') contentKeyword?: string,
|
@Query('contentKeyword') contentKeyword?: string,
|
||||||
@Query('channelKeyword') channelKeyword?: string,
|
@Query('channelKeyword') channelKeyword?: string,
|
||||||
|
@Query('carrier') carrier?: string,
|
||||||
@Query('queuedAtFrom') queuedAtFrom?: string,
|
@Query('queuedAtFrom') queuedAtFrom?: string,
|
||||||
@Query('queuedAtTo') queuedAtTo?: string,
|
@Query('queuedAtTo') queuedAtTo?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@@ -54,6 +55,7 @@ export class AdminOperationsController {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
contentKeyword,
|
contentKeyword,
|
||||||
channelKeyword,
|
channelKeyword,
|
||||||
|
carrier,
|
||||||
queuedAtFrom,
|
queuedAtFrom,
|
||||||
queuedAtTo,
|
queuedAtTo,
|
||||||
status,
|
status,
|
||||||
@@ -70,6 +72,7 @@ export class AdminOperationsController {
|
|||||||
@Query('phoneNumber') phoneNumber: string | undefined,
|
@Query('phoneNumber') phoneNumber: string | undefined,
|
||||||
@Query('contentKeyword') contentKeyword: string | undefined,
|
@Query('contentKeyword') contentKeyword: string | undefined,
|
||||||
@Query('channelKeyword') channelKeyword: string | undefined,
|
@Query('channelKeyword') channelKeyword: string | undefined,
|
||||||
|
@Query('carrier') carrier: string | undefined,
|
||||||
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
|
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
|
||||||
@Query('queuedAtTo') queuedAtTo: string | undefined,
|
@Query('queuedAtTo') queuedAtTo: string | undefined,
|
||||||
@Query('status') status: string | undefined,
|
@Query('status') status: string | undefined,
|
||||||
@@ -82,6 +85,7 @@ export class AdminOperationsController {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
contentKeyword,
|
contentKeyword,
|
||||||
channelKeyword,
|
channelKeyword,
|
||||||
|
carrier,
|
||||||
queuedAtFrom,
|
queuedAtFrom,
|
||||||
queuedAtTo,
|
queuedAtTo,
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ describe('OperationsService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => {
|
it('filters send-chain messages by tenant, application, channel, carrier, content, date, task, phone, and status', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
@@ -202,6 +202,7 @@ describe('OperationsService', () => {
|
|||||||
messageId: 'MSG-1',
|
messageId: 'MSG-1',
|
||||||
phoneNumber: '13800000001',
|
phoneNumber: '13800000001',
|
||||||
contentKeyword: '验证码',
|
contentKeyword: '验证码',
|
||||||
|
carrier: 'mobile',
|
||||||
queuedAtFrom: '2026-07-01',
|
queuedAtFrom: '2026-07-01',
|
||||||
queuedAtTo: '2026-07-02',
|
queuedAtTo: '2026-07-02',
|
||||||
status: 'delivered',
|
status: 'delivered',
|
||||||
@@ -215,6 +216,7 @@ describe('OperationsService', () => {
|
|||||||
batchTaskId: 'task-1',
|
batchTaskId: 'task-1',
|
||||||
messageId: 'MSG-1',
|
messageId: 'MSG-1',
|
||||||
phoneNumber: '13800000001',
|
phoneNumber: '13800000001',
|
||||||
|
carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] },
|
||||||
status: 'delivered',
|
status: 'delivered',
|
||||||
content: { contains: '验证码', mode: 'insensitive' },
|
content: { contains: '验证码', mode: 'insensitive' },
|
||||||
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
|
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
|
||||||
@@ -238,6 +240,34 @@ describe('OperationsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats null and nonstandard carrier values as unrecognized', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
|
await service.listMessages({ carrier: 'unknown' });
|
||||||
|
|
||||||
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ carrier: null },
|
||||||
|
{
|
||||||
|
carrier: {
|
||||||
|
notIn: [
|
||||||
|
'mobile', 'cmcc', '移动', '中国移动',
|
||||||
|
'unicom', 'cucc', '联通', '中国联通',
|
||||||
|
'telecom', 'ctcc', '电信', '中国电信',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('separates upstream submit failures from post-acceptance delivery failures', async () => {
|
it('separates upstream submit failures from post-acceptance delivery failures', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
@@ -369,7 +399,13 @@ describe('OperationsService', () => {
|
|||||||
todaySpendCents: 24000n,
|
todaySpendCents: 24000n,
|
||||||
balanceCents: 1000000n,
|
balanceCents: 1000000n,
|
||||||
creditCents: 50000n,
|
creditCents: 50000n,
|
||||||
}]);
|
}]).mockResolvedValueOnce([
|
||||||
|
{ hour: 9, submittedCount: 12n, successCount: 10n },
|
||||||
|
{ hour: 10, submittedCount: 5n, successCount: 4n },
|
||||||
|
]).mockResolvedValueOnce([
|
||||||
|
{ category: 'templates', count: 3n, averageProcessingMs: 90_000n },
|
||||||
|
{ category: 'signatures', count: 2n, averageProcessingMs: 120_000n },
|
||||||
|
]);
|
||||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
prisma.cmppDownstreamDelivery.count = jest.fn()
|
||||||
.mockResolvedValueOnce(3)
|
.mockResolvedValueOnce(3)
|
||||||
.mockResolvedValueOnce(2)
|
.mockResolvedValueOnce(2)
|
||||||
@@ -394,6 +430,17 @@ describe('OperationsService', () => {
|
|||||||
total: 5,
|
total: 5,
|
||||||
},
|
},
|
||||||
today: expect.objectContaining({ returnedCents: 10 }),
|
today: expect.objectContaining({ returnedCents: 10 }),
|
||||||
|
hourlySendTrend: expect.arrayContaining([
|
||||||
|
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
|
||||||
|
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
|
||||||
|
]),
|
||||||
|
auditProcessingSpeed: [
|
||||||
|
{ category: 'enterpriseCertifications', label: '企业认证', count: 0, averageProcessingMs: null },
|
||||||
|
{ category: 'smsAudits', label: '短信审核', count: 0, averageProcessingMs: null },
|
||||||
|
{ category: 'templates', label: '模板', count: 3, averageProcessingMs: 90000 },
|
||||||
|
{ category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 },
|
||||||
|
{ category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null },
|
||||||
|
],
|
||||||
enterpriseSpendRanks: [{
|
enterpriseSpendRanks: [{
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
tenantName: '租户A',
|
tenantName: '租户A',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface MessageQuery {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
contentKeyword?: string;
|
contentKeyword?: string;
|
||||||
|
carrier?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
queuedAtFrom?: string;
|
queuedAtFrom?: string;
|
||||||
queuedAtTo?: string;
|
queuedAtTo?: string;
|
||||||
@@ -321,11 +322,14 @@ export class OperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dashboard(query: { tenantId?: string }) {
|
async dashboard(query: { tenantId?: string }) {
|
||||||
const sinceToday = startOfToday();
|
|
||||||
const businessDay = qualityBusinessDay();
|
const businessDay = qualityBusinessDay();
|
||||||
|
const sinceToday = businessDay.startAt;
|
||||||
const downstreamAlertWindow = downstreamAlertWindows();
|
const downstreamAlertWindow = downstreamAlertWindows();
|
||||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||||
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
const todayMessageWhereClause = {
|
||||||
|
...messageWhereClause,
|
||||||
|
queuedAt: { gte: sinceToday, lt: businessDay.endAt },
|
||||||
|
};
|
||||||
const [
|
const [
|
||||||
taskCount,
|
taskCount,
|
||||||
messageGroups,
|
messageGroups,
|
||||||
@@ -345,6 +349,8 @@ export class OperationsService {
|
|||||||
downstreamStalledPendingCount,
|
downstreamStalledPendingCount,
|
||||||
downstreamStalledAckCount,
|
downstreamStalledAckCount,
|
||||||
downstreamRecentFailedCount,
|
downstreamRecentFailedCount,
|
||||||
|
hourlySendRows,
|
||||||
|
auditSpeedRows,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||||
this.prisma.smsMessageRecord.groupBy({
|
this.prisma.smsMessageRecord.groupBy({
|
||||||
@@ -451,8 +457,124 @@ export class OperationsService {
|
|||||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
this.prisma.$queryRaw<Array<{
|
||||||
|
hour: number;
|
||||||
|
submittedCount: bigint;
|
||||||
|
successCount: bigint;
|
||||||
|
}>>(Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM message."queuedAt" AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
|
||||||
|
COUNT(*)::bigint AS "submittedCount",
|
||||||
|
COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount"
|
||||||
|
FROM "SmsMessageRecord" message
|
||||||
|
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||||
|
AND message."queuedAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1
|
||||||
|
`),
|
||||||
|
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
|
||||||
|
this.prisma.$queryRaw<Array<{
|
||||||
|
category: string;
|
||||||
|
count: bigint;
|
||||||
|
averageProcessingMs: bigint | null;
|
||||||
|
}>>(Prisma.sql`
|
||||||
|
WITH review_samples AS (
|
||||||
|
SELECT
|
||||||
|
'enterpriseCertifications'::text AS category,
|
||||||
|
certification."submittedAt" AS "submittedAt",
|
||||||
|
certification."reviewedAt" AS "reviewedAt"
|
||||||
|
FROM "EnterpriseCertification" certification
|
||||||
|
WHERE certification."reviewedAt" >= ${businessDay.startAt}
|
||||||
|
AND certification."reviewedAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null})
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'smsAudits'::text,
|
||||||
|
task."createdAt",
|
||||||
|
task."reviewedAt"
|
||||||
|
FROM "SmsSendTask" task
|
||||||
|
WHERE task."reviewedAt" >= ${businessDay.startAt}
|
||||||
|
AND task."reviewedAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null})
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'drainageInfos'::text,
|
||||||
|
drainage."submittedAt",
|
||||||
|
drainage."reviewedAt"
|
||||||
|
FROM "SmsDrainageInfo" drainage
|
||||||
|
WHERE drainage."reviewedAt" >= ${businessDay.startAt}
|
||||||
|
AND drainage."reviewedAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null})
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
CASE review."targetType"
|
||||||
|
WHEN 'sms_signature' THEN 'signatures'
|
||||||
|
WHEN 'sms_template' THEN 'templates'
|
||||||
|
END,
|
||||||
|
submission."createdAt",
|
||||||
|
review."createdAt"
|
||||||
|
FROM "AuditRecord" review
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT pending."createdAt"
|
||||||
|
FROM "AuditRecord" pending
|
||||||
|
WHERE pending."targetType" = review."targetType"
|
||||||
|
AND pending."targetId" = review."targetId"
|
||||||
|
AND pending."statusAfter" = 'pending'
|
||||||
|
AND pending."createdAt" <= review."createdAt"
|
||||||
|
ORDER BY pending."createdAt" DESC
|
||||||
|
LIMIT 1
|
||||||
|
) submission ON true
|
||||||
|
WHERE review."targetType" IN ('sms_signature', 'sms_template')
|
||||||
|
AND review."statusBefore" = 'pending'
|
||||||
|
AND review."statusAfter" IN ('approved', 'rejected')
|
||||||
|
AND review."createdAt" >= ${businessDay.startAt}
|
||||||
|
AND review."createdAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null})
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
category,
|
||||||
|
COUNT(*)::bigint AS count,
|
||||||
|
ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs"
|
||||||
|
FROM review_samples
|
||||||
|
WHERE "reviewedAt" >= "submittedAt"
|
||||||
|
GROUP BY category
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||||
|
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
||||||
|
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
||||||
|
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
||||||
|
const row = hourlyRowsByHour.get(hour);
|
||||||
|
return {
|
||||||
|
hour,
|
||||||
|
label: `${String(hour).padStart(2, '0')}:00`,
|
||||||
|
submittedCount: Number(row?.submittedCount ?? 0),
|
||||||
|
successCount: Number(row?.successCount ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row]));
|
||||||
|
const auditProcessingSpeed = [
|
||||||
|
['enterpriseCertifications', '企业认证'],
|
||||||
|
['smsAudits', '短信审核'],
|
||||||
|
['templates', '模板'],
|
||||||
|
['signatures', '签名'],
|
||||||
|
['drainageInfos', '引流信息'],
|
||||||
|
].map(([category, label]) => {
|
||||||
|
const row = auditSpeedByCategory.get(category);
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
label,
|
||||||
|
count: Number(row?.count ?? 0),
|
||||||
|
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
|
||||||
|
};
|
||||||
|
});
|
||||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
||||||
return {
|
return {
|
||||||
taskCount,
|
taskCount,
|
||||||
@@ -473,6 +595,8 @@ export class OperationsService {
|
|||||||
gatewayConnections: connectionGroups,
|
gatewayConnections: connectionGroups,
|
||||||
pendingAuditCount: pendingAudits.total,
|
pendingAuditCount: pendingAudits.total,
|
||||||
pendingAudits,
|
pendingAudits,
|
||||||
|
hourlySendTrend,
|
||||||
|
auditProcessingSpeed,
|
||||||
downstreamDeliverySummary: {
|
downstreamDeliverySummary: {
|
||||||
pending: downstreamPendingCount,
|
pending: downstreamPendingCount,
|
||||||
failed: downstreamFailedCount,
|
failed: downstreamFailedCount,
|
||||||
@@ -507,6 +631,8 @@ export class OperationsService {
|
|||||||
gatewayConnections: [],
|
gatewayConnections: [],
|
||||||
pendingAuditCount: dashboard.pendingAuditCount,
|
pendingAuditCount: dashboard.pendingAuditCount,
|
||||||
pendingAudits: dashboard.pendingAudits,
|
pendingAudits: dashboard.pendingAudits,
|
||||||
|
hourlySendTrend: dashboard.hourlySendTrend,
|
||||||
|
auditProcessingSpeed: dashboard.auditProcessingSpeed,
|
||||||
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
|
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
|
||||||
accounts: dashboard.accounts.map(clientAccountView),
|
accounts: dashboard.accounts.map(clientAccountView),
|
||||||
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
|
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
|
||||||
@@ -1678,6 +1804,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
|||||||
batchTaskId: query.taskId,
|
batchTaskId: query.taskId,
|
||||||
messageId: query.messageId,
|
messageId: query.messageId,
|
||||||
phoneNumber: query.phoneNumber,
|
phoneNumber: query.phoneNumber,
|
||||||
|
...carrierWhere(query.carrier),
|
||||||
...statusWhere,
|
...statusWhere,
|
||||||
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
||||||
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
||||||
@@ -1690,6 +1817,35 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recognizedCarrierValues = [
|
||||||
|
'mobile', 'cmcc', '移动', '中国移动',
|
||||||
|
'unicom', 'cucc', '联通', '中国联通',
|
||||||
|
'telecom', 'ctcc', '电信', '中国电信',
|
||||||
|
];
|
||||||
|
|
||||||
|
function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
|
||||||
|
if (!carrier) return {};
|
||||||
|
// Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized.
|
||||||
|
if (carrier === 'unknown') {
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ carrier: null },
|
||||||
|
{ carrier: { notIn: recognizedCarrierValues } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const valuesByCarrier: Record<string, string[]> = {
|
||||||
|
mobile: ['mobile', 'cmcc', '移动', '中国移动'],
|
||||||
|
unicom: ['unicom', 'cucc', '联通', '中国联通'],
|
||||||
|
telecom: ['telecom', 'ctcc', '电信', '中国电信'],
|
||||||
|
};
|
||||||
|
return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {};
|
||||||
|
}
|
||||||
|
|
||||||
function startOfShanghaiDay(value: string) {
|
function startOfShanghaiDay(value: string) {
|
||||||
return new Date(`${value}T00:00:00+08:00`);
|
return new Date(`${value}T00:00:00+08:00`);
|
||||||
}
|
}
|
||||||
@@ -1735,12 +1891,6 @@ function normalizeGroupBy(groupBy?: string) {
|
|||||||
return 'channelId';
|
return 'channelId';
|
||||||
}
|
}
|
||||||
|
|
||||||
function startOfToday() {
|
|
||||||
const date = new Date();
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput {
|
function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput {
|
||||||
return {
|
return {
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { DictionariesModule } from '../dictionaries/dictionaries.module';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { RiskReviewModule } from '../risk-review/risk-review.module';
|
import { RiskReviewModule } from '../risk-review/risk-review.module';
|
||||||
import { SmsConfigModule } from '../sms-config/sms-config.module';
|
import { SmsConfigModule } from '../sms-config/sms-config.module';
|
||||||
@@ -10,7 +11,7 @@ import { GatewayEventsController } from './gateway-events.controller';
|
|||||||
import { SendChainService } from './send-chain.service';
|
import { SendChainService } from './send-chain.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, BillingModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
|
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
|
||||||
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
|
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
|
||||||
providers: [SendChainService],
|
providers: [SendChainService],
|
||||||
exports: [SendChainService],
|
exports: [SendChainService],
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]),
|
findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]),
|
||||||
},
|
},
|
||||||
phoneSegment: {
|
phoneSegment: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ prefix: '1380000', province: '山东', city: '济南' }),
|
findMany: jest.fn().mockResolvedValue([{ prefix: '1380000', province: '山东', city: '济南' }]),
|
||||||
},
|
},
|
||||||
smsChannel: {
|
smsChannel: {
|
||||||
findFirst: jest.fn().mockResolvedValue(channel),
|
findFirst: jest.fn().mockResolvedValue(channel),
|
||||||
@@ -1873,6 +1873,26 @@ describe('SendChainService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses persisted carrier and province without querying routing dictionaries again', async () => {
|
||||||
|
const { service, prisma } = createService();
|
||||||
|
service['identifyCarrier'] = jest.fn();
|
||||||
|
service['identifyProvince'] = jest.fn();
|
||||||
|
|
||||||
|
await expect(service['selectChannelForMessage']({
|
||||||
|
id: 'record-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
signatureId: 'sig-1',
|
||||||
|
phoneNumber: '13800000001',
|
||||||
|
carrier: 'mobile',
|
||||||
|
province: '山东',
|
||||||
|
})).resolves.toEqual(expect.objectContaining({ carrier: 'mobile', province: '山东' }));
|
||||||
|
|
||||||
|
expect(service['identifyCarrier']).not.toHaveBeenCalled();
|
||||||
|
expect(service['identifyProvince']).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('updates submit result status, charges billing, and task progress', async () => {
|
it('updates submit result status, charges billing, and task progress', async () => {
|
||||||
const { service, prisma, billing } = createService();
|
const { service, prisma, billing } = createService();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { isIpAllowed } from '../common/ip-allowlist';
|
import { isIpAllowed } from '../common/ip-allowlist';
|
||||||
import { moneyToNumber } from '../common/money';
|
import { moneyToNumber } from '../common/money';
|
||||||
|
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||||
import { OpenApiService } from '../open-api/open-api.service';
|
import { OpenApiService } from '../open-api/open-api.service';
|
||||||
@@ -315,13 +316,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
|
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
|
||||||
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
|
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
|
||||||
private upstreamReceiptInboxScanRunning = false;
|
private upstreamReceiptInboxScanRunning = false;
|
||||||
|
private readonly phoneRouting: PhoneRoutingLookupService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly riskReview: RiskReviewService,
|
private readonly riskReview: RiskReviewService,
|
||||||
@Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService,
|
@Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService,
|
||||||
) {}
|
@Optional() phoneRouting?: PhoneRoutingLookupService,
|
||||||
|
) {
|
||||||
|
this.phoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma);
|
||||||
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
|
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
|
||||||
@@ -3600,6 +3605,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
queuedAt?: Date;
|
queuedAt?: Date;
|
||||||
clientSrcId?: string | null;
|
clientSrcId?: string | null;
|
||||||
applicationExtension?: string | null;
|
applicationExtension?: string | null;
|
||||||
|
carrier?: string | null;
|
||||||
|
province?: string | null;
|
||||||
},
|
},
|
||||||
reason: string,
|
reason: string,
|
||||||
sourceSubmitRecordId?: string,
|
sourceSubmitRecordId?: string,
|
||||||
@@ -3659,7 +3666,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
})}`);
|
})}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber));
|
const retryCarrier = message.carrier
|
||||||
|
? normalizeCarrier(message.carrier)
|
||||||
|
: await this.identifyCarrier(message.phoneNumber);
|
||||||
|
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier);
|
||||||
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
||||||
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
||||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||||
@@ -3673,7 +3683,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const routed = await this.selectChannelForMessage(message, {
|
const routed = await this.selectChannelForMessage({ ...message, carrier: retryCarrier }, {
|
||||||
forceNational: true,
|
forceNational: true,
|
||||||
excludeChannelIds: attemptedChannelIds,
|
excludeChannelIds: attemptedChannelIds,
|
||||||
});
|
});
|
||||||
@@ -3708,18 +3718,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async selectChannelForMessage(
|
private async selectChannelForMessage(
|
||||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||||
): Promise<RoutedChannel> {
|
): Promise<RoutedChannel> {
|
||||||
if (!message.applicationId) {
|
if (!message.applicationId) {
|
||||||
throw new BadRequestException('短信应用未配置,无法选择通道组');
|
throw new BadRequestException('短信应用未配置,无法选择通道组');
|
||||||
}
|
}
|
||||||
const carrier = await this.identifyCarrier(message.phoneNumber);
|
const hasPersistedRouting = Boolean(message.carrier);
|
||||||
const province = await this.identifyProvince(message.phoneNumber);
|
const [carrier, province] = hasPersistedRouting
|
||||||
|
? [normalizeCarrier(message.carrier), message.province ?? null]
|
||||||
|
: await Promise.all([
|
||||||
|
this.identifyCarrier(message.phoneNumber),
|
||||||
|
this.identifyProvince(message.phoneNumber),
|
||||||
|
]);
|
||||||
|
if (!hasPersistedRouting) {
|
||||||
await this.prisma.smsMessageRecord.update({
|
await this.prisma.smsMessageRecord.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
data: { carrier, province },
|
data: { carrier, province },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier);
|
const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier);
|
||||||
const excluded = new Set(options.excludeChannelIds ?? []);
|
const excluded = new Set(options.excludeChannelIds ?? []);
|
||||||
const signatureId = await this.resolveMessageSignatureId(message);
|
const signatureId = await this.resolveMessageSignatureId(message);
|
||||||
@@ -3777,30 +3794,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async identifyCarrier(phoneNumber: string) {
|
private async identifyCarrier(phoneNumber: string) {
|
||||||
const rules = await this.prisma.phoneCarrierRule.findMany({
|
return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber));
|
||||||
where: { status: 'active' },
|
|
||||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
|
||||||
});
|
|
||||||
for (const rule of rules) {
|
|
||||||
try {
|
|
||||||
if (new RegExp(rule.pattern).test(phoneNumber)) {
|
|
||||||
return normalizeCarrier(rule.carrier);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'mobile';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async identifyProvince(phoneNumber: string) {
|
private async identifyProvince(phoneNumber: string) {
|
||||||
for (let length = Math.min(7, phoneNumber.length); length >= 3; length -= 1) {
|
return this.phoneRouting.identifyProvince(phoneNumber);
|
||||||
const segment = await this.prisma.phoneSegment.findUnique({ where: { prefix: phoneNumber.slice(0, length) } });
|
|
||||||
if (segment?.province) {
|
|
||||||
return segment.province;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isChannelSendAvailable(channel: { status: string; connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }> }) {
|
private isChannelSendAvailable(channel: { status: string; connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }> }) {
|
||||||
|
|||||||
@@ -1815,3 +1815,24 @@
|
|||||||
- 企业应用列表及分页接口只能在 Prisma 查询中引用 `SmsApplication` 模型真实存在的字段;接口入参或响应别名(例如 `passwordCipher`)不得作为数据库 `select`、`omit` 或筛选字段。
|
- 企业应用列表及分页接口只能在 Prisma 查询中引用 `SmsApplication` 模型真实存在的字段;接口入参或响应别名(例如 `passwordCipher`)不得作为数据库 `select`、`omit` 或筛选字段。
|
||||||
- 企业应用列表必须排除真实敏感字段 `secretHash`,不得通过列表接口返回应用接入密码;密码只允许在既有受控的专用参数接口中按权限读取。
|
- 企业应用列表必须排除真实敏感字段 `secretHash`,不得通过列表接口返回应用接入密码;密码只允许在既有受控的专用参数接口中按权限读取。
|
||||||
- 企业应用黑名单等页面加载筛选项时应使用轻量企业应用选项接口,避免依赖包含连接状态和统计信息的完整企业应用列表。
|
- 企业应用黑名单等页面加载筛选项时应使用轻量企业应用选项接口,避免依赖包含连接状态和统计信息的完整企业应用列表。
|
||||||
|
|
||||||
|
## 短信号码路由识别性能约束(2026-07-29)
|
||||||
|
|
||||||
|
- 短信发送仍以启用的 `PhoneCarrierRule` 作为运营商路由判定依据,手机号段表继续用于省份识别;不得直接用 `PhoneSegment.carrier` 替代运营商业务规则。
|
||||||
|
- 启用的运营商正则规则应在 API 进程内编译并短时缓存,默认有效期 30 秒且允许通过 `PHONE_CARRIER_RULE_CACHE_TTL_MS` 调整;并发首次加载必须合并为一次数据库查询,规则新增或删除成功后必须立即使当前进程缓存失效。
|
||||||
|
- 缓存失效期间正在执行的旧规则查询不得覆盖新一代缓存;多实例部署时允许其他实例最多保留一个 TTL 周期的旧缓存,后续扩容到多 API 实例时应升级为跨实例版本通知。
|
||||||
|
- 省份识别必须把 7 位至 3 位候选前缀放入一次真实 PostgreSQL 查询,并按前缀从长到短选择首个有省份的结果;未知号码也不得逐级产生 5 次数据库往返。
|
||||||
|
- `SmsMessageRecord` 已持久化 `carrier` 后,补发和再次选路必须同时复用该记录的 `carrier/province`,包括已确认省份未知而保存为 `null` 的情况,不得重复加载运营商规则或号码段。
|
||||||
|
|
||||||
|
## 运营看板与短信记录运营商筛选(2026-07-29)
|
||||||
|
|
||||||
|
- 运营看板“今日发送趋势”按 `Asia/Shanghai` 自然日固定返回 00:00 至 23:00 共 24 个小时桶,以折线图同时展示每小时业务短信提交总条数和最终状态为 `delivered` 的成功条数;无数据小时必须补零,不使用前端静态数据。
|
||||||
|
- 运营看板“审核处理趋势”更名为“审核处理速度”。按企业认证、短信审核、模板、签名、引流信息五类展示北京时间当天已完成审核数量及平均处理时长;处理时长为同一审核事项本轮审核完成时间减本轮提交时间,无有效提交时间或出现负时长的记录不参与平均值。
|
||||||
|
- 签名通道发送质量明细的运营商概览固定按移动、联通、电信排列;未识别运营商如有数据排在三大运营商之后,数据库聚合返回顺序不得直接作为展示顺序。
|
||||||
|
- 运营端短信记录新增运营商筛选,选项为全部、移动、联通、电信、未识别。筛选必须由真实后端和 PostgreSQL 执行,并同时作用于分页总数、当前页和 CSV 导出;“未识别”包含空运营商及不属于三大运营商标准/兼容值的历史记录。
|
||||||
|
|
||||||
|
## 短信审核列表信息密度调整(2026-07-29)
|
||||||
|
|
||||||
|
- 短信审核列表将“发送企业 / 企业应用”、“提交时间 / 审核来源”和“号码数量 / 状态”分别合并为三个上下分层的信息格,相关信息不得删除或改为仅在详情中展示。
|
||||||
|
- 短信内容列应设置为列表主要宽列,桌面端目标宽度不小于 440px;当可用宽度不足时由表格容器横向滚动,不得通过压缩内容列造成短信正文难以阅读。
|
||||||
|
- 号码数量继续使用真实审核任务关联短信记录数量,并保留打开真实号码分页列表的交互;本次仅调整展示布局,不改变审核查询、批量选择、通过或驳回流程。
|
||||||
|
|||||||
@@ -4017,3 +4017,35 @@ npm run verify:phase8
|
|||||||
| TC-APP-LIST-001 | 运营端打开企业应用列表并请求分页接口 | Prisma 查询仅排除真实存在的 `secretHash`;接口 HTTP 200,不因 DTO 字段 `passwordCipher` 触发 Prisma 校验错误 |
|
| TC-APP-LIST-001 | 运营端打开企业应用列表并请求分页接口 | Prisma 查询仅排除真实存在的 `secretHash`;接口 HTTP 200,不因 DTO 字段 `passwordCipher` 触发 Prisma 校验错误 |
|
||||||
| TC-APP-LIST-002 | 运营端打开企业应用黑名单并加载企业应用筛选项 | 黑名单及筛选项均从真实后端返回;企业应用加载不触发 HTTP 500,列表响应不包含 `secretHash` |
|
| TC-APP-LIST-002 | 运营端打开企业应用黑名单并加载企业应用筛选项 | 黑名单及筛选项均从真实后端返回;企业应用加载不触发 HTTP 500,列表响应不包含 `secretHash` |
|
||||||
| TC-APP-LIST-003 | 校验企业应用列表 Prisma `omit` 字段 | 所有 `omit` 键都存在于当前生成客户端的 `SmsApplication` DMMF 模型,模型字段变更或错误别名会使回归测试失败 |
|
| TC-APP-LIST-003 | 校验企业应用列表 Prisma `omit` 字段 | 所有 `omit` 键都存在于当前生成客户端的 `SmsApplication` DMMF 模型,模型字段变更或错误别名会使回归测试失败 |
|
||||||
|
|
||||||
|
## 2026-07-29 短信号码路由识别性能回归用例
|
||||||
|
|
||||||
|
| 用例编号 | 场景 | 预期结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-PHONE-ROUTE-001 | 并发使用多个号码识别运营商 | 只查询一次全部启用规则;正则只编译一次,并按优先级返回移动、联通或电信业务路由 |
|
||||||
|
| TC-PHONE-ROUTE-002 | 运营商规则新增或删除后再次识别 | 写入成功后当前进程缓存立即失效,下一次识别读取真实新规则;正在完成的旧查询不得覆盖新缓存 |
|
||||||
|
| TC-PHONE-ROUTE-003 | 号码同时命中 7 位、5 位和 3 位号段 | 单次 `prefix IN (...)` 查询全部候选,选择最长的 7 位号段省份 |
|
||||||
|
| TC-PHONE-ROUTE-004 | 号码不命中任何号段 | 只执行一次号段查询并返回省份未知,不产生 7 位至 3 位的 5 次往返 |
|
||||||
|
| TC-PHONE-ROUTE-005 | 已完成首次识别的短信进入失败补发 | 复用短信记录持久化的运营商和省份,不再查询规则和号段;既有通道组、报备及全国/省份路由规则不变 |
|
||||||
|
|
||||||
|
## 2026-07-29 运营看板与运营商筛选用例
|
||||||
|
|
||||||
|
| 用例编号 | 场景 | 预期结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-DASHBOARD-HOURLY-001 | 北京时间当天仅部分小时存在短信记录 | 今日发送趋势固定展示 24 个小时;有数据小时显示真实提交总条数和最终成功条数,其余小时补 0 |
|
||||||
|
| TC-DASHBOARD-HOURLY-002 | 同一小时包含成功、失败、未知短信 | 提交总条数包含全部业务短信,成功条数只包含最终状态 `delivered` 的短信,两条折线均来自后端 PostgreSQL 聚合 |
|
||||||
|
| TC-DASHBOARD-AUDIT-SPEED-003 | 当天五类审核存在不同数量和处理时长 | “审核处理速度”按企业认证、短信审核、模板、签名、引流信息展示已处理数量及审核完成时间减提交时间的平均分钟数 |
|
||||||
|
| TC-DASHBOARD-AUDIT-SPEED-004 | 某类当天无审核或历史记录缺少可配对提交时间 | 该类数量显示 0;无有效样本时平均时长为空,不使用 0 时长伪造结果,负时长不参与统计 |
|
||||||
|
| TC-ANALYTICS-CARRIER-ORDER-014 | 后端以电信、移动、联通顺序返回运营商概览 | 弹窗始终按移动、联通、电信展示,未识别项如有数据排在三大运营商之后 |
|
||||||
|
| TC-SMS-RECORD-CARRIER-007 | 分别选择移动、联通、电信并查询和翻页 | 请求携带真实 `carrier` 条件;当前页、总数和 CSV 导出均只包含所选运营商及兼容历史值 |
|
||||||
|
| TC-SMS-RECORD-CARRIER-008 | 选择未识别 | 返回运营商为空或非三大运营商标准/兼容值的真实短信记录,不把移动、联通、电信混入结果 |
|
||||||
|
| TC-SMS-RECORD-CARRIER-009 | 点击重置 | 运营商恢复“全部”,日期等既有默认条件保持原规则,并从第一页重新请求后端 |
|
||||||
|
|
||||||
|
## 2026-07-29 短信审核列表布局回归用例
|
||||||
|
|
||||||
|
| 用例编号 | 场景 | 预期结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-SMS-AUDIT-LAYOUT-001 | 桌面端打开短信审核列表 | 每行分别以一个格子展示发送企业/企业应用、提交时间/审核来源、号码数量/状态,六列结构对齐且信息完整 |
|
||||||
|
| TC-SMS-AUDIT-LAYOUT-002 | 查看包含较长短信正文的审核任务 | 短信内容列宽不小于 440px,正文获得明显更大的展示空间,不被其他元信息列无意义挤压 |
|
||||||
|
| TC-SMS-AUDIT-LAYOUT-003 | 点击合并格中的“查看列表”并操作待审核任务 | 真实号码分页弹窗正常打开;详情、勾选、批量通过和驳回入口不受布局调整影响 |
|
||||||
|
| TC-SMS-AUDIT-LAYOUT-004 | 在窄窗口打开短信审核列表 | 表格保持信息格内部上下层级,宽度不足时允许容器横向滚动,不发生文字重叠或操作按钮遮挡 |
|
||||||
|
|||||||
@@ -2698,3 +2698,41 @@ git diff --check
|
|||||||
- Node.js v24.14.0 下短信配置定向 1 suite / 62 tests、API 全量 26 suites / 367 tests 全部通过;Prisma format、validate、generate、API TypeScript 正式构建、前端 TypeScript/Vite 生产构建、Gateway `go test ./...` 与 `go vet ./...`、依赖安全门禁和 `git diff --check` 均通过。Jest 仅保留既有强制结束异步句柄提示,Vite 仅保留既有大 chunk 体积事实。
|
- Node.js v24.14.0 下短信配置定向 1 suite / 62 tests、API 全量 26 suites / 367 tests 全部通过;Prisma format、validate、generate、API TypeScript 正式构建、前端 TypeScript/Vite 生产构建、Gateway `go test ./...` 与 `go vet ./...`、依赖安全门禁和 `git diff --check` 均通过。Jest 仅保留既有强制结束异步句柄提示,Vite 仅保留既有大 chunk 体积事实。
|
||||||
- 发布前使用预生产真实 Prisma Client 和 PostgreSQL 只读执行 `SmsApplication.findMany(... omit: { secretHash: true })` 成功返回 10 条企业应用,结果中 `secretHash` 泄漏计数为 0;未写入或修改预生产数据。
|
- 发布前使用预生产真实 Prisma Client 和 PostgreSQL 只读执行 `SmsApplication.findMany(... omit: { secretHash: true })` 成功返回 10 条企业应用,结果中 `secretHash` 泄漏计数为 0;未写入或修改预生产数据。
|
||||||
- 既有 `api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/` 和空文件 `=` 继续作为构建或临时产物保留,不纳入提交。
|
- 既有 `api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/` 和空文件 `=` 继续作为构建或临时产物保留,不纳入提交。
|
||||||
|
|
||||||
|
## 2026-07-29 短信号码路由查询降载(本地未提交)
|
||||||
|
|
||||||
|
- 预生产只读诊断确认手机号段 516217 条、全部为 7 位且省份完整,唯一前缀索引单次执行约 0.058ms;运营商规则 30 条,单次全量读取约 0.044ms。最近 24 小时仅 41 条短信、峰值 2 条/分钟,因此当前不是线上瓶颈,但发送 Worker 并发 50 与默认 10 个 PostgreSQL 连接组合下存在批量放大风险。
|
||||||
|
- 使用预生产真实 PostgreSQL 和最近 100 个真实号码进行只读微基准:当前每条正常号码 2 次查询,顺序总耗时约 108ms;连接池预热并发 50 时号码识别部分 100 条约 86ms、单条 P50 约 40ms;缓存规则后 100 条约 20ms。基准只覆盖号码识别,不等同于完整短信发送耗时。
|
||||||
|
- 新增共享 `PhoneRoutingLookupService`:30 条启用规则编译后默认缓存 30 秒,并发冷加载合并;规则新增和删除成功后立即失效,失效前正在执行的旧加载不会覆盖新缓存。多实例场景仍由 TTL 限定其他实例最多 30 秒陈旧窗口。
|
||||||
|
- 省份识别由最多 5 次 `findUnique` 改为一次 7 位至 3 位候选前缀 `findMany`,在内存中选择最长命中;未知号码同样只查询一次。运营商路由仍由独立 `PhoneCarrierRule` 决定,不改变广电等业务归类口径。
|
||||||
|
- 首次路由继续把运营商和省份写入真实 `SmsMessageRecord`;后续补发检测到已持久化运营商时直接复用运营商及省份(含 `province=null`),不再重复读取规则和号段。
|
||||||
|
- Node.js v24.14.0 下新增路由服务及发送链定向 3 suites / 122 tests、API 全量 27 suites / 374 tests 全部通过;Prisma format、validate、generate、API TypeScript 正式构建、前端 TypeScript/Vite 生产构建、Gateway `go test ./...` 与 `go vet ./...`、依赖安全门禁和 `git diff --check` 均通过。首次直接使用系统默认 Node.js v14.17.4 执行安全门禁因运行时过旧失败,切换到项目验证用 Node.js v24.14.0 后通过;本机 5432 未监听,因此未虚报本地数据库实测。
|
||||||
|
- 本轮按用户要求只保留本地未提交修改,不提交、不推送、不部署;既有构建缓存、`outputs/`、空文件 `=` 和 `tsconfig.tsbuildinfo` 继续原样保留。
|
||||||
|
|
||||||
|
## 2026-07-29 运营看板与短信记录运营商筛选(本地未提交)
|
||||||
|
|
||||||
|
- 运营看板“今日发送趋势”已改为上海时区 24 个小时桶的真实折线图,同时展示业务短信提交总条数和最终成功条数;后端按 `SmsMessageRecord.queuedAt/status` 聚合并为无数据小时补零。
|
||||||
|
- “审核处理趋势”已更名为“审核处理速度”,按企业认证、短信审核、模板、签名、引流信息展示北京时间当天已完成数量及平均处理时长。企业认证、短信审核、引流信息使用各业务表提交/审核时间;签名和模板使用同一对象最近一次进入 `pending` 的审计记录与审核完成审计配对,负时长和无法配对的历史数据不参与平均值。
|
||||||
|
- 签名通道发送质量详情的运营商概览已显式固定为移动、联通、电信顺序,未识别项排在其后,不依赖数据库聚合返回顺序。
|
||||||
|
- 运营端短信记录新增全部、移动、联通、电信、未识别筛选;真实后端条件同时用于数据库分页、总数和 CSV 导出。兼容 `mobile/cmcc/移动/中国移动` 等历史值,空值及非三大运营商值归入未识别。
|
||||||
|
- Node.js v24.14.0 下 Operations 定向 1 suite / 26 tests、API 全量 27 suites / 375 tests 全部通过,API TypeScript 正式构建、前端 TypeScript 检查和 Vite 生产构建通过;全量 Jest 仅保留既有强制结束异步句柄提示,Vite 仅保留既有约 1.99MB 单 chunk 提示。首次通过系统 `npm` 启动定向测试未在 120 秒内输出,终止该次遗留测试进程后改用 Node.js v24 直接运行 Jest,测试正常完成,未将超时计为通过。
|
||||||
|
- 本机 PostgreSQL 5432 未监听。预发布 PostgreSQL 只读执行等价 SQL 成功:当天小时聚合返回 6 个有数据小时;审核速度 SQL 执行成功且当天无已完成审核样本;运营商真实存量分组为移动 575、联通 138、电信 121、未识别 87。验证过程未写数据库、未发送短信、未修改通道或企业数据。
|
||||||
|
- 本轮按用户要求保持未提交、未推送、未部署。工作区中另一会话既有的号码路由优化代码和文档继续保留,本节不将其归因于本需求。
|
||||||
|
|
||||||
|
## 2026-07-29 短信审核列表信息布局调整(本地未提交)
|
||||||
|
|
||||||
|
- 短信审核列表由原 8 列压缩为 6 列:发送企业与企业应用、提交时间与审核来源、号码数量与状态分别在同一单元格内上下分层展示;短信内容列设置为 440px 主要宽列。
|
||||||
|
- 号码数量仍打开真实后端分页号码列表,审核状态、详情、勾选、批量通过和驳回逻辑均未改变;本次没有新增 mock、静态数据或 localStorage 数据路径。
|
||||||
|
- 工作区中既有号码路由优化及另一会话的运营看板、短信记录运营商筛选修改全部保留,本节不将其归因于本需求。
|
||||||
|
- 恢复 `package-lock.json` 锁定依赖后,Node.js v24.14.0 下前端 TypeScript 检查和 Vite v8.0.16 生产构建通过,2442 个模块完成转换;仅保留既有约 1.99MB 单 chunk 提示。首次误用捆绑 pnpm 触发包管理器不一致保护并生成的两个临时 pnpm 文件已精确清理,没有纳入工作区修改。
|
||||||
|
- 本地 PostgreSQL、API 和前端预览启动成功,应用内浏览器及 Chrome 均无现成的本地运营登录会话,目标路由被真实鉴权跳转至图形验证码登录页。未绕过验证码,故未把登录页冒充短信审核列表的视觉和号码弹窗交互验收;本次启动的 PostgreSQL、API 和前端进程已清理,原先已运行的 Redis 保持不变。
|
||||||
|
- 本轮按用户要求只保留本地修改,不提交、不推送、不部署。
|
||||||
|
|
||||||
|
## 2026-07-29 号码路由、运营视图与短信审核布局汇总发布门禁
|
||||||
|
|
||||||
|
- 用户已明确授权将当前工作区代码提交、推送并发布到预生产。本次汇总范围为:号码路由规则缓存和单次最长前缀查询、运营看板逐小时发送趋势和审核处理速度、短信记录运营商真实后端筛选、签名质量运营商排序,以及短信审核组合单元格布局。
|
||||||
|
- 发布前 `main`、本地 `HEAD` 与 `origin/main` 均为 `500f43f673408900c3d658051d67cf0602f6005a`。工作区中不同会话形成的上述有效源码、测试和文档统一纳入本次授权范围;`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/` 和空文件 `=` 继续作为构建缓存或临时产物排除。
|
||||||
|
- 预生产只读基线确认 `.deployed-commit=500f43f673408900c3d658051d67cf0602f6005a`,76 条 migration 已应用且与源码目录一致。API、Gateway、Nginx、PostgreSQL、Redis、MinIO 均 active,`12026/17890/8090/3000/6379/5432/9000` 监听,内外健康接口和首页、运营端、客户端均 HTTP 200。
|
||||||
|
- 发布前 Redis Stream `gateway.submit.commands` 消费者 1、`pending=0`、`lag=0`,12 个通道 TPS 配置键存在;4 条 active 供应商通道均为 `connected 1/1`,最近 120 秒活跃下游客户连接为 0,API/Gateway 近 30 分钟 error 级 journal 均为 0。
|
||||||
|
- Node.js v24.14.0 下 API 全量 27 suites / 375 tests 全部通过;Prisma format、validate、generate、API TypeScript 正式构建、前端 TypeScript/Vite v8.0.16 生产构建、Gateway `go test ./...` 与 `go vet ./...`、依赖安全门禁和 `git diff --check` 均通过。Vite 仅保留既有约 1.99MB 单 chunk 提示,Jest 仅保留既有强制结束异步句柄提示。
|
||||||
|
- 本次没有新 migration;发布过程不得发送、重投或补发真实短信,不修改供应商通道账号、密码、启停状态、企业余额或客户连接。提交、推送、备份、部署和发布后验证结果在本节后续补记。
|
||||||
|
|||||||
+4
-2
@@ -351,6 +351,8 @@ export type DashboardResponse = {
|
|||||||
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
|
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
|
||||||
pendingAuditCount: number;
|
pendingAuditCount: number;
|
||||||
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number };
|
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number };
|
||||||
|
hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>;
|
||||||
|
auditProcessingSpeed: Array<{ category: string; label: string; count: number; averageProcessingMs: number | null }>;
|
||||||
downstreamDeliverySummary?: {
|
downstreamDeliverySummary?: {
|
||||||
pending: number;
|
pending: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
@@ -1932,9 +1934,9 @@ export const adminApi = {
|
|||||||
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
||||||
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
||||||
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
|
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
|
||||||
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
||||||
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
||||||
requestBlob(withQuery('/admin/operations/messages/export', query)),
|
requestBlob(withQuery('/admin/operations/messages/export', query)),
|
||||||
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||||
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
||||||
|
|||||||
@@ -290,14 +290,22 @@ function SignatureQualityDrawer({
|
|||||||
item: SignatureChannelQualityItem;
|
item: SignatureChannelQualityItem;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const carriers = item.carrierOverview.map((carrier) => ({
|
const carriers = item.carrierOverview
|
||||||
|
.map((carrier) => ({
|
||||||
...carrier,
|
...carrier,
|
||||||
channelCount: new Set(
|
channelCount: new Set(
|
||||||
item.breakdowns
|
item.breakdowns
|
||||||
.filter((entry) => normalizeCarrier(entry.carrier) === normalizeCarrier(carrier.carrier))
|
.filter((entry) => normalizeCarrier(entry.carrier) === normalizeCarrier(carrier.carrier))
|
||||||
.map((entry) => entry.channelId),
|
.map((entry) => entry.channelId),
|
||||||
).size,
|
).size,
|
||||||
}));
|
}))
|
||||||
|
// Database aggregation order is not a display contract; keep the three major carriers stable.
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier));
|
||||||
|
const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier));
|
||||||
|
return (leftRank < 0 ? carrierOrder.length : leftRank)
|
||||||
|
- (rightRank < 0 ? carrierOrder.length : rightRank);
|
||||||
|
});
|
||||||
const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()]
|
const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()]
|
||||||
.map(([channelId, channelName]) => ({ channelId, channelName }));
|
.map(([channelId, channelName]) => ({ channelId, channelName }));
|
||||||
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
|
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi';
|
import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi';
|
||||||
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
||||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||||
|
|
||||||
type EnterpriseSpendRank = {
|
type EnterpriseSpendRank = {
|
||||||
@@ -92,23 +92,30 @@ export function AdminHome() {
|
|||||||
|
|
||||||
const sendTrendOption = useMemo(
|
const sendTrendOption = useMemo(
|
||||||
() => createLineOption({
|
() => createLineOption({
|
||||||
labels: ['今日'],
|
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
||||||
series: [
|
series: [
|
||||||
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
|
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
||||||
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
|
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[dashboard],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const auditTrendOption = useMemo(
|
const auditSpeedOption = useMemo(
|
||||||
() => createBarOption({
|
() => createDualAxisBarLineOption({
|
||||||
labels: ['企业认证', '短信审核', '模板', '签名', '引流信息'],
|
labels: dashboard?.auditProcessingSpeed.map((item) => item.label) ?? [],
|
||||||
series: [
|
bar: {
|
||||||
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures, pendingAudits.drainageInfos] },
|
name: '审核数量',
|
||||||
],
|
data: dashboard?.auditProcessingSpeed.map((item) => item.count) ?? [],
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
name: '平均处理时长(分钟)',
|
||||||
|
data: dashboard?.auditProcessingSpeed.map((item) => (
|
||||||
|
item.averageProcessingMs == null ? null : Number((item.averageProcessingMs / 60_000).toFixed(1))
|
||||||
|
)) ?? [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[pendingAudits],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
||||||
@@ -212,13 +219,13 @@ export function AdminHome() {
|
|||||||
<div className="chart-grid">
|
<div className="chart-grid">
|
||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<h2>今日发送趋势</h2>
|
<h2>今日发送趋势</h2>
|
||||||
<p className="muted">按真实后端今日聚合展示提交量和成功量。</p>
|
<p className="muted">按上海时区逐小时展示业务短信提交总条数和最终成功条数。</p>
|
||||||
<Chart height={300} option={sendTrendOption} />
|
<Chart height={300} option={sendTrendOption} />
|
||||||
</div>
|
</div>
|
||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<h2>审核处理趋势</h2>
|
<h2>审核处理速度</h2>
|
||||||
<p className="muted">待审核数量来自真实审核聚合。</p>
|
<p className="muted">展示今日各项已处理审核数量,以及从提交到审核完成的平均时长。</p>
|
||||||
<Chart height={300} option={auditTrendOption} />
|
<Chart height={300} option={auditSpeedOption} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -133,17 +133,41 @@ export function AdminSmsAuditPage() {
|
|||||||
width: '54px',
|
width: '54px',
|
||||||
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
||||||
},
|
},
|
||||||
{ key: 'tenant', title: '发送企业', width: '180px', render: (record) => <strong>{record.tenant?.name ?? record.tenantId}</strong> },
|
|
||||||
{ key: 'application', title: '企业应用', width: '180px', render: (record) => record.application?.name ?? record.applicationId ?? '-' },
|
|
||||||
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
|
||||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
|
||||||
{ key: 'phoneTotal', title: '号码数量', width: '140px', render: (record) => <button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · 查看列表</button> },
|
|
||||||
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'tenantApplication',
|
||||||
title: '状态',
|
title: '发送企业 / 企业应用',
|
||||||
width: '130px',
|
width: '220px',
|
||||||
render: (record) => <Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>,
|
render: (record) => (
|
||||||
|
<div className="sms-audit-cell-stack">
|
||||||
|
<strong>{record.tenant?.name ?? record.tenantId}</strong>
|
||||||
|
<small>{record.application?.name ?? record.applicationId ?? '-'}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'content', title: '短信内容', width: '440px', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||||
|
{
|
||||||
|
key: 'submittedSource',
|
||||||
|
title: '提交时间 / 审核来源',
|
||||||
|
width: '200px',
|
||||||
|
render: (record) => (
|
||||||
|
<div className="sms-audit-cell-stack">
|
||||||
|
<time dateTime={record.createdAt}>{formatDateTime(record.createdAt)}</time>
|
||||||
|
<Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'phoneStatus',
|
||||||
|
title: '号码数量 / 状态',
|
||||||
|
width: '160px',
|
||||||
|
render: (record) => (
|
||||||
|
<div className="sms-audit-cell-stack">
|
||||||
|
<button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">
|
||||||
|
{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · 查看列表
|
||||||
|
</button>
|
||||||
|
<Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||||
const [contentKeyword, setContentKeyword] = useState('');
|
const [contentKeyword, setContentKeyword] = useState('');
|
||||||
const [channelKeyword, setChannelKeyword] = useState('');
|
const [channelKeyword, setChannelKeyword] = useState('');
|
||||||
|
const [carrier, setCarrier] = useState('all');
|
||||||
const [status, setStatus] = useState('all');
|
const [status, setStatus] = useState('all');
|
||||||
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
|
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
|
||||||
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
||||||
@@ -430,6 +431,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
contentKeyword?: string;
|
contentKeyword?: string;
|
||||||
channelKeyword?: string;
|
channelKeyword?: string;
|
||||||
|
carrier?: string;
|
||||||
queuedAtFrom?: string;
|
queuedAtFrom?: string;
|
||||||
queuedAtTo?: string;
|
queuedAtTo?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -442,6 +444,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
phoneNumber: phoneKeyword || undefined,
|
phoneNumber: phoneKeyword || undefined,
|
||||||
contentKeyword: contentKeyword || undefined,
|
contentKeyword: contentKeyword || undefined,
|
||||||
channelKeyword: channelKeyword || undefined,
|
channelKeyword: channelKeyword || undefined,
|
||||||
|
carrier: carrier === 'all' ? undefined : carrier,
|
||||||
queuedAtFrom: dateRange.start,
|
queuedAtFrom: dateRange.start,
|
||||||
queuedAtTo: dateRange.end,
|
queuedAtTo: dateRange.end,
|
||||||
status: status === 'all' ? undefined : status,
|
status: status === 'all' ? undefined : status,
|
||||||
@@ -512,6 +515,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
setPhoneKeyword('');
|
setPhoneKeyword('');
|
||||||
setContentKeyword('');
|
setContentKeyword('');
|
||||||
setChannelKeyword('');
|
setChannelKeyword('');
|
||||||
|
setCarrier('all');
|
||||||
setStatus('all');
|
setStatus('all');
|
||||||
if (page !== 1) setPage(1);
|
if (page !== 1) setPage(1);
|
||||||
else loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }, 1);
|
else loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }, 1);
|
||||||
@@ -554,6 +558,18 @@ export function AdminSmsRecordsPage() {
|
|||||||
<Select label="应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
<Select label="应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||||
<DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
|
<DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
|
||||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||||
|
<Select
|
||||||
|
label="运营商"
|
||||||
|
onChange={(event) => setCarrier(event.target.value)}
|
||||||
|
options={[
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '移动', value: 'mobile' },
|
||||||
|
{ label: '联通', value: 'unicom' },
|
||||||
|
{ label: '电信', value: 'telecom' },
|
||||||
|
{ label: '未识别', value: 'unknown' },
|
||||||
|
]}
|
||||||
|
value={carrier}
|
||||||
|
/>
|
||||||
<Input label="短信内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
<Input label="短信内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
||||||
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
|
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -6437,6 +6437,23 @@ h3 {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sms-audit-cell-stack {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-audit-cell-stack small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-audit-cell-stack .table-link {
|
||||||
|
justify-self: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.sms-audit-head,
|
.sms-audit-head,
|
||||||
.sms-audit-row {
|
.sms-audit-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -52,6 +52,42 @@ export function createBarOption(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createDualAxisBarLineOption(params: {
|
||||||
|
labels: string[];
|
||||||
|
bar: { name: string; data: number[] };
|
||||||
|
line: { name: string; data: Array<number | null> };
|
||||||
|
}): EChartsOption {
|
||||||
|
return {
|
||||||
|
color: [...chartPalette],
|
||||||
|
grid: { left: 12, right: 18, top: 42, bottom: 8, containLabel: true },
|
||||||
|
legend: { top: 0, right: 0, textStyle: { color: themeColors.textMuted } },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: params.labels, ...axisStyle },
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: '审核数量', minInterval: 1, ...axisStyle },
|
||||||
|
{ type: 'value', name: '平均分钟', ...axisStyle },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: params.bar.name,
|
||||||
|
data: params.bar.data,
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 28,
|
||||||
|
itemStyle: { borderRadius: [6, 6, 0, 0] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: params.line.name,
|
||||||
|
data: params.line.data,
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
smooth: true,
|
||||||
|
symbolSize: 7,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createPieOption(params: {
|
export function createPieOption(params: {
|
||||||
data: Array<{ name: string; value: number }>;
|
data: Array<{ name: string; value: number }>;
|
||||||
}): EChartsOption {
|
}): EChartsOption {
|
||||||
|
|||||||
Reference in New Issue
Block a user