Compare commits
2
Commits
001d5f2cbd
...
3cacb6e8e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cacb6e8e7 | ||
|
|
28951b4fc4 |
@@ -0,0 +1,4 @@
|
||||
-- Deleted records remain available for audit but do not reserve an application template name.
|
||||
-- Fail on conflicting legacy rows; never rename or delete business data during migration.
|
||||
CREATE UNIQUE INDEX "SmsTemplate_application_name_active_key"
|
||||
ON "SmsTemplate" ("applicationId", btrim(name)) WHERE "auditStatus" <> 'deleted';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Serialize member writes against channel capability changes; validate whole carrier strings.
|
||||
CREATE FUNCTION cmpp_check_group_channel_carrier() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
capabilities text[];
|
||||
legacy text;
|
||||
target_carrier text;
|
||||
BEGIN
|
||||
SELECT carriers, carrier INTO capabilities, legacy FROM "SmsChannel" WHERE id=NEW."channelId" FOR SHARE;
|
||||
SELECT carrier INTO target_carrier FROM "SmsChannelGroup" WHERE id=NEW."groupId";
|
||||
IF cardinality(capabilities) = 0 THEN
|
||||
capabilities := CASE WHEN legacy='all' THEN ARRAY['mobile','unicom','telecom'] ELSE ARRAY[legacy] END;
|
||||
END IF;
|
||||
IF target_carrier IS NOT NULL AND NOT (target_carrier=ANY(capabilities)) THEN
|
||||
RAISE EXCEPTION 'Channel carrier is not compatible with the channel group carrier' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER "SmsChannelGroupItem_carrier_guard"
|
||||
BEFORE INSERT OR UPDATE OF "groupId", "channelId" ON "SmsChannelGroupItem"
|
||||
FOR EACH ROW EXECUTE FUNCTION cmpp_check_group_channel_carrier();
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChannelConfigurationService } from './channel-configuration.service';
|
||||
import { selectChannelCandidate } from '../send-chain/send-chain.helpers';
|
||||
describe('carrier capability reduction', () => {
|
||||
it('preserves group references and avoids reconnecting for a capability-only change', async () => {
|
||||
it('removes incompatible group members in a transaction without reconnecting', async () => {
|
||||
const channel = {
|
||||
id: 'c',
|
||||
carrier: 'all',
|
||||
@@ -10,16 +10,18 @@ describe('carrier capability reduction', () => {
|
||||
config: {},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: jest.fn(),
|
||||
smsChannel: {
|
||||
findUnique: jest.fn().mockResolvedValue(channel),
|
||||
update: jest.fn().mockImplementation(({ data }) => ({ ...channel, ...data })),
|
||||
},
|
||||
operationLog: { create: jest.fn() },
|
||||
smsChannelGroupItem: {
|
||||
findMany: jest.fn().mockResolvedValue([{ group: { name: 'existing' } }]),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'member', group: { name: 'existing', carrier: 'telecom' } }]),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(prisma));
|
||||
const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() };
|
||||
await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', {
|
||||
carriers: ['mobile', 'unicom'],
|
||||
@@ -27,7 +29,7 @@ describe('carrier capability reduction', () => {
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }),
|
||||
);
|
||||
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['member'] } } });
|
||||
expect(connection.requestChannelConnection).not.toHaveBeenCalled();
|
||||
const candidate = {
|
||||
channelId: 'c',
|
||||
|
||||
@@ -173,53 +173,72 @@ export class ChannelConfigurationService {
|
||||
cmppVersion: cmppVersion ?? channel.cmppVersion,
|
||||
config: config ?? channel.config,
|
||||
});
|
||||
const updated = await this.prisma.smsChannel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier:
|
||||
data.carriers !== undefined || data.carrier !== undefined
|
||||
? legacyCarrierFromCapabilities(carriers)
|
||||
: undefined,
|
||||
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
||||
sendRegion: data.sendRegion,
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
srcId: data.srcId,
|
||||
cmppVersion,
|
||||
rateLimitPerSecond,
|
||||
unitPrice: data.unitPrice,
|
||||
status: data.status,
|
||||
config: config as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
action: 'sms_channel.update',
|
||||
resource: 'sms_channel',
|
||||
resourceId: channelId,
|
||||
detail: {
|
||||
before: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier,
|
||||
carriers: channel.carriers,
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
enterpriseCode: channel.enterpriseCode,
|
||||
account: channel.account,
|
||||
srcId: channel.srcId,
|
||||
unitPrice: moneyToNumber(channel.unitPrice),
|
||||
},
|
||||
after: data,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.smsChannel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier:
|
||||
data.carriers !== undefined || data.carrier !== undefined
|
||||
? legacyCarrierFromCapabilities(carriers)
|
||||
: undefined,
|
||||
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
||||
sendRegion: data.sendRegion,
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
srcId: data.srcId,
|
||||
cmppVersion,
|
||||
rateLimitPerSecond,
|
||||
unitPrice: data.unitPrice,
|
||||
status: data.status,
|
||||
config: config as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
const removedGroupItems =
|
||||
data.carriers !== undefined || data.carrier !== undefined
|
||||
? await tx.smsChannelGroupItem.findMany({
|
||||
where: { channelId, group: { carrier: { notIn: carriers } } },
|
||||
select: {
|
||||
id: true,
|
||||
groupId: true,
|
||||
carrier: true,
|
||||
province: true,
|
||||
group: { select: { name: true, carrier: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
if (removedGroupItems.length)
|
||||
await tx.smsChannelGroupItem.deleteMany({ where: { id: { in: removedGroupItems.map((item) => item.id) } } });
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
action: 'sms_channel.update',
|
||||
resource: 'sms_channel',
|
||||
resourceId: channelId,
|
||||
detail: {
|
||||
before: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier,
|
||||
carriers: channel.carriers,
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
enterpriseCode: channel.enterpriseCode,
|
||||
account: channel.account,
|
||||
srcId: channel.srcId,
|
||||
unitPrice: moneyToNumber(channel.unitPrice),
|
||||
},
|
||||
after: { ...data, passwordCipher: data.passwordCipher ? '[updated]' : undefined },
|
||||
removedGroupItems,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
const updatedStatus = data.status ?? channel.status;
|
||||
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
||||
|
||||
@@ -363,6 +363,10 @@ export class ChannelReportingService {
|
||||
status?: string;
|
||||
reportType?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
objectKeyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
@@ -482,6 +486,16 @@ export class ChannelReportingService {
|
||||
const changedAt = new Date(task.updatedAt);
|
||||
if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false;
|
||||
if (query.createdAtTo && changedAt > new Date(`${query.createdAtTo}T23:59:59.999+08:00`)) return false;
|
||||
const matches = (value: string | null | undefined, filter?: string) =>
|
||||
!filter?.trim() || (value ?? '').includes(filter.trim());
|
||||
if (!matches(task.signature.tenant.name, query.enterpriseKeyword)) return false;
|
||||
if (!matches(task.signature.application?.name, query.applicationKeyword)) return false;
|
||||
if (!matches(task.channel.name, query.channelKeyword)) return false;
|
||||
const objects =
|
||||
task.reportType === 'drainage'
|
||||
? [task.drainageInfo?.siteName, task.drainageInfo?.url]
|
||||
: [task.signature.name];
|
||||
if (query.objectKeyword?.trim() && !objects.some((value) => matches(value, query.objectKeyword))) return false;
|
||||
if (!query.keyword?.trim()) return true;
|
||||
const keyword = query.keyword.trim();
|
||||
return [
|
||||
|
||||
@@ -246,6 +246,10 @@ export class ChannelsController {
|
||||
@Query('status') status?: string,
|
||||
@Query('reportType') reportType?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('enterpriseKeyword') enterpriseKeyword?: string,
|
||||
@Query('applicationKeyword') applicationKeyword?: string,
|
||||
@Query('channelKeyword') channelKeyword?: string,
|
||||
@Query('objectKeyword') objectKeyword?: string,
|
||||
@Query('createdAtFrom') createdAtFrom?: string,
|
||||
@Query('createdAtTo') createdAtTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@@ -260,6 +264,10 @@ export class ChannelsController {
|
||||
status,
|
||||
reportType,
|
||||
keyword,
|
||||
enterpriseKeyword,
|
||||
applicationKeyword,
|
||||
channelKeyword,
|
||||
objectKeyword,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
page: Number(page),
|
||||
|
||||
@@ -72,11 +72,14 @@ function createPrismaMock() {
|
||||
},
|
||||
],
|
||||
};
|
||||
const channelUpdate = jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data }));
|
||||
const operationLogCreate = jest.fn();
|
||||
return {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
$transaction: jest.fn((callback) =>
|
||||
callback({
|
||||
smsChannel: {
|
||||
update: channelUpdate,
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
|
||||
},
|
||||
smsChannelGroup: {
|
||||
@@ -86,6 +89,7 @@ function createPrismaMock() {
|
||||
.mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }),
|
||||
},
|
||||
smsChannelGroupItem: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
@@ -98,7 +102,7 @@ function createPrismaMock() {
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
create: operationLogCreate,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -106,7 +110,7 @@ function createPrismaMock() {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
findUnique: jest.fn().mockResolvedValue(channel),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })),
|
||||
update: channelUpdate,
|
||||
},
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
@@ -228,7 +232,7 @@ function createPrismaMock() {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
create: operationLogCreate,
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'log-1',
|
||||
|
||||
@@ -202,6 +202,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
status?: string;
|
||||
reportType?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
objectKeyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
|
||||
@@ -103,3 +103,19 @@ export async function operationStatus(tx: Prisma.TransactionClient) {
|
||||
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
|
||||
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
|
||||
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
|
||||
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
|
||||
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
|
||||
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
|
||||
GROUP BY hour ORDER BY hour`);
|
||||
const byHour = new Map(rows.map((row) => [row.hour, row]));
|
||||
return Array.from({ length: 24 }, (_, hour) => ({
|
||||
hour,
|
||||
label: String(hour).padStart(2, '0') + ':00',
|
||||
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
|
||||
successCount: Number(byHour.get(hour)?.successCount ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { SessionRequest } from '../auth/session-validation.middleware';
|
||||
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
|
||||
import { aggregateHome, enterpriseRanks, metrics, operationStatus } from './home-read';
|
||||
import { aggregateHome, enterpriseRanks, hourlySendTrend, metrics, operationStatus } from './home-read';
|
||||
|
||||
@Injectable()
|
||||
export class HomeService {
|
||||
@@ -40,7 +40,7 @@ export class HomeService {
|
||||
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
|
||||
if (!state.initialized || state.seededDay !== date)
|
||||
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
|
||||
const [rows, ranks, status, pending, unresolved] = await Promise.all([
|
||||
const [rows, ranks, status, pending, unresolved, hourlyTrend] = await Promise.all([
|
||||
aggregateHome(tx, date, state.version),
|
||||
enterpriseRanks(tx, date),
|
||||
operationStatus(tx),
|
||||
@@ -51,6 +51,7 @@ export class HomeService {
|
||||
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
|
||||
},
|
||||
}),
|
||||
hourlySendTrend(tx, date),
|
||||
]);
|
||||
const values = metrics(rows[0]);
|
||||
const summary = {
|
||||
@@ -66,6 +67,7 @@ export class HomeService {
|
||||
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
|
||||
today: values,
|
||||
enterpriseSpendRanks: ranks,
|
||||
hourlySendTrend: hourlyTrend,
|
||||
...status,
|
||||
};
|
||||
const snapshot = await tx.homeSnapshot.create({
|
||||
|
||||
@@ -10,13 +10,15 @@ import type {
|
||||
// Pure query builders and response mappers shared by the R2 query domains.
|
||||
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
const statusWhere =
|
||||
query.status === 'submit_failed'
|
||||
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
||||
: query.status === 'failed'
|
||||
? { status: 'failed', submitStatus: 'accepted' }
|
||||
: query.status
|
||||
? { status: query.status }
|
||||
: {};
|
||||
query.status === 'unknown'
|
||||
? { status: { in: ['submitted', 'unknown'] } }
|
||||
: query.status === 'submit_failed'
|
||||
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
||||
: query.status === 'failed'
|
||||
? { status: 'failed', submitStatus: 'accepted' }
|
||||
: query.status
|
||||
? { status: query.status }
|
||||
: {};
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { startOfDay, addDays } from './analytics-date';
|
||||
import { leadingSignatureSql } from './signature-extraction';
|
||||
|
||||
export type ActivityDimension = {
|
||||
dimensionKey: string;
|
||||
@@ -114,7 +115,7 @@ export async function unreportedRows(db: PrismaService, date: string) {
|
||||
}>
|
||||
>(Prisma.sql`
|
||||
WITH extracted AS (
|
||||
SELECT m."tenantId",m."applicationId",SUBSTRING(m.content FROM '^【[^【】]+】') AS name
|
||||
SELECT m."tenantId",m."applicationId",${leadingSignatureSql(Prisma.sql`m.content`)} AS name
|
||||
FROM "SmsMessageRecord" m WHERE m."queuedAt">=${startOfDay(date)} AND m."queuedAt"<${startOfDay(addDays(date, 1))} AND m."signatureId" IS NULL
|
||||
) SELECT jsonb_build_array(e."tenantId",e."applicationId",e.name)::text AS "dimensionKey",
|
||||
e."tenantId",e."applicationId",e.name AS "signatureName",t.name AS "tenantName",a.name AS "applicationName",COUNT(*)::int AS "messageCount"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/** Keep delimiters as whole strings: SQL_ASCII treats a Chinese character class as bytes. */
|
||||
export function leadingSignatureSql(content: Prisma.Sql): Prisma.Sql {
|
||||
// The lookahead excludes complete brackets without rejecting bytes shared by other Chinese characters.
|
||||
// Noncapturing groups keep SUBSTRING returning the complete signature, including its brackets.
|
||||
return Prisma.sql`SUBSTRING(${content} FROM '^【(?:(?!【|】).)+】')`;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
});
|
||||
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
||||
const sql = query.strings?.join('?') ?? '';
|
||||
expect(sql).toContain("SUBSTRING(message.content FROM '^【[^【】]+】')");
|
||||
expect(sql).toContain("SUBSTRING(message.content FROM '^【(?:(?!【|】).)+】')");
|
||||
expect(sql).toContain('message."signatureId" IS NULL');
|
||||
expect(sql).toContain('FROM "SmsSignature" signature');
|
||||
expect(sql).toContain('signature."applicationId" = extracted.application_id');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read';
|
||||
import { analyticsDate, analyticsPage, todayKey } from '../signature-analytics/analytics-date';
|
||||
import { detectRetirement } from '../signature-analytics/retirement-batch';
|
||||
import { leadingSignatureSql } from '../signature-analytics/signature-extraction';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -568,7 +569,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
SELECT
|
||||
message."tenantId" AS tenant_id,
|
||||
message."applicationId" AS application_id,
|
||||
SUBSTRING(message.content FROM '^【[^【】]+】') AS signature_name
|
||||
${leadingSignatureSql(Prisma.sql`message.content`)} AS signature_name
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${shanghaiStart(date)}
|
||||
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReviewDto, StatusChangeDto } from './sms-config.contracts';
|
||||
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
||||
import { SmsReportValidationService } from './report-validation.service';
|
||||
import { writeUniqueSignature } from './signature-uniqueness';
|
||||
import { templateWriteError } from './template-uniqueness';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsAuditService {
|
||||
@@ -72,7 +73,9 @@ export class SmsAuditService {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
|
||||
const updated = await this.prisma.smsTemplate
|
||||
.update({ where: { id: templateId }, data: { auditStatus: status } })
|
||||
.catch(templateWriteError);
|
||||
await this.lifecycle.writeOperationLog(
|
||||
template.tenantId,
|
||||
data.operatorId,
|
||||
@@ -158,13 +161,15 @@ export class SmsAuditService {
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
|
||||
const updated = await this.prisma.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
||||
},
|
||||
});
|
||||
const updated = await this.prisma.smsTemplate
|
||||
.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
||||
},
|
||||
})
|
||||
.catch(templateWriteError);
|
||||
await this.createAuditRecord({
|
||||
tenantId: template.tenantId,
|
||||
targetType: 'sms_template',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { CreateSmsApplicationDto } from './sms-config.contracts';
|
||||
|
||||
/** Pure normalization and report-value helpers shared by the R3 domain services. */
|
||||
export const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const;
|
||||
export type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
|
||||
export type ApplicationQueuePriority = (typeof APPLICATION_QUEUE_PRIORITIES)[number];
|
||||
export const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
|
||||
export type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
|
||||
export type ApplicationInterfaceType = (typeof APPLICATION_INTERFACE_TYPES)[number];
|
||||
export const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
|
||||
export const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000;
|
||||
export const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000;
|
||||
@@ -55,16 +55,16 @@ export function validateAndNormalizeTemplateVariables(
|
||||
const end = content.indexOf('}', start + 2);
|
||||
if (end < 0) throw new BadRequestException('模板变量未闭合');
|
||||
const name = content.slice(start + 2, end);
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) {
|
||||
throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母、数字和下划线,长度1至32位');
|
||||
if (!/^[A-Za-z0-9]{1,32}$/.test(name)) {
|
||||
throw new BadRequestException('模板变量名仅允许英文字母和数字,长度1至32位');
|
||||
}
|
||||
if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`);
|
||||
names.push(name);
|
||||
cursor = end + 1;
|
||||
}
|
||||
if (!supplied) return names.map((name) => ({ name, required: true }));
|
||||
const suppliedNames = supplied.map((item) => item.name?.trim());
|
||||
if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) {
|
||||
const suppliedNames = supplied.map((item) => item.name);
|
||||
if (suppliedNames.some((name) => typeof name !== 'string' || !/^[A-Za-z0-9]{1,32}$/.test(name))) {
|
||||
throw new BadRequestException('变量配置中包含非法变量名');
|
||||
}
|
||||
if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量');
|
||||
@@ -75,7 +75,10 @@ export function validateAndNormalizeTemplateVariables(
|
||||
}
|
||||
|
||||
export function normalizeSmsSignature(name: string) {
|
||||
const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
|
||||
const innerName = name
|
||||
.trim()
|
||||
.replace(/^[【[]+|[】\]]+$/g, '')
|
||||
.trim();
|
||||
return innerName ? `【${innerName}】` : '';
|
||||
}
|
||||
|
||||
@@ -114,26 +117,27 @@ export function normalizeApplicationInterfaceType(value?: string): ApplicationIn
|
||||
}
|
||||
|
||||
export function normalizeCmppAccessNumberConfig(
|
||||
data: Pick<CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'>,
|
||||
data: Pick<
|
||||
CreateSmsApplicationDto,
|
||||
'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'
|
||||
>,
|
||||
current?: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
},
|
||||
) {
|
||||
const applicationExtension = (
|
||||
data.cmppApplicationExtension === undefined
|
||||
const applicationExtension =
|
||||
(data.cmppApplicationExtension === undefined
|
||||
? current?.cmppApplicationExtension
|
||||
: data.cmppApplicationExtension
|
||||
)?.trim() || null;
|
||||
const fillEnabled = data.cmppAccessNumberFillEnabled
|
||||
?? current?.cmppAccessNumberFillEnabled
|
||||
?? false;
|
||||
const configuredPrefix = (
|
||||
data.cmppAccessNumberFillPrefix === undefined
|
||||
)?.trim() || null;
|
||||
const fillEnabled = data.cmppAccessNumberFillEnabled ?? current?.cmppAccessNumberFillEnabled ?? false;
|
||||
const configuredPrefix =
|
||||
(data.cmppAccessNumberFillPrefix === undefined
|
||||
? current?.cmppAccessNumberFillPrefix
|
||||
: data.cmppAccessNumberFillPrefix
|
||||
)?.trim() || null;
|
||||
)?.trim() || null;
|
||||
|
||||
if (applicationExtension && !/^\d+$/.test(applicationExtension)) {
|
||||
throw new BadRequestException('cmppApplicationExtension must contain digits only');
|
||||
@@ -152,9 +156,7 @@ export function normalizeCmppAccessNumberConfig(
|
||||
}
|
||||
|
||||
const fillPrefix = fillEnabled ? configuredPrefix : null;
|
||||
const clientSrcId = applicationExtension
|
||||
? `${fillPrefix ?? ''}${applicationExtension}`
|
||||
: null;
|
||||
const clientSrcId = applicationExtension ? `${fillPrefix ?? ''}${applicationExtension}` : null;
|
||||
if (clientSrcId && clientSrcId.length > 21) {
|
||||
throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits');
|
||||
}
|
||||
@@ -179,7 +181,9 @@ export function normalizeApplicationCmppStatus(connections: Array<{ status: stri
|
||||
if (connections.some((connection) => connection.status === 'connected')) {
|
||||
return 'connected';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||
if (
|
||||
connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))
|
||||
) {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'disconnected';
|
||||
@@ -202,7 +206,10 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
export function reportValueParts(value: unknown) {
|
||||
if (isRecord(value) && typeof value.fileObjectId === 'string') {
|
||||
return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId };
|
||||
return {
|
||||
fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined,
|
||||
fileObjectId: value.fileObjectId,
|
||||
};
|
||||
}
|
||||
return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export function templateWriteError(error: unknown): never {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
|
||||
throw new BadRequestException('同一企业应用下已存在相同名称的模板,请修改模板名称');
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { validateAndNormalizeTemplateVariables } from './sms-config.helpers';
|
||||
|
||||
describe('template variable names', () => {
|
||||
it.each(['code', 'Code123', '123', 'A'.repeat(32)])('accepts ASCII alphanumeric name %s', (name) => {
|
||||
expect(validateAndNormalizeTemplateVariables(`正文\${${name}}`, [{ name, example: '中文示例' }])).toEqual([
|
||||
{ name, example: '中文示例' },
|
||||
]);
|
||||
});
|
||||
it.each(['中文', 'code_1', 'code-1', ' name', '1', 'é', '', 'A'.repeat(33)])('rejects invalid name %s', (name) => {
|
||||
expect(() => validateAndNormalizeTemplateVariables(`正文\${${name}}`)).toThrow('变量名仅允许');
|
||||
});
|
||||
it('rejects unclosed, repeated and mismatched variables', () => {
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code')).toThrow('未闭合');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}${code}')).toThrow('重复');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'other' }])).toThrow('完全一致');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'code_' }])).toThrow('非法');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'code ' }])).toThrow('非法');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
import { templateWriteError } from './template-uniqueness';
|
||||
import type {
|
||||
CreateSmsTemplateDto,
|
||||
CreateSmsTemplateOptions,
|
||||
@@ -87,6 +88,7 @@ export class SmsTemplateService {
|
||||
}
|
||||
|
||||
async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) {
|
||||
const name = this.templateName(data.name);
|
||||
const variables = validateAndNormalizeTemplateVariables(data.content, data.variables);
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
@@ -96,26 +98,28 @@ export class SmsTemplateService {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content);
|
||||
return this.prisma.smsTemplate.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
billingUnits: estimateBillingUnits(data.content),
|
||||
variables: {
|
||||
create: variables.map((variable: TemplateVariableInput) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
return this.prisma.smsTemplate
|
||||
.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
billingUnits: estimateBillingUnits(data.content),
|
||||
variables: {
|
||||
create: variables.map((variable: TemplateVariableInput) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
})
|
||||
.catch(templateWriteError);
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
|
||||
@@ -151,35 +155,42 @@ export class SmsTemplateService {
|
||||
(data.category !== undefined && data.category !== template.category) ||
|
||||
data.variables !== undefined;
|
||||
const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (variables) {
|
||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||
}
|
||||
return tx.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
||||
variables: variables
|
||||
? {
|
||||
create: variables.map((variable) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
});
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
if (variables) {
|
||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||
}
|
||||
return tx.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name === undefined ? undefined : this.templateName(data.name),
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
||||
variables: variables
|
||||
? {
|
||||
create: variables.map((variable) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
})
|
||||
.catch(templateWriteError);
|
||||
}
|
||||
|
||||
private templateName(value: string) {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new BadRequestException('模板名称不能为空');
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
|
||||
|
||||
@@ -2392,3 +2392,13 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
||||
- 按已协商的 CMPP 2.0/2.1、3.0 编解码 60/71 字节回执;不得在 3.0 静默使用 2.0 布局。合法序号 0 可恢复下游原 Msg_Id,回绕不得覆盖在途记录;所有非零 CONNECT_RESP 状态失败,合法多号码大包可接收且异常长度有界拒绝。
|
||||
- 同一回执并发首次接收只形成一个持久化事实。历史应急记录缺失序号不自动补填,不再次触发通知、补发或账务。
|
||||
- 详细字段、迁移发布边界及验收依据:[CMPP 字段兼容性方案](cmpp-protocol-field-compatibility-remediation-20260920.md)。本轮授权并完成本地实现/验收/提交,环境上线另行执行标准发布。
|
||||
|
||||
|
||||
## 2026-09-21 未报备签名统计编码兼容修正
|
||||
|
||||
保持既有未报备签名定义:规范正文开头签名在当前企业应用有效签名库不存在才计入。实时统计和日报聚合须同时兼容现有SQL_ASCII及UTF8数据库;不能因汉字共享编码字节漏统计,也不能放宽空签名、嵌套括号或非规范开头限制。不更改分页、权限、归属、数据库编码或已冻结历史报表。根因和本地验收见[排查及修复记录](unreported-signature-diagnosis-20260921.md)。
|
||||
|
||||
|
||||
## 2026-09-21 六项运营功能更正
|
||||
|
||||
模板签名使用通用可搜索下拉框;变量名仅ASCII英文字母和数字1~32位,示例内容可中文。按用户最终更正,同一企业应用下模板名称唯一,与签名无关;名称去首尾空格,非deleted状态占用名称,跨应用同名允许,创建/编辑/恢复/并发均受约束。首页恢复北京时间今日小时发送曲线,置于企业消费排行上方;未知筛选包含submitted与历史unknown,submitted显示提交成功;签名通道质量列表删除通道提交列,详情保留;报备明细企业/应用/通道/对象四条件独立且AND组合。通道移除运营商须先提示并确认,然后事务性移除该运营商全部省网/全国组成员并审计,保留其他组成员及路由,禁止并发重新挂回不兼容成员。此项替代2026-09-20仅跳过不移除的规则。设计、迁移兼容与验收见[六项整改](operations-six-fixes-20260921.md)。授权代码提交推送,未授权环境部署。
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 2026-09-21 六项运营功能整改
|
||||
|
||||
状态:实现及本机真实接口、页面验收完成,提交推送收尾。授权:本地修改、测试、提交、推送;不部署、不发送短信、不修改线上业务配置。基线main 28951b4,2026-09-21 19:25前回读实际远端main 001d5f2;推送将包含前一轮未报备统计修复及本次提交,其他会话未提交修改保留。
|
||||
|
||||
## 规则与实现范围
|
||||
|
||||
1. 模板:运营端和客户端复用Select的searchable;变量名仅ASCII英文字母和数字,保留1~32字符上限,不接受下划线、空格、汉字或其他符号,数字可以开头。自定义输入、正文占位符、API变量配置均校验;变量示例值仍可使用中文。已有模板不自动改写,编辑涉及变量/正文时按新规则校验。按用户随后更正,以“企业应用×模板名称”唯一,签名不同也不能同名;名称去首尾空格,非deleted状态均占用名称,已删除模板不占用,新建/编辑/恢复和并发保存由数据库部分唯一索引兜底,API返回明确中文冲突提示。不同应用同名允许。2026-09-21预生产只读检查有效模板按applicationId+btrim(name)重名组0;迁移不自动重命名或删除任何模板,目标环境若有新冲突则阻断迁移。
|
||||
2. 首页:恢复今日24小时发送曲线,位于企业消费排行上方。沿用原曲线口径,按北京时间queuedAt小时汇总号码级业务短信数及其中最终delivered数,不把供应商补发计为新业务短信;来自真实PG当日范围查询,空小时补0,接口失败显示不可用/旧数据提示。通过现有首页summary加载,不重新调用旧的全量dashboard接口;返回hourlySendTrend,指标卡及其他快照逻辑保留。
|
||||
3. 短信记录:“未知”筛选包含提交成功尚无最终送达结果的submitted以及历史unknown;不包含成功/失败/排队/拒绝,既有其他条件取交集。submitted显示“提交成功”,不变更真实业务状态或回执。
|
||||
4. 签名质量:仅删除“签名通道发送质量”列表的通道提交列,调整该表统计说明;保留详情的提交尝试数及后端指标,避免改变其他统计口径。
|
||||
5. 通道报备明细:拆分企业、应用、通道、报备对象四个独立文本搜索框,AND组合且各自仅匹配对应字段。报备对象按当前类型匹配签名或引流站点/URL。保留旧keyword接口兼容、时间/状态/运营商/类型条件、虚拟未报备行、详情/导出及分页,查询/重置回第一页,翻页使用已应用条件。
|
||||
6. 通道运营商缩减:保存前列出移除的运营商并要求确认影响,提示将从这些运营商的全部通道组移除(含省网/全国),可能导致该组无可用通道;重新勾选/取消不写库。后端同一事务更新通道、按通道组carrier移除成员、记录移除关系审计,其他运营商组/组本体/企业应用路由/报备历史保持。能力变更不触发无关重连。成员写入的数据库约束锁定通道并检查组运营商兼容性,阻止并发通道组保存重新挂回已移除能力。事务失败整体回滚。替代此前“缩减能力仅跳过、不移除引用”的规则。
|
||||
|
||||
## 数据、权限与兼容
|
||||
|
||||
新增迁移仅增加模板名称部分唯一索引和通道组成员写入兼容性约束,无历史重写、余额/收费调整。迁移失败不自动resolve或清数据。应用回退不移除索引/约束,旧代码写入不兼容关系会被数据库拒绝。沿用既有运营端/客户端登录、权限及租户限定;前端提示不代替服务端校验。模板名称冲突返回400而非500。
|
||||
|
||||
## 验收
|
||||
|
||||
真实本机隔离PG全迁移、API与必要Redis;模板并发创建/更新、自编辑、跨应用、软删除重用、非法变量与中文示例;小时边界/零点/重复Submit;未知筛选的命中与排除;四搜索项组合及分页虚拟行;运营商移除、取消、回滚、并发成员写入、审计和保留项。页面1600×1000、1366×768、390×844首次进入/刷新/跨路由和相关交互,检查控制台及截图。Browser插件/对应browser技能不在清单,按前端测试技能使用已有Playwright,不安装依赖。定向+前后端全量、覆盖率、TypeScript/生产构建、格式/lint/样式/包体及diff门禁;精确提交候选与工作区结果区分。实际结果收尾追加,不将方案当已完成。
|
||||
|
||||
## 2026-09-21 本轮结果
|
||||
|
||||
- 根因:签名Select未启用通用搜索;变量校验允许下划线且前端缺少输入拦截;模板无应用名称唯一约束;新首页未接回旧小时序列;未知条件直接查unknown遗漏submitted;签名质量列表保留了不需要的列;报备查询只提供合并keyword;通道更新此前明确保留失配的通道组引用。
|
||||
- 真实验收脚本:`tools/testing/verify-operations-six-fixes.mjs`,仅允许localhost专用数据库名。PG16.14隔离新库115项迁移通过(113项基线和本轮2项,不含其他会话未提交迁移);Redis专用16452,真实Nest全局认证、PG、HTTP、前端production构建。无需文件上传/发送,未启动MinIO和Gateway,无任何网络短信发送。Redis5为既有本机版本,BullMQ版本建议提示保留;本轮只验证会话和配置查询,不作为队列/Gateway验收。
|
||||
- 模板:运营与客户端均拒绝同应用跨签名重名;两个并发创建只有一个成功;首尾空格、自编辑、编辑重名、跨应用、删除后重用及恢复冲突通过;非法变量、数字开头和中文示例通过;未登录和客户端访问运营接口被拒绝。数据库唯一索引覆盖写竞争,迁移不自动处理历史重名。
|
||||
- 小时与短信:真实24小时数据,北京零点包含、前日末排除、空桶为0;同业务多条供应商提交记录不增加小时业务量;真实首页summary一致。未知筛选只含submitted/unknown,浏览器真实请求参数为unknown且展示提交成功。
|
||||
- 报备:企业/应用/通道/对象同时匹配;逐项不匹配均空;虚拟未报备记录保留,分页总数和第二页不同记录验证通过。签名质量真实页面三种尺寸均无通道提交表头。
|
||||
- 通道:删除联通时全国与湖北成员均移除,移动/电信及4条应用路由保留,审计记录2条删除关系;强制审计失败触发整体回滚;真实第二数据库连接在通道更新期间写成员等待,提交后被兼容约束拒绝。页面不确认禁止保存、确认后可保存、关闭取消不写库。提示样式由同目录ChannelFormModal.css直接归属并登记所有权,不改历史CSS基线。
|
||||
- 页面:1600×1000、1366×768、390×844覆盖首页刷新/图表位置、四个搜索框、模板签名搜索及粘贴非法变量、通道警示与取消、质量列表、未知筛选、跨路由;无pageerror。18张截图在`C:/Users/hectorzhao/AppData/Local/Temp/cmpp-operations-six-20260921-final/`,人工查看桌面首页与移动通道警示。截图为本机真实测试数据,不是预生产页面。
|
||||
- 从暂存区导出精确候选`C:/cmpp-platform-local/operations-six-candidate-20260921`:前端38组179项、覆盖率语句88.48/分支85.09/函数84/行88.19;后端87组935项、覆盖率68.02/53.71/68.98/70.76,均达到现有门禁;前后端TypeScript与生产构建通过。lint无错误,保留10项既有any/Hook依赖警告;格式、样式、CSS所有权与15项治理测试、包体和staged diff检查通过,入口107.46KiB gzip低于250KiB预算。Gateway未改,不执行Go或线上链路测试。
|
||||
- 原始日志:工作区`.local-data/operations-six-20260921/`与`C:/cmpp-platform-local/operations-six-*.log`;最终真实验收`operations-six-real-verified.log`。最初后端事务mock、浏览器控件定位/手机隐藏表头等待错误已修正;工作区并发覆盖率曾15项超时,精确候选maxWorkers=2、testTimeout=15000全通过。Windows导出CRLF造成格式检查失败,改用本次checkout-index进程级core.autocrlf=false导出提交内LF后通过,未改全局配置。旧拒收脚本中通道缩减断言同步新规则,未执行其发送命令生成流程。
|
||||
- 边界:本次交付代码和两项迁移,未部署测试或预生产;目标环境真实页面、真实历史数据量下首页查询耗时以及迁移时新增重名检查仍需在获授权的发布流程核验。应用回退不会移除已执行的数据库约束。
|
||||
- 增量覆盖率门禁另行执行87组935项通过,语句87.98/分支77.03/函数95.91/行91.22;专用PG和Redis已停止,隔离数据、产物和失败/成功日志保留。
|
||||
@@ -5730,3 +5730,30 @@ TC-SQA-01~14:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
|
||||
| T17~T18 | inbound/protocol_fields_test.go、verify-protocol-fields.mjs | 2.0/3.0 本机 TCP 重连零序号回执与 ACK,历史缺失序号不回填且终态/通知不重开;通过 |
|
||||
|
||||
后续上线须追加目标 schema/版本、真实供应商/客户互通、队列排空和账务对账证据。当前不标记目标环境完成,也不执行历史回执重投。
|
||||
|
||||
|
||||
## 2026-09-21 未报备签名编码回归
|
||||
|
||||
| 用例 | 场景与预期 |
|
||||
|---|---|
|
||||
| TC-UNREPORTED-ENC-001 | 真实SQL_ASCII、UTF8隔离库提取26种输入:目标签名、中文、ASCII、emoji、换行、引号、长文本完整提取;空签名、前导空白/BOM、未关闭和嵌套括号不提取,保留原规范。 |
|
||||
| TC-UNREPORTED-ENC-002 | 64条【宜都市万商市场投资有限公司】无signatureId消息在本应用无签名、其他应用/企业有同名时仍聚合64;当前应用有效或待审核同名排除,deleted不排除;已有signatureId及无应用记录按原逻辑排除。 |
|
||||
| TC-UNREPORTED-ENC-003 | 北京时间日初包含、次日日初排除、日末包含;实时和日报聚合一致,真实日报持久化与HTTP查询一致;13组按每页10条分两页,三类搜索、空结果、非法参数不改变既有语义。 |
|
||||
| TC-UNREPORTED-ENC-004 | 已冻结日报在源记录改变后普通生成跳过、补建覆盖拒绝,原内容保持;隔离测试不创建任何供应商Submit。 |
|
||||
|
||||
执行:本机PostgreSQL16.14两编码各113项提交内迁移,verify-unreported-signature-encoding.mjs全部通过;真实Nest控制器/服务/PG,不含全局登录鉴权、浏览器及在线发送。完整结果和证据见[本轮记录](unreported-signature-diagnosis-20260921.md)。
|
||||
|
||||
|
||||
## 2026-09-21 六项运营整改用例
|
||||
|
||||
| 编号 | 验收要求 |
|
||||
|---|---|
|
||||
| TC-OPS6-001 | 运营端/客户端签名可搜索;变量仅英文数字,汉字/符号/空格/未闭合/重复均拒绝,数字开头和中文示例可保存。 |
|
||||
| TC-OPS6-002 | 同应用跨签名同名拒绝;trim名称后比较;自编辑正常,跨应用允许;并发只一个成功;软删除可重用,恢复冲突拒绝。 |
|
||||
| TC-OPS6-003 | 首页真实24小时曲线在消费排行上方;北京时间日边界和零桶正确;重复供应商提交不增加业务短信量,刷新仍真实读取。 |
|
||||
| TC-OPS6-004 | 未知筛选匹配submitted/unknown,排除delivered/failed/queued/rejected,真实页面请求unknown且显示提交成功。 |
|
||||
| TC-OPS6-005 | 签名通道质量表头无通道提交列,详情指标不删;报备四个搜索框独立字段AND组合,过滤先于分页且保留虚拟未报备行。 |
|
||||
| TC-OPS6-006 | 移除运营商前明确提示并确认;取消不修改。全国/省网成员均移除,其他组/路由保留且审计完整;事务故障整体回滚,并发成员写入等待后拒绝失配。 |
|
||||
| TC-OPS6-007 | 三种尺寸1600×1000、1366×768、390×844覆盖相关页面、刷新、路由、搜索/校验/确认/取消,无pageerror。 |
|
||||
|
||||
本轮真实本机PG115项迁移、Redis、全Nest认证HTTP和production前端通过;脚本verify-operations-six-fixes.mjs,精确候选前端179/后端935项通过。目标环境未部署未验收,迁移冲突和历史数据量性能仍须发布时核验。详见[执行证据](operations-six-fixes-20260921.md)。
|
||||
|
||||
@@ -5210,3 +5210,23 @@ API全量81套/880项通过并达覆盖率门禁(语句67.73%、分支52.89%
|
||||
- 迁移:从已提交基线排除其他会话未提交 mobile-rule 迁移,新空库部署 113 迁移成功。七表 21 行小样本证明末表负值使此前 DDL 整体回滚,锁等待 5 秒失败回滚,旧值/NULL/7 索引保留;最终有效扩容约 106ms、WAL 81800 字节。生产规模、真实发布备份/恢复与 3.0 供应商非规范布局抽样待上线前补验,禁止把小样本当容量承诺。
|
||||
- 证据:.local-data/protocol-fields-20260920/ 保留原失败和复验日志;最终 real-chain-verified.log、migrate-verified.log、api-coverage-final.log、api-incremental.log、go-final.log、gocmpp-final.log。专用 Redis 5.0.14.1 的真实 Stream 验证有效,但低于 BullMQ 推荐 6.2;PG 并发 query 弃用提示记为存量,未修改依赖。测试脚本要求全新本机 cmpp_qa_* 数据库,避免固定最大 Msg_Id fixture 跨轮冲突。
|
||||
- 方案/需求/用例同步:docs/cmpp-protocol-field-compatibility-remediation-20260920.md 第 10 节完整映射 T01~T18。无前端/CSS改动、未执行浏览器回归;客户端 JSON 类型保持不变。业务状态与资金未通过迁移回填。后续所有相关进程须与 BigInt schema 配套,旧客户端不能读取高位值;应用回退不执行 bigint 缩回 integer。
|
||||
|
||||
|
||||
## 2026-09-21 未报备签名漏统计修复与本地提交
|
||||
|
||||
预生产001d5f2只读诊断发现:【宜都市万商市场投资有限公司】今日64条、当前应用有效同名签名0,SQL_ASCII使中文排除字符集合误判,旧提取全部NULL。用户随后授权修复并提交;新增共用SQL提取函数,实时与日报均按完整括号排除嵌套,保持原日期/应用/签名库/分页及冻结历史规则,无迁移。15:33:24预生产READ ONLY对照旧0/新64,不是线上发布。
|
||||
|
||||
本地真实PG16.14 UTF8/SQL_ASCII新空库各113项已提交迁移;26输入、64条案例、租户应用/签名状态、日期边界、13组两页、三类搜索、参数校验、真实日报落库和Nest控制器HTTP、冻结历史保护均通过,Submit=0。首次分页夹具用不支持的2返回400,改现有10并新库复验通过;旧运行目录initdb缺dict_snowball改用完整既有运行库通过,失败证据保留。API87套990项全通过,覆盖率68.05/53.73/69/70.77达到门禁,生产构建、定向lint/格式通过。
|
||||
|
||||
代码与需求口径一致,相关需求和TC-UNREPORTED-ENC-001~004同步。原始证据.local-data/unreported-signature-20260921/,详情见[诊断及修复记录](unreported-signature-diagnosis-20260921.md)。本轮仅本地修改和提交,无推送、部署或历史重算;不包含其他会话草稿。真实HTTP为最小控制器验收,未注册应用全局登录鉴权,未做浏览器或线上页面验收;前端/Go未变不重跑。预生产仍需单独授权部署后验收。
|
||||
|
||||
提交前从暂存区导出的精确候选另行通过API86套922项、覆盖率68.05/53.73/69.02/70.77和生产构建;候选产物在两种编码新库重新执行完整真实PG/HTTP/日报脚本通过。前述990项含他轮未提交测试,不作为本次精确提交测试数。最终candidate-api-coverage.log、candidate-build.log及real-*-candidate.log留档;专用PG已停止,数据保留。
|
||||
|
||||
|
||||
## 2026-09-21 六项运营整改:实现、隔离真实验收与提交推送
|
||||
|
||||
用户授权本轮修改、提交、推送,并明确模板唯一规则改为企业应用×模板名称。六项代码完成,设计先行并同步需求及TC-OPS6-001~007;其中通道移除组成员替代此前仅跳过规则。新增应用模板名部分唯一索引和通道组成员兼容约束,历史数据不重写;签名搜索/变量校验、首页小时曲线、未知映射、质量列删除、报备四条件、通道移除提示/事务均已实现。
|
||||
|
||||
本机PG16.14独立数据库115项迁移、真实Redis、全Nest认证API、production前端通过;并发模板只一个成功,跨签名/跨应用/软删除/恢复符合最终口径;通道省网/全国移除、故障回滚、第二数据库连接并发失配拒绝通过。三尺寸18张真实页面截图和刷新/路由/相关交互通过,无pageerror,未发送短信、未启动Gateway。精确暂存候选前端38组179项、后端87组935项,覆盖率/构建/类型/lint/格式/样式/包体/diff门禁通过;遗留10条原有lint警告。并发覆盖率超时和浏览器脚本定位失败保留,降低并发及修正定位后复验通过。原工作区后端1003项含他轮未提交测试,不作为提交内数量。
|
||||
|
||||
证据及未验收项见[六项整改记录](operations-six-fixes-20260921.md)。本轮Git将包含28951b4未报备签名修复及本次提交,保护其他会话所有未提交内容;推送结果以最终远端精确SHA回读为准。本轮未部署测试或预生产,线上页面、目标历史量下曲线查询性能与迁移时是否新出现重名不冒充已验收。
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 2026-09-21 预生产未报备签名漏统计只读诊断
|
||||
|
||||
核验时间:2026-09-21 15:09~15:12(北京时间)。授权仅排查,不修复、不部署、不改变线上数据。
|
||||
|
||||
## 当前证据
|
||||
|
||||
- 预生产 8.160.169.106,运行提交 `001d5f2cbdbd19e56c4de914bd3b94a04c3b19c5`,cmpp-api active;实际工作目录 `/opt/cmpp-platform/api`。本地 main 同提交,现有 origin/main 跟踪差异 0/0,未 fetch;暂存区为空,其他会话修改保留。
|
||||
- 真实 PostgreSQL `cmpp_platform` 的 `server_encoding=SQL_ASCII`。查询全部在 READ ONLY 事务内,statement_timeout 12~15 秒,结束回滚。
|
||||
- 今天【宜都市万商市场投资有限公司】共有64条业务短信,企业为“启瑞企业”,应用为“启瑞物业-From老平台”,入队时间北京时间12:12:21.238~14:19:39.114。全部 signatureId 为空,status=failed,errorCode=SIGNATURE,错误说明“短信内容未识别到已审核通过的签名”。未查询或输出收件号码及完整正文。
|
||||
- 当前企业应用有效签名库精确同名记录为0。存在的是另一个名称【宜都市万商商业运营有限公司】,审核/报备均 approved,不能替代用户询问的签名。
|
||||
- 正文从规范黑括号开始,前缀字节为 `e38090`。线上运行产物 `api/dist/signature-retirement/signature-retirement.service.js:520` 使用 `SUBSTRING(message.content FROM '^【[^【】]+】')`;该表达式对全部64条返回NULL。
|
||||
- 只读对照表达式 `^【.+?】` 对同一64条均提取出完整目标签名。对照仅证明现有表达式漏识别,不代表替代表达式已满足异常/嵌套括号等全部验收规则。
|
||||
|
||||
## 根因及范围
|
||||
|
||||
当前数据库按 SQL_ASCII 处理字符串,现有排除字符集合 `[^【】]` 按字节处理中文括号及正文,误排除共享字节的汉字;提取结果为空后被 `signature_name IS NOT NULL` 条件剔除。因此该签名按业务规则应进入统计,实际因统计提取缺陷漏掉,非日期或已登记签名排除。
|
||||
|
||||
需求依据:first-version-development-requirements.md 未报备签名规则;测试依据:system-functional-test-cases.md 的 TC-SIGNATURE-RETIREMENT-022/023/028/029。同一表达式同时存在于实时查询 `api/src/signature-retirement/signature-retirement.service.ts` 与日报聚合 `api/src/signature-analytics/analytics-aggregate.ts`,修复须同时覆盖。历史冻结报表不应未经授权重算。
|
||||
|
||||
15:11~15:12扩大只读对照:今天signatureId为空1327条,原表达式识别629条,对照识别1318条;进一步限定真实企业/应用关联、有效签名库无同名记录,得到655条/61个签名与应用组合的潜在遗漏。此为对照候选量,未逐一核验异常正文,不能冒称全部应补计或历史总影响。
|
||||
|
||||
## 后续最小修复建议与边界
|
||||
|
||||
在保持规范开头签名、企业应用归属和有效签名库判定不变的前提下,修复实时及日报提取对 SQL_ASCII 的兼容;在真实 SQL_ASCII 与 UTF8 隔离数据库验证汉字、非规范开头、嵌套括号、已有签名、分页及数量。无需将迁移数据库编码作为本次最小修复。
|
||||
|
||||
本轮未调用HTTP登录后接口、未做浏览器页面验收;数据库和线上运行表达式已直接复现原因。无业务代码修改、提交、推送、部署、发短信、重投或配置变更,仅新增本记录及进度条目。初次前缀字符截取因SQL_ASCII字节截断产生UTF8转换错误,改为完整字节十六进制核验;扩大统计初次SQL列名歧义,限定x.name后通过,失败事务均未写入数据。
|
||||
|
||||
## 后续授权修复与本地验收(2026-09-21)
|
||||
|
||||
用户在诊断后明确授权“修复、提交代码”,不包含推送、部署或历史重算;上文仅排查边界保留为前一阶段记录。
|
||||
|
||||
最小修复:新增共用 `leadingSignatureSql`,实时查询和日报聚合统一采用 `^【(?:(?!【|】).)+】`。负向前瞻检查完整开闭括号,不再用中文排除字符集合;非捕获分组保证提取结果包含外侧括号。保留非空内容、开头定位、拒绝嵌套括号,以及原租户/应用/有效签名匹配、signatureId、日期、排序和分页逻辑。无数据库迁移、编码变更、接口字段或权限修改;现有需求不变,无需新增业务设计。
|
||||
|
||||
验证结果:
|
||||
|
||||
- PostgreSQL 16.14 两种编码各自新建本机隔离库,使用提交内的113项迁移(排除其他会话未提交的mobile-rule迁移)。`tools/testing/verify-unreported-signature-encoding.mjs` 在UTF8、SQL_ASCII均通过:26种直接提取输入,64条目标案例,日期起止边界、其他企业/应用同名、有效/待审核/已删除签名、已有signatureId、空应用、非规范及嵌套括号。
|
||||
- 真实后端服务生成并持久化昨日日报,两日均得到13个预期聚合组;真实Nest控制器HTTP返回与数据库一致,验证每页10条的两页、三类搜索、空结果、参数化输入及非法日期/页码。测试仅注册所需控制器及服务,不注册整套应用全局认证/授权,不能冒称登录权限或浏览器已验收。没有启动发送/通知调度、Gateway、Redis或MinIO,实际Submit记录为0。
|
||||
- 隔离库验证冻结日期已有报表不重算:源消息改变后普通生成跳过,补建覆盖被拒绝,历史计数仍为1。
|
||||
- API全量87套/990项通过,覆盖率语句68.05%、分支53.73%、函数69%、行70.77%,达到现有门禁;新公共函数覆盖100%。API生产TypeScript构建、五个本轮代码文件ESLint(零错误零警告)、Prettier通过。
|
||||
- 15:33:24北京时间在预生产READ ONLY事务中对同一64条再次对照:旧提取0、新提取64,结束回滚。仅查询表达式对照,不是部署或页面修复。
|
||||
|
||||
原始失败保留:首先使用的旧PostgreSQL16运行目录缺dict_snowball,initdb失败,改用现有完整pgsql运行目录后成功;初次HTTP分页验收传入不支持的pageSize=2,返回400。夹具改为13组、每页10条,并在两种编码新空库重新迁移和完整复验通过,未放宽业务规则。原失败库/日志保留。
|
||||
|
||||
证据目录:`.local-data/unreported-signature-20260921/`,包括 `real-utf8-verified.log`、`real-ascii-verified.log`、两份 `migrate-*-verified.log`、`api-coverage.log`、`build.log`、`lint.log`、`format.log`、`preprod-readonly-comparison.log`。复跑需API构建、全迁移的本机新空库,设置 `SIGNATURE_TEST_DATABASE_URL`(库名须以cmpp_qa_signature_encoding_开头),执行上述脚本;脚本拒绝远端/非隔离库及已有租户库,不自动清空数据。
|
||||
|
||||
本轮交付为本地代码、回归脚本、需求兼容说明、测试用例及实施记录;本地提交号以Git结果为准。未推送、未部署,预生产页面仍运行旧统计逻辑,冻结报表未修正。无前端/Gateway变更,未重跑前端、Go或线上登录页面验收。
|
||||
|
||||
提交前补充精确版本复验:从暂存区导出独立候选,仅共享已安装依赖,未带入其他会话未提交代码/测试。候选API全量86套/922项通过,覆盖率68.05/53.73/69.02/70.77达门禁,生产构建通过;候选编译产物在两种编码的另外两个全新库完整复跑上述真实PG/日报/HTTP脚本均通过。前文87套/990项为工作区验证,包含他轮未提交测试,不能用作本次精确提交测试数。最终证据为 `candidate-api-coverage.log`、`candidate-build.log`、`real-utf8-candidate.log`、`real-ascii-candidate.log` 及对应迁移日志。专用本机PG集群已停止,数据和日志保留。暂存脚本初次读取较大进度文档达到Node默认输出缓冲限制,检查已暂存范围后提高本地缓冲并续接,没有覆盖其他会话内容。
|
||||
@@ -323,6 +323,10 @@ export const adminChannelsReportsApi = {
|
||||
status?: string;
|
||||
reportType?: 'signature' | 'drainage';
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
objectKeyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page: number;
|
||||
|
||||
@@ -16,6 +16,7 @@ export type HomeSummary = {
|
||||
profitCents: number;
|
||||
profitRate: number;
|
||||
};
|
||||
hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>;
|
||||
taskCount: number;
|
||||
pendingAudits: {
|
||||
enterpriseCertifications: number;
|
||||
|
||||
@@ -190,13 +190,6 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
align: 'right',
|
||||
render: (record) => record.total.toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'channelSubmitTotal',
|
||||
title: '通道提交',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => record.channelSubmitTotal.toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'successCount',
|
||||
title: '送达成功',
|
||||
@@ -339,7 +332,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
|
||||
</div>
|
||||
<div className="signature-quality-card__note">
|
||||
<strong>统计说明:</strong>
|
||||
业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
|
||||
业务短信按消息记录去重,送达结果按业务短信最终状态统计;通道尝试明细可在详情中查看。
|
||||
</div>
|
||||
<Table
|
||||
columns={signatureColumns}
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
} from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
import {
|
||||
templateVariableError,
|
||||
templateVariableNameHint,
|
||||
templateVariableNamePattern,
|
||||
} from '@/utils/templateVariables';
|
||||
import { Edit3, Eye, Plus, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TemplateOptOutModal } from './TemplateOptOutModal';
|
||||
@@ -102,6 +107,7 @@ function TemplateFormModal({
|
||||
tenants: TenantOption[];
|
||||
}) {
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [customVariableError, setCustomVariableError] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
||||
@@ -124,6 +130,7 @@ function TemplateFormModal({
|
||||
});
|
||||
const [initialForm] = useState(form);
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||
const variableError = templateVariableError(form.content);
|
||||
const tenantApplications = applications.filter(
|
||||
(application) => application.tenantId === form.tenantId && application.status !== 'deleted',
|
||||
);
|
||||
@@ -152,7 +159,11 @@ function TemplateFormModal({
|
||||
}
|
||||
|
||||
function insertVariable(name: string) {
|
||||
const normalized = name.trim();
|
||||
const normalized = name;
|
||||
if (!templateVariableNamePattern.test(normalized)) {
|
||||
setCustomVariableError(templateVariableNameHint);
|
||||
return;
|
||||
}
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -183,7 +194,14 @@ function TemplateFormModal({
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
|
||||
disabled={
|
||||
!form.tenantId ||
|
||||
!form.applicationId ||
|
||||
!form.signatureId ||
|
||||
!form.name.trim() ||
|
||||
!form.content.trim() ||
|
||||
Boolean(variableError)
|
||||
}
|
||||
onClick={() => onSubmit({ ...form, variables: currentVariables })}
|
||||
>
|
||||
保存
|
||||
@@ -224,6 +242,8 @@ function TemplateFormModal({
|
||||
/>
|
||||
<Select
|
||||
label="签名"
|
||||
searchable
|
||||
searchPlaceholder="搜索短信签名"
|
||||
onChange={(event) => selectSignature(event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择签名', value: '' },
|
||||
@@ -247,6 +267,7 @@ function TemplateFormModal({
|
||||
/>
|
||||
<Textarea
|
||||
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
||||
error={variableError}
|
||||
label="模板内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请选择签名后填写正文,例如:尊敬的${name},您的验证码为${code}。"
|
||||
@@ -276,12 +297,26 @@ function TemplateFormModal({
|
||||
<h3>自定义变量</h3>
|
||||
<div className="template-custom-variable">
|
||||
<Input
|
||||
onChange={(event) => setCustomVariable(event.target.value)}
|
||||
aria-label="自定义变量名"
|
||||
error={customVariableError}
|
||||
maxLength={32}
|
||||
onChange={(event) => {
|
||||
if (/[^A-Za-z0-9]/.test(event.target.value)) {
|
||||
setCustomVariableError(templateVariableNameHint);
|
||||
return;
|
||||
}
|
||||
setCustomVariableError('');
|
||||
setCustomVariable(event.target.value);
|
||||
}}
|
||||
placeholder="英文字符或数字"
|
||||
value={customVariable}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!templateVariableNamePattern.test(customVariable)) {
|
||||
setCustomVariableError(templateVariableNameHint);
|
||||
return;
|
||||
}
|
||||
insertVariable(customVariable);
|
||||
setCustomVariable('');
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import './AdminHome.css';
|
||||
import { HomeMetrics } from './home/HomeMetrics';
|
||||
import { HourlySendChart } from './home/HourlySendChart';
|
||||
import { homeApi } from '@/api/admin/home.api';
|
||||
import type { HomeSummary } from '@/api/types/home';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
@@ -216,6 +217,7 @@ export function AdminHome() {
|
||||
</div>
|
||||
)}
|
||||
<HomeMetrics key={dashboard?.snapshotToken ?? 'empty'} data={dashboard} />
|
||||
<HourlySendChart data={dashboard?.hourlySendTrend} loading={loading && !dashboard} />
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
|
||||
@@ -176,7 +176,12 @@ export function AdminReportTasksPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = searchParams.get('scope') === 'pending' ? 'pending' : 'all';
|
||||
const [tasks, setTasks] = useState<ReportTask[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [search, setSearch] = useState({
|
||||
enterpriseKeyword: '',
|
||||
applicationKeyword: '',
|
||||
channelKeyword: '',
|
||||
objectKeyword: '',
|
||||
});
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [reportType, setReportType] = useState('all');
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
@@ -196,7 +201,10 @@ export function AdminReportTasksPage() {
|
||||
const requestId = useRef(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
keyword: '',
|
||||
enterpriseKeyword: '',
|
||||
applicationKeyword: '',
|
||||
channelKeyword: '',
|
||||
objectKeyword: '',
|
||||
dateRange: {} as DateRangeValue,
|
||||
reportType: 'all',
|
||||
status: initialStatus,
|
||||
@@ -210,7 +218,10 @@ export function AdminReportTasksPage() {
|
||||
reportType: filters.reportType === 'all' ? undefined : (filters.reportType as 'signature' | 'drainage'),
|
||||
status: filters.status === 'all' ? undefined : filters.status,
|
||||
carrier: filters.carrier === 'all' ? undefined : filters.carrier,
|
||||
keyword: filters.keyword || undefined,
|
||||
enterpriseKeyword: filters.enterpriseKeyword || undefined,
|
||||
applicationKeyword: filters.applicationKeyword || undefined,
|
||||
channelKeyword: filters.channelKeyword || undefined,
|
||||
objectKeyword: filters.objectKeyword || undefined,
|
||||
createdAtFrom: filters.dateRange.start || undefined,
|
||||
createdAtTo: filters.dateRange.end || undefined,
|
||||
page: targetPage,
|
||||
@@ -444,12 +455,22 @@ export function AdminReportTasksPage() {
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface admin-task-filter">
|
||||
<Input
|
||||
label="企业/应用/通道/报备对象"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索报备明细"
|
||||
value={keyword}
|
||||
/>
|
||||
{(
|
||||
[
|
||||
['enterpriseKeyword', '企业'],
|
||||
['applicationKeyword', '应用'],
|
||||
['channelKeyword', '通道'],
|
||||
['objectKeyword', '报备对象'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<Input
|
||||
key={key}
|
||||
label={label}
|
||||
placeholder={`搜索${label}`}
|
||||
value={search[key]}
|
||||
onChange={(event) => setSearch((current) => ({ ...current, [key]: event.target.value }))}
|
||||
/>
|
||||
))}
|
||||
<Select
|
||||
label="报备类型"
|
||||
onChange={(event) => setReportType(event.target.value)}
|
||||
@@ -487,7 +508,7 @@ export function AdminReportTasksPage() {
|
||||
<Button
|
||||
icon={<Search size={16} />}
|
||||
onClick={() => {
|
||||
const filters = { keyword: keyword.trim(), dateRange, reportType, status, carrier };
|
||||
const filters = { ...search, dateRange, reportType, status, carrier };
|
||||
setAppliedFilters(filters);
|
||||
if (page !== 1) setPage(1);
|
||||
else loadData(1, filters);
|
||||
@@ -497,13 +518,16 @@ export function AdminReportTasksPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setSearch({ enterpriseKeyword: '', applicationKeyword: '', channelKeyword: '', objectKeyword: '' });
|
||||
setDateRange({});
|
||||
setReportType('all');
|
||||
setCarrier('all');
|
||||
setStatus('all');
|
||||
const filters = {
|
||||
keyword: '',
|
||||
enterpriseKeyword: '',
|
||||
applicationKeyword: '',
|
||||
channelKeyword: '',
|
||||
objectKeyword: '',
|
||||
dateRange: {} as DateRangeValue,
|
||||
reportType: 'all',
|
||||
status: 'all',
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('report workbench pages', () => {
|
||||
'100 条/页',
|
||||
]);
|
||||
await user.click(screen.getByRole('option', { name: '25 条/页' }));
|
||||
await user.type(screen.getByRole('textbox', { name: '企业/应用/通道/报备对象' }), '测试企业');
|
||||
await user.type(screen.getByRole('textbox', { name: '企业' }), '测试企业');
|
||||
await user.click(screen.getByRole('button', { name: '查询' }));
|
||||
await user.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('签名2-1');
|
||||
@@ -119,7 +119,7 @@ describe('report workbench pages', () => {
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: '测试企业',
|
||||
enterpriseKeyword: '测试企业',
|
||||
signatureId: 'signature-filter',
|
||||
status: 'pending',
|
||||
}),
|
||||
@@ -133,7 +133,7 @@ describe('report workbench pages', () => {
|
||||
await user.click(screen.getByRole('option', { name: `${size} 条/页` }));
|
||||
await waitFor(() =>
|
||||
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 1, pageSize: size, keyword: '测试企业' }),
|
||||
expect.objectContaining({ page: 1, pageSize: size, enterpriseKeyword: '测试企业' }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.sms-channel-form .sms-channel-form__carrier-warning {
|
||||
grid-column: 1 / -1;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-warning);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
|
||||
.sms-channel-form .sms-channel-form__carrier-warning p {
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.sms-channel-form .sms-channel-form__carrier-warning label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import './ChannelFormModal.css';
|
||||
import { Button, Input, Modal, Select } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { baseCarrierOptions, cmppVersionOptions, regionOptions } from './channelModel';
|
||||
@@ -17,6 +18,8 @@ export function ChannelFormModal({
|
||||
const [name, setName] = useState(channel?.name ?? '');
|
||||
const [carriers, setCarriers] = useState<BaseCarrier[]>(channel?.carriers ?? ['mobile']);
|
||||
const [carrierError, setCarrierError] = useState('');
|
||||
const [carrierRemovalConfirmed, setCarrierRemovalConfirmed] = useState(false);
|
||||
const removedCarriers = (channel?.carriers ?? []).filter((carrier) => !carriers.includes(carrier));
|
||||
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
|
||||
const [unitPriceError, setUnitPriceError] = useState('');
|
||||
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
|
||||
@@ -45,6 +48,10 @@ export function ChannelFormModal({
|
||||
setCarrierError('至少选择一个运营商');
|
||||
return;
|
||||
}
|
||||
if (removedCarriers.length && !carrierRemovalConfirmed) {
|
||||
setCarrierError('请先确认移除运营商对通道组的影响');
|
||||
return;
|
||||
}
|
||||
if (!isValidMoneyInput(unitPrice)) {
|
||||
setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位');
|
||||
return;
|
||||
@@ -93,7 +100,9 @@ export function ChannelFormModal({
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={submit}>确认</Button>
|
||||
<Button disabled={removedCarriers.length > 0 && !carrierRemovalConfirmed} onClick={submit}>
|
||||
确认
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
@@ -128,6 +137,7 @@ export function ChannelFormModal({
|
||||
: [...current, item.value],
|
||||
);
|
||||
setCarrierError('');
|
||||
setCarrierRemovalConfirmed(false);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -136,6 +146,26 @@ export function ChannelFormModal({
|
||||
))}
|
||||
{carrierError ? <small className="form-error">{carrierError}</small> : null}
|
||||
</div>
|
||||
{removedCarriers.length > 0 ? (
|
||||
<div className="sms-channel-form__carrier-warning" role="alert">
|
||||
<p>
|
||||
保存后,将从
|
||||
{baseCarrierOptions
|
||||
.filter((item) => removedCarriers.includes(item.value))
|
||||
.map((item) => item.label)
|
||||
.join('、')}
|
||||
的全部通道组中移除此通道,包括省网和全国配置。相关通道组可能因此没有可用通道,影响后续发送;其他运营商配置保留。
|
||||
</p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={carrierRemovalConfirmed}
|
||||
onChange={(event) => setCarrierRemovalConfirmed(event.target.checked)}
|
||||
/>
|
||||
我已确认移除运营商及对应通道组成员
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
error={unitPriceError}
|
||||
label="* 单价(元)"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { Chart } from '@/components/ui/Chart';
|
||||
import type { HomeSummary } from '@/api/types/home';
|
||||
|
||||
export function HourlySendChart({ data, loading }: { data?: HomeSummary['hourlySendTrend']; loading: boolean }) {
|
||||
const option = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
color: ['#2563eb', '#16a34a'],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['提交短信', '发送成功'], bottom: 0 },
|
||||
grid: { top: 20, left: 12, right: 16, bottom: 52, containLabel: true },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: data?.map((item) => item.label) ?? [] },
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '提交短信', type: 'line', showSymbol: false, data: data?.map((item) => item.submittedCount) ?? [] },
|
||||
{ name: '发送成功', type: 'line', showSymbol: false, data: data?.map((item) => item.successCount) ?? [] },
|
||||
],
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
return (
|
||||
<section className="surface section-stack" aria-label="小时发送曲线">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>小时发送曲线</h2>
|
||||
<p className="muted">今日北京时间,按业务短信提交小时统计;成功数为对应短信的最终送达成功数。</p>
|
||||
</div>
|
||||
</div>
|
||||
{data ? (
|
||||
<Chart height={280} option={option} />
|
||||
) : (
|
||||
<p role="status">{loading ? '正在加载小时发送数据…' : '小时发送数据暂不可用'}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
SmsMessageRecord,
|
||||
SmsMessageSegmentAudit,
|
||||
SmsReceiptRecord,
|
||||
} from '@/api/adminApi';
|
||||
import type { SmsMessageRecord, SmsMessageSegmentAudit, SmsReceiptRecord } from '@/api/adminApi';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import type { DateRangeValue } from '@/components/ui';
|
||||
import type { RouteRow } from './smsRecordTypes';
|
||||
@@ -10,7 +6,7 @@ import type { RouteRow } from './smsRecordTypes';
|
||||
export const statusLabelMap: Record<string, string> = {
|
||||
delivered: '发送成功',
|
||||
queued: '排队中',
|
||||
submitted: '已提交',
|
||||
submitted: '提交成功',
|
||||
submit_failed: '提交失败',
|
||||
unknown: '未知',
|
||||
failed: '送达失败',
|
||||
@@ -91,8 +87,8 @@ export function getRecordStatusLabel(record: SmsMessageRecord) {
|
||||
}
|
||||
|
||||
export function getReceiptNotice(record: SmsMessageRecord) {
|
||||
const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some((receipt) =>
|
||||
receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD',
|
||||
const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some(
|
||||
(receipt) => receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD',
|
||||
);
|
||||
if (hasPlatformFailureReceipt) {
|
||||
const deliveries = (record.downstreamDeliveries ?? []).filter((item) => item.deliveryType === 'receipt');
|
||||
@@ -131,9 +127,10 @@ export function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessa
|
||||
return {
|
||||
id: submitId,
|
||||
attempt: Math.min(...ordered.map((segment) => segment.attempt)),
|
||||
channel: ordered.find((segment) => segment.channel?.name)?.channel?.name
|
||||
?? ordered.find((segment) => segment.channelId)?.channelId
|
||||
?? '-',
|
||||
channel:
|
||||
ordered.find((segment) => segment.channel?.name)?.channel?.name ??
|
||||
ordered.find((segment) => segment.channelId)?.channelId ??
|
||||
'-',
|
||||
channelGroup: submitById.get(submitId)?.channelGroupName ?? submitById.get(submitId)?.channelGroup?.name,
|
||||
sentAt: sentTimes.sort()[0],
|
||||
receiptAt: receiptTimes.sort()[receiptTimes.length - 1],
|
||||
@@ -161,14 +158,16 @@ export function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessa
|
||||
};
|
||||
});
|
||||
if (submitRows.length > 0) return submitRows;
|
||||
return [{
|
||||
id: record.id,
|
||||
channel: record.channel?.name ?? record.channelId ?? '-',
|
||||
sentAt: record.submittedAt ?? record.queuedAt,
|
||||
receiptAt: record.deliveredAt,
|
||||
receiptCode: receipts[0]?.rawStatus,
|
||||
submitStatus: record.submitStatus,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: record.id,
|
||||
channel: record.channel?.name ?? record.channelId ?? '-',
|
||||
sentAt: record.submittedAt ?? record.queuedAt,
|
||||
receiptAt: record.deliveredAt,
|
||||
receiptCode: receipts[0]?.rawStatus,
|
||||
submitStatus: record.submitStatus,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
@@ -178,7 +177,21 @@ function csvCell(value: unknown) {
|
||||
|
||||
export function downloadCsv(records: SmsMessageRecord[]) {
|
||||
const rows = [
|
||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'],
|
||||
[
|
||||
'消息编号',
|
||||
'企业',
|
||||
'应用',
|
||||
'提交时间',
|
||||
'手机号',
|
||||
'地区',
|
||||
'运营商',
|
||||
'计费条数',
|
||||
'金额',
|
||||
'通道',
|
||||
'状态',
|
||||
'回执时间',
|
||||
'短信内容',
|
||||
],
|
||||
...records.map((record) => [
|
||||
record.messageId,
|
||||
record.tenant?.name ?? record.tenantId,
|
||||
@@ -195,7 +208,9 @@ export function downloadCsv(records: SmsMessageRecord[]) {
|
||||
record.content,
|
||||
]),
|
||||
];
|
||||
const blob = new Blob([`\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' });
|
||||
const blob = new Blob([`\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
|
||||
@@ -25,6 +25,24 @@ vi.mock('@/api/adminApi', () => ({
|
||||
},
|
||||
}));
|
||||
describe('client template form', () => {
|
||||
it('searches signatures and rejects invalid variable input and pasted placeholders', async () => {
|
||||
render(<ClientTemplatesPage />);
|
||||
await screen.findByText('测试模板');
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /短信签名/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索短信签名'), { target: { value: '不存在' } });
|
||||
expect(screen.getByText('无匹配选项')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索短信签名'), { target: { value: '签名' } });
|
||||
fireEvent.click(screen.getByRole('option', { name: '【签名】' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '插入变量' }));
|
||||
const input = screen.getByRole('textbox', { name: '自定义变量名' });
|
||||
fireEvent.change(input, { target: { value: '姓名' } });
|
||||
expect(input).toHaveValue('');
|
||||
fireEvent.change(input, { target: { value: 'code123' } });
|
||||
expect(input).toHaveValue('code123');
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /模板内容/ }), { target: { value: '【签名】${中文}' } });
|
||||
expect(screen.getByRole('button', { name: '提交审核' })).toBeDisabled();
|
||||
});
|
||||
it('orders requested fields and keeps standard deletion action', async () => {
|
||||
render(<ClientTemplatesPage />);
|
||||
await screen.findByText('测试模板');
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
} from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
import {
|
||||
templateVariableError,
|
||||
templateVariableNameHint,
|
||||
templateVariableNamePattern,
|
||||
} from '@/utils/templateVariables';
|
||||
import './ClientTemplatesPage.css';
|
||||
|
||||
type TemplateVariable = {
|
||||
@@ -79,6 +84,7 @@ function TemplateModal({
|
||||
signatures: ClientSmsSignatureView[];
|
||||
}) {
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [customVariableError, setCustomVariableError] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
||||
@@ -99,6 +105,7 @@ function TemplateModal({
|
||||
});
|
||||
const [initialForm] = useState(form);
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||
const variableError = templateVariableError(form.content);
|
||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||
const availableSignatures = signatures.filter(
|
||||
(signature) =>
|
||||
@@ -125,7 +132,11 @@ function TemplateModal({
|
||||
}
|
||||
|
||||
function insertVariable(name: string) {
|
||||
const normalized = name.trim();
|
||||
const normalized = name;
|
||||
if (!templateVariableNamePattern.test(normalized)) {
|
||||
setCustomVariableError(templateVariableNameHint);
|
||||
return;
|
||||
}
|
||||
if (!normalized) return;
|
||||
const token = `\${${normalized}}`;
|
||||
const textarea = contentRef.current;
|
||||
@@ -154,7 +165,13 @@ function TemplateModal({
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
|
||||
disabled={
|
||||
!form.applicationId ||
|
||||
!form.signatureId ||
|
||||
!form.name.trim() ||
|
||||
!form.content.trim() ||
|
||||
Boolean(variableError)
|
||||
}
|
||||
onClick={() => onSubmit({ ...form, variables })}
|
||||
>
|
||||
提交审核
|
||||
@@ -184,6 +201,8 @@ function TemplateModal({
|
||||
/>
|
||||
<Select
|
||||
label="短信签名"
|
||||
searchable
|
||||
searchPlaceholder="搜索短信签名"
|
||||
onChange={(event) => selectSignature(event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择签名', value: '' },
|
||||
@@ -194,6 +213,7 @@ function TemplateModal({
|
||||
/>
|
||||
<Textarea
|
||||
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
||||
error={variableError}
|
||||
label="模板内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请选择签名后填写正文,变量格式:${code}"
|
||||
@@ -223,12 +243,26 @@ function TemplateModal({
|
||||
<h3>自定义变量</h3>
|
||||
<div className="template-custom-variable">
|
||||
<Input
|
||||
onChange={(event) => setCustomVariable(event.target.value)}
|
||||
aria-label="自定义变量名"
|
||||
error={customVariableError}
|
||||
maxLength={32}
|
||||
onChange={(event) => {
|
||||
if (/[^A-Za-z0-9]/.test(event.target.value)) {
|
||||
setCustomVariableError(templateVariableNameHint);
|
||||
return;
|
||||
}
|
||||
setCustomVariableError('');
|
||||
setCustomVariable(event.target.value);
|
||||
}}
|
||||
placeholder="英文字符或数字"
|
||||
value={customVariable}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!templateVariableNamePattern.test(customVariable)) {
|
||||
setCustomVariableError(templateVariableNameHint);
|
||||
return;
|
||||
}
|
||||
insertVariable(customVariable);
|
||||
setCustomVariable('');
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { templateVariableError } from './templateVariables';
|
||||
|
||||
describe('template variable editor validation', () => {
|
||||
it.each(['${code123}', '${123}', '中文正文${Code}'])('accepts %s', (value) => {
|
||||
expect(templateVariableError(value)).toBeUndefined();
|
||||
});
|
||||
it.each(['${姓名}', '${code_1}', '${a b}', '${}', '${code', '${x}${x}', '${12}'])('rejects %s', (value) => {
|
||||
expect(templateVariableError(value)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export const templateVariableNamePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
export const templateVariableNameHint = '变量名仅允许英文字母和数字,长度1至32位';
|
||||
|
||||
export function templateVariableError(content: string): string | undefined {
|
||||
const names = new Set<string>();
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const start = content.indexOf('${', cursor);
|
||||
if (start < 0) return undefined;
|
||||
const end = content.indexOf('}', start + 2);
|
||||
if (end < 0) return '模板变量未闭合';
|
||||
const name = content.slice(start + 2, end);
|
||||
if (!templateVariableNamePattern.test(name)) return templateVariableNameHint;
|
||||
if (names.has(name)) return `模板变量 ${name} 重复`;
|
||||
names.add(name);
|
||||
cursor = end + 1;
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,12 @@
|
||||
"roots": [
|
||||
"template-optout"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/apps/admin/channels/ChannelFormModal.css",
|
||||
"owners": ["src/apps/admin/channels/ChannelFormModal.tsx"],
|
||||
"stylelintLegacy": false,
|
||||
"roots": ["sms-channel-form"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const url = new URL(process.env.OPERATIONS_TEST_DATABASE_URL || '');
|
||||
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_operations_six_'));
|
||||
const redis = new URL(process.env.OPERATIONS_TEST_REDIS_URL || 'redis://127.0.0.1:16452');
|
||||
assert(['127.0.0.1', 'localhost'].includes(redis.hostname));
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: url.toString(),
|
||||
REDIS_URL: redis.toString(),
|
||||
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
|
||||
MINIO_ENDPOINT: '127.0.0.1:19400',
|
||||
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
|
||||
SIGNATURE_ANALYTICS_ENABLED: 'false',
|
||||
HOME_DASHBOARD_ENABLED: 'false',
|
||||
GATEWAY_STARTUP_RECONNECT_DELAY_MS: '3600000',
|
||||
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
|
||||
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
|
||||
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
|
||||
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
|
||||
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
|
||||
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
|
||||
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
|
||||
CMPP_PROCESS_ROLE: 'api',
|
||||
API_ENABLE_SEND_WORKER: 'false',
|
||||
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
|
||||
});
|
||||
// Only isolated fixtures are written. No send API, worker, or Gateway is started.
|
||||
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||
require('reflect-metadata');
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
value() {
|
||||
return Number(this);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const { NestFactory } = require('@nestjs/core');
|
||||
const { AppModule } = require('./dist/app.module');
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service');
|
||||
const { UsersService } = require('./dist/users/users.service');
|
||||
const { SessionService } = require('./dist/auth/session.service');
|
||||
const { HomeProjection } = require('./dist/home-dashboard/home-projection');
|
||||
const { todayKey, startOfDay } = require('./dist/signature-analytics/analytics-date');
|
||||
const { hourlySendTrend } = require('./dist/home-dashboard/home-read');
|
||||
const app = await NestFactory.create(AppModule, { logger: ['error'] });
|
||||
app.setGlobalPrefix('api');
|
||||
let browser;
|
||||
const pass = (name) => console.log('PASS', name);
|
||||
try {
|
||||
await app.listen(Number(process.env.OPERATIONS_TEST_PORT || 17451), '127.0.0.1');
|
||||
const base = (await app.getUrl()) + '/api';
|
||||
const db = app.get(PrismaService),
|
||||
sessions = app.get(SessionService),
|
||||
users = app.get(UsersService);
|
||||
const stamp = randomUUID().slice(0, 8),
|
||||
key = () => randomUUID();
|
||||
const tenant = await db.tenant.create({ data: { name: '六项验收企业' + stamp, code: key() } });
|
||||
const makeApp = (name) =>
|
||||
db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name,
|
||||
cmppAccount: key(),
|
||||
cmppEnterpriseCode: '000001',
|
||||
secretHash: 'unused',
|
||||
interfaceEnabled: false,
|
||||
},
|
||||
});
|
||||
const application = await makeApp('通知应用' + stamp),
|
||||
otherApp = await makeApp('其他应用' + stamp);
|
||||
const makeSignature = (applicationId, name) =>
|
||||
db.smsSignature.create({ data: { tenantId: tenant.id, applicationId, name, auditStatus: 'approved' } });
|
||||
const signature = await makeSignature(application.id, '【验收甲】'),
|
||||
signature2 = await makeSignature(application.id, '【验收乙】');
|
||||
const otherSignature = await makeSignature(otherApp.id, '【其他应用】');
|
||||
const user = await users.create({
|
||||
username: 'operations' + stamp,
|
||||
email: stamp + '@example.invalid',
|
||||
displayName: '隔离验收',
|
||||
password: randomBytes(24).toString('hex'),
|
||||
roleCode: 'platform_admin',
|
||||
});
|
||||
const session = await sessions.create(user.id, 'admin', 0),
|
||||
cookie = sessions.cookieName('admin');
|
||||
const headers = { 'content-type': 'application/json', cookie: cookie + '=' + session.token };
|
||||
const req = async (route, body, method = body === undefined ? 'GET' : 'PUT', head = headers) => {
|
||||
const response = await fetch(base + route, {
|
||||
method,
|
||||
headers: head,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, data: await response.json() };
|
||||
};
|
||||
const templateBody = (name, sig = signature) => ({
|
||||
tenantId: tenant.id,
|
||||
applicationId: sig.applicationId,
|
||||
signatureId: sig.id,
|
||||
name,
|
||||
content: sig.name + '验证码${code123}',
|
||||
variables: [{ name: 'code123', example: '中文示例', required: true }],
|
||||
});
|
||||
const create = (body) => req('/admin/enterprise-templates', body, 'POST');
|
||||
const customer = await users.create({
|
||||
username: 'client' + stamp,
|
||||
email: 'c' + stamp + '@example.invalid',
|
||||
displayName: '隔离客户',
|
||||
password: randomBytes(24).toString('hex'),
|
||||
roleCode: 'enterprise_admin',
|
||||
tenantId: tenant.id,
|
||||
});
|
||||
const clientSession = await sessions.create(customer.id, 'client', 0);
|
||||
const clientHeaders = {
|
||||
'content-type': 'application/json',
|
||||
cookie: sessions.cookieName('client') + '=' + clientSession.token,
|
||||
};
|
||||
const first = await create(templateBody('通知模板' + stamp));
|
||||
assert.equal(first.status, 201, JSON.stringify(first));
|
||||
const duplicate = await create(templateBody(' 通知模板' + stamp + ' ', signature2));
|
||||
assert.equal(duplicate.status, 400);
|
||||
assert.match(duplicate.data.message, /同一.*应用|当前.*应用/);
|
||||
assert.equal((await create(templateBody('通知模板' + stamp, otherSignature))).status, 201);
|
||||
assert.equal((await req('/admin/enterprise-templates/' + first.data.id, { name: '通知模板' + stamp })).status, 200);
|
||||
const concurrent = await Promise.all([
|
||||
create(templateBody('并发' + stamp)),
|
||||
create(templateBody('并发' + stamp, signature2)),
|
||||
]);
|
||||
assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 400]);
|
||||
for (const variable of ['中文', 'a_b', 'a-b', 'a b', '12']) {
|
||||
const body = templateBody('非法' + key());
|
||||
body.content = signature.name + '${' + variable + '}';
|
||||
body.variables[0].name = variable;
|
||||
assert.equal((await create(body)).status, 400);
|
||||
}
|
||||
const numeric = templateBody('数字变量' + stamp);
|
||||
numeric.content = signature.name + '${123}';
|
||||
numeric.variables[0].name = '123';
|
||||
assert.equal((await create(numeric)).status, 201);
|
||||
const extra = await create(templateBody('修改前' + stamp));
|
||||
assert.equal((await req('/admin/enterprise-templates/' + extra.data.id, { name: first.data.name })).status, 400);
|
||||
await db.smsTemplate.update({ where: { id: extra.data.id }, data: { auditStatus: 'deleted' } });
|
||||
assert.equal((await create(templateBody('修改前' + stamp))).status, 201);
|
||||
assert.equal(
|
||||
(await req('/admin/enterprise-templates/' + extra.data.id + '/status', { status: 'approved' }, 'POST')).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await req('/admin/enterprise-templates', undefined, 'GET', {})).status, 401);
|
||||
const clientBody = templateBody('客户模板' + stamp);
|
||||
delete clientBody.tenantId;
|
||||
assert.equal((await req('/client/templates', clientBody, 'POST', clientHeaders)).status, 201);
|
||||
assert.equal(
|
||||
(
|
||||
await req(
|
||||
'/client/templates',
|
||||
{ ...clientBody, signatureId: signature2.id, content: signature2.name + '验证码${code123}' },
|
||||
'POST',
|
||||
clientHeaders,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await req(
|
||||
'/client/templates',
|
||||
{ ...clientBody, name: '非法客户模板', content: signature.name + '${中文}', variables: [{ name: '中文' }] },
|
||||
'POST',
|
||||
clientHeaders,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await req('/admin/enterprise-templates', undefined, 'GET', clientHeaders)).status, 401);
|
||||
pass(
|
||||
'real API application/name uniqueness across signatures, concurrent saves, rename, restore, cross-application and variable validation',
|
||||
);
|
||||
|
||||
const channel = await db.smsChannel.create({
|
||||
data: {
|
||||
code: key(),
|
||||
name: '验收通道' + stamp,
|
||||
carrier: 'mobile',
|
||||
carriers: ['mobile', 'unicom', 'telecom'],
|
||||
status: 'active',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: key(),
|
||||
passwordCipher: 'unused',
|
||||
srcId: '1069',
|
||||
},
|
||||
});
|
||||
const groups = [];
|
||||
for (const [carrier, province] of [
|
||||
['mobile', '全国'],
|
||||
['unicom', '全国'],
|
||||
['unicom', '湖北'],
|
||||
['telecom', '全国'],
|
||||
]) {
|
||||
const group = await db.smsChannelGroup.create({
|
||||
data: {
|
||||
code: key(),
|
||||
name: carrier + province + stamp,
|
||||
carrier,
|
||||
items: { create: { channelId: channel.id, carrier, province } },
|
||||
},
|
||||
});
|
||||
groups.push(group);
|
||||
await db.channelRouteRule.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier },
|
||||
});
|
||||
}
|
||||
const filters = {
|
||||
enterpriseKeyword: tenant.name,
|
||||
applicationKeyword: application.name,
|
||||
channelKeyword: channel.name,
|
||||
objectKeyword: signature.name,
|
||||
pageSize: '1',
|
||||
};
|
||||
const details = await req('/admin/report-details?' + new URLSearchParams(filters));
|
||||
assert.equal(details.status, 200);
|
||||
assert.equal(details.data.total, 3);
|
||||
assert.equal(details.data.items.length, 1);
|
||||
assert.equal(details.data.items[0].virtual, true);
|
||||
for (const field of ['enterpriseKeyword', 'applicationKeyword', 'channelKeyword', 'objectKeyword']) {
|
||||
const empty = await req('/admin/report-details?' + new URLSearchParams({ ...filters, [field]: '不匹配' }));
|
||||
assert.equal(empty.data.total, 0, field);
|
||||
}
|
||||
const page2 = await req('/admin/report-details?' + new URLSearchParams({ ...filters, page: '2' }));
|
||||
assert.notEqual(page2.data.items[0].id, details.data.items[0].id);
|
||||
pass('four independent AND search fields apply before pagination and retain virtual unreported rows');
|
||||
|
||||
const date = todayKey(new Date()),
|
||||
start = startOfDay(date);
|
||||
const beforeTrend = await hourlySendTrend(db, date);
|
||||
const messages = [];
|
||||
for (const [index, status] of ['submitted', 'unknown', 'delivered', 'failed', 'queued', 'rejected'].entries()) {
|
||||
messages.push(
|
||||
await db.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
channelId: channel.id,
|
||||
messageId: key(),
|
||||
phoneNumber: '13800000000',
|
||||
content: signature.name + '隔离记录',
|
||||
status,
|
||||
queuedAt: new Date(+start + (index ? 3600000 : 0)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await db.smsMessageRecord.create({
|
||||
data: {
|
||||
messageId: key(),
|
||||
phoneNumber: '13800000000',
|
||||
content: '前日',
|
||||
status: 'delivered',
|
||||
queuedAt: new Date(+start - 1),
|
||||
},
|
||||
});
|
||||
await db.smsSubmitRecord.createMany({
|
||||
data: [1, 2].map(() => ({
|
||||
messageRecordId: messages[2].id,
|
||||
channelId: channel.id,
|
||||
submitId: key(),
|
||||
submitStatus: 'submitted',
|
||||
})),
|
||||
});
|
||||
const records = await req(
|
||||
'/admin/operations/messages?' +
|
||||
new URLSearchParams({ applicationId: application.id, status: 'unknown', page: '1', pageSize: '20' }),
|
||||
);
|
||||
assert.equal(records.status, 200);
|
||||
assert.deepEqual(records.data.items.map((r) => r.status).sort(), ['submitted', 'unknown']);
|
||||
const trend = await hourlySendTrend(db, date);
|
||||
assert.equal(trend.length, 24);
|
||||
assert.equal(trend[0].submittedCount - beforeTrend[0].submittedCount, 1);
|
||||
assert.equal(trend[1].submittedCount - beforeTrend[1].submittedCount, 5);
|
||||
assert.equal(trend[1].successCount - beforeTrend[1].successCount, 1);
|
||||
assert.equal(trend[2].submittedCount - beforeTrend[2].submittedCount, 0);
|
||||
await app.get(HomeProjection).tick();
|
||||
const home = await req('/admin/operations/home/summary');
|
||||
assert.equal(home.status, 200, JSON.stringify(home));
|
||||
assert.deepEqual(home.data.hourlySendTrend, trend);
|
||||
pass(
|
||||
'unknown maps to submitted and legacy unknown; home uses Beijing hours, zero fill and business rows, not supplier retries',
|
||||
);
|
||||
|
||||
// Force a failure after member deletion: the entire configuration transaction must roll back.
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE FUNCTION qa_reject_channel_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='sms_channel.update' THEN RAISE EXCEPTION 'qa rollback'; END IF; RETURN NEW; END $$`,
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE TRIGGER qa_reject_channel_audit BEFORE INSERT ON "OperationLog" FOR EACH ROW EXECUTE FUNCTION qa_reject_channel_audit()`,
|
||||
);
|
||||
assert.equal((await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'telecom'] })).status, 500);
|
||||
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 4);
|
||||
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, [
|
||||
'mobile',
|
||||
'unicom',
|
||||
'telecom',
|
||||
]);
|
||||
await db.$executeRawUnsafe('DROP TRIGGER qa_reject_channel_audit ON "OperationLog"');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION qa_reject_channel_audit()');
|
||||
assert.equal((await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'telecom'] })).status, 200);
|
||||
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 2);
|
||||
assert.equal(await db.channelRouteRule.count({ where: { applicationId: application.id } }), 4);
|
||||
const audit = await db.operationLog.findFirst({
|
||||
where: { resourceId: channel.id, action: 'sms_channel.update' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
assert.equal(audit.detail.removedGroupItems.length, 2);
|
||||
await assert.rejects(
|
||||
db.smsChannelGroupItem.create({ data: { channelId: channel.id, groupId: groups[1].id, carrier: 'unicom' } }),
|
||||
/not compatible/,
|
||||
);
|
||||
pass(
|
||||
'carrier reduction is atomic, removes national/province memberships, preserves routes and other carriers, records audit and rejects stale membership writes',
|
||||
);
|
||||
|
||||
const { Client } = require('pg');
|
||||
const writer = new Client({ connectionString: url.toString() });
|
||||
await writer.connect();
|
||||
let settled = false,
|
||||
insertion;
|
||||
try {
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.smsChannel.update({ where: { id: channel.id }, data: { carriers: ['mobile'] } });
|
||||
insertion = writer
|
||||
.query('INSERT INTO "SmsChannelGroupItem" (id,"groupId","channelId",carrier) VALUES ($1,$2,$3,$4)', [
|
||||
key(),
|
||||
groups[3].id,
|
||||
channel.id,
|
||||
'telecom',
|
||||
])
|
||||
.then(
|
||||
() => ({ success: true }),
|
||||
(error) => ({ code: error.code }),
|
||||
)
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.equal(settled, false);
|
||||
await tx.smsChannelGroupItem.deleteMany({ where: { channelId: channel.id, groupId: groups[3].id } });
|
||||
});
|
||||
assert.equal((await insertion).code, '23514');
|
||||
} finally {
|
||||
await writer.end();
|
||||
}
|
||||
await db.smsChannel.update({ where: { id: channel.id }, data: { carriers: ['mobile', 'telecom'] } });
|
||||
await db.smsChannelGroupItem.create({ data: { channelId: channel.id, groupId: groups[3].id, carrier: 'telecom' } });
|
||||
pass('real concurrent member insertion waits for carrier change then rejects stale capability');
|
||||
|
||||
if (process.env.OPERATIONS_TEST_BROWSER_URL) {
|
||||
const uiUrl = new URL(process.env.OPERATIONS_TEST_BROWSER_URL);
|
||||
assert(['localhost', '127.0.0.1'].includes(uiUrl.hostname));
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const evidence = process.env.OPERATIONS_TEST_EVIDENCE_DIR;
|
||||
assert(evidence && path.isAbsolute(evidence));
|
||||
mkdirSync(evidence, { recursive: true });
|
||||
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: cookie, value: session.token, url: uiUrl.origin, httpOnly: true, sameSite: 'Lax' },
|
||||
]);
|
||||
const auth = await req('/admin/auth/session');
|
||||
assert.equal(auth.status, 200);
|
||||
await context.addInitScript(
|
||||
(value) => localStorage.setItem('cmpp-auth-session:admin', JSON.stringify(value)),
|
||||
auth.data,
|
||||
);
|
||||
const page = await context.newPage(),
|
||||
errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
for (const [width, height] of [
|
||||
[1600, 1000],
|
||||
[1366, 768],
|
||||
[390, 844],
|
||||
]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(uiUrl.origin + '/admin');
|
||||
await page.getByRole('heading', { name: '小时发送曲线', exact: true }).waitFor();
|
||||
await page.locator('[aria-label="小时发送曲线"] canvas').waitFor();
|
||||
const curve = await page.getByRole('heading', { name: '小时发送曲线', exact: true }).boundingBox();
|
||||
const rank = await page.getByRole('heading', { name: /企业消费排行/ }).boundingBox();
|
||||
assert(curve.y < rank.y);
|
||||
await page.screenshot({ path: path.join(evidence, `home-${width}.png`), fullPage: true });
|
||||
await page.reload();
|
||||
await page.locator('[aria-label="小时发送曲线"] canvas').waitFor();
|
||||
await page.goto(uiUrl.origin + '/admin/report-tasks');
|
||||
for (const label of ['企业', '应用', '通道', '报备对象'])
|
||||
await page.getByRole('textbox', { name: label, exact: true }).waitFor();
|
||||
await page.getByRole('textbox', { name: '企业', exact: true }).fill(tenant.name);
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
await page.screenshot({ path: path.join(evidence, `reports-${width}.png`), fullPage: true });
|
||||
await page.goto(uiUrl.origin + '/admin/enterprise-templates');
|
||||
await page.getByRole('textbox', { name: '企业应用', exact: true }).fill(application.name);
|
||||
await page.getByRole('textbox', { name: '模板名称', exact: true }).fill(first.data.name);
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
await page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: first.data.name })
|
||||
.filter({ hasText: application.name })
|
||||
.getByRole('button', { name: '编辑', exact: true })
|
||||
.click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: /^签名/ }).click();
|
||||
await page.getByPlaceholder('搜索短信签名').fill('验收甲');
|
||||
await page.getByRole('option', { name: signature.name, exact: true }).waitFor();
|
||||
await page.screenshot({ path: path.join(evidence, `template-search-${width}.png`), fullPage: true });
|
||||
await page.getByRole('option', { name: signature.name, exact: true }).click();
|
||||
await page.getByRole('textbox', { name: /模板内容/ }).fill(signature.name + '${中文}');
|
||||
assert(await page.getByRole('button', { name: '保存', exact: true }).isDisabled());
|
||||
await page.getByRole('button', { name: '取消', exact: true }).click();
|
||||
await page.getByRole('button', { name: '放弃并关闭', exact: true }).click();
|
||||
await page.goto(uiUrl.origin + '/admin/channels');
|
||||
await page.getByRole('textbox', { name: '通道名称', exact: true }).fill(channel.name);
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
await page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: channel.name })
|
||||
.getByRole('button', { name: '编辑', exact: true })
|
||||
.click();
|
||||
await page.getByRole('checkbox', { name: '电信', exact: true }).uncheck();
|
||||
await page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: /通道组/ })
|
||||
.waitFor();
|
||||
assert(await page.getByRole('button', { name: '确认', exact: true }).isDisabled());
|
||||
await page.screenshot({ path: path.join(evidence, `carrier-warning-${width}.png`), fullPage: true });
|
||||
await page.getByRole('checkbox', { name: /我已确认/ }).check();
|
||||
assert(await page.getByRole('button', { name: '确认', exact: true }).isEnabled());
|
||||
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).last().click();
|
||||
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 2);
|
||||
await page.goto(uiUrl.origin + '/admin/analytics');
|
||||
await page.getByRole('heading', { name: '签名通道发送质量', exact: true }).waitFor();
|
||||
await page
|
||||
.getByRole('columnheader', { name: '业务短信', exact: true, includeHidden: true })
|
||||
.waitFor({ state: 'attached' });
|
||||
assert.equal(
|
||||
await page.getByRole('columnheader', { name: '通道提交', exact: true, includeHidden: true }).count(),
|
||||
0,
|
||||
);
|
||||
await page.screenshot({ path: path.join(evidence, `quality-${width}.png`), fullPage: true });
|
||||
await page.goto(uiUrl.origin + '/admin/sms-records');
|
||||
await page.getByRole('button', { name: '发送状态', exact: true }).click();
|
||||
await page.getByRole('option', { name: '未知', exact: true }).click();
|
||||
const filteredResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('/api/admin/operations/messages?') &&
|
||||
new URL(r.url()).searchParams.get('status') === 'unknown',
|
||||
);
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
const filtered = await (await filteredResponse).json();
|
||||
assert(filtered.items.some((r) => r.status === 'submitted'));
|
||||
await page.getByText('提交成功', { exact: true }).first().waitFor();
|
||||
await page.screenshot({ path: path.join(evidence, `unknown-${width}.png`), fullPage: true });
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
pass(
|
||||
'real authenticated UI: three viewport sizes, refresh, routing, searchable signatures, validation, channel warning and cancel',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await app.close();
|
||||
}
|
||||
@@ -208,8 +208,9 @@ try {
|
||||
const reduced = await req('/admin/channels/' + channel.id, { carriers: ['mobile'] });
|
||||
assert.equal(reduced.status, 200, await reduced.text());
|
||||
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, ['mobile']);
|
||||
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: unicom.id, channelId: channel.id } }), 1);
|
||||
pass('carrier reduction saves with active group references preserved');
|
||||
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: unicom.id, channelId: channel.id } }), 0);
|
||||
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: group.id, channelId: channel.id } }), 1);
|
||||
pass('carrier reduction removes incompatible members and preserves supported group members');
|
||||
const batch = await db.smsBatchTask.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, taskNo: key(), content, phoneTotal: 1 },
|
||||
});
|
||||
@@ -373,6 +374,8 @@ try {
|
||||
.click();
|
||||
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor();
|
||||
await page.getByRole('checkbox', { name: '联通', exact: true }).uncheck();
|
||||
assert(await page.getByRole('button', { name: '确认', exact: true }).isDisabled());
|
||||
await page.getByRole('checkbox', { name: /我已确认移除运营商/ }).check();
|
||||
await page.screenshot({ path: `.local-data/template-optout-20260920/channel-${width}.png`, fullPage: true });
|
||||
await page.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor({ state: 'hidden' });
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
// Run once per encoding against a fresh, fully migrated local QA database.
|
||||
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
|
||||
assert(['127.0.0.1', 'localhost'].includes(url.hostname));
|
||||
assert(url.pathname.startsWith('/cmpp_qa_signature_encoding_'));
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
process.env.CMPP_PROCESS_ROLE = 'api';
|
||||
process.env.NODE_ENV = 'test';
|
||||
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||
require('reflect-metadata');
|
||||
const { Prisma } = require('@prisma/client');
|
||||
const { Module } = require('@nestjs/common');
|
||||
const { NestFactory } = require('@nestjs/core');
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service');
|
||||
const { leadingSignatureSql } = require('./dist/signature-analytics/signature-extraction');
|
||||
const { unreportedRows } = require('./dist/signature-analytics/analytics-aggregate');
|
||||
const { SignatureRetirementService } = require('./dist/signature-retirement/signature-retirement.service');
|
||||
const { SignatureRetirementController } = require('./dist/signature-retirement/signature-retirement.controller');
|
||||
const { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service');
|
||||
const { todayKey, addDays, startOfDay } = require('./dist/signature-analytics/analytics-date');
|
||||
const db = new PrismaService();
|
||||
const service = new SignatureRetirementService(db);
|
||||
const writer = new SignatureAnalyticsService(db);
|
||||
const today = todayKey();
|
||||
const yesterday = addDays(today, -1);
|
||||
const target = '【宜都市万商市场投资有限公司】';
|
||||
const pass = (name) => console.log('PASS', name);
|
||||
let app;
|
||||
|
||||
try {
|
||||
assert.equal(await db.tenant.count(), 0, 'A fresh isolated database is required; never clear existing data');
|
||||
const [{ server_encoding: encoding }] = await db.$queryRaw`SHOW server_encoding`;
|
||||
assert(['UTF8', 'SQL_ASCII'].includes(encoding));
|
||||
console.log('Database encoding:', encoding);
|
||||
const cases = [
|
||||
[target + '正文', target],
|
||||
['【湘银物业】正文', '【湘银物业】'],
|
||||
['【A】正文', '【A】'],
|
||||
['【天地中文—123】正文', '【天地中文—123】'],
|
||||
['【😀𠀀】正文', '【😀𠀀】'],
|
||||
["【O'Reilly\\示例】正文", "【O'Reilly\\示例】"],
|
||||
['【一\n二】正文', '【一\n二】'],
|
||||
['【一\r\n二】正文', '【一\r\n二】'],
|
||||
['【 】正文', '【 】'],
|
||||
['【第一】正文【第二】', '【第一】'],
|
||||
['【第一】【第二】', '【第一】'],
|
||||
['【第二】', '【第二】'],
|
||||
['【' + '中'.repeat(10000) + '】正文', '【' + '中'.repeat(10000) + '】'],
|
||||
['', null],
|
||||
['无签名正文', null],
|
||||
[' 正文【签名】', null],
|
||||
[' 【签名】正文', null],
|
||||
['\n【签名】正文', null],
|
||||
['\uFEFF【签名】正文', null],
|
||||
['[签名]正文', null],
|
||||
['【】正文', null],
|
||||
['【未关闭', null],
|
||||
['】错误【签名】', null],
|
||||
['【外【内】外】', null],
|
||||
['【【嵌套】', null],
|
||||
["'; SELECT 1; --【签名】", null],
|
||||
];
|
||||
const extracted = await db.$queryRaw(Prisma.sql`
|
||||
SELECT v.value AS input,${leadingSignatureSql(Prisma.sql`v.value`)} AS signature
|
||||
FROM jsonb_array_elements_text(${JSON.stringify(cases.map(([input]) => input))}::jsonb) WITH ORDINALITY AS v(value,position)
|
||||
ORDER BY v.position`);
|
||||
assert.deepEqual(
|
||||
extracted.map((r) => [r.input, r.signature]),
|
||||
cases,
|
||||
);
|
||||
const [old] = await db.$queryRaw(Prisma.sql`SELECT SUBSTRING(${target} FROM '^【[^【】]+】') AS signature`);
|
||||
assert.equal(old.signature, encoding === 'SQL_ASCII' ? null : target);
|
||||
pass('26 extraction cases; original SQL_ASCII failure reproduced; UTF8 semantics retained');
|
||||
|
||||
const tenant = await db.tenant.create({ data: { name: '编码验收企业', code: randomUUID() } });
|
||||
const foreign = await db.tenant.create({ data: { name: '其他企业', code: randomUUID() } });
|
||||
const createApplication = (owner, name) =>
|
||||
db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: owner.id,
|
||||
name,
|
||||
cmppAccount: randomUUID(),
|
||||
cmppEnterpriseCode: '000001',
|
||||
secretHash: 'isolated-no-login',
|
||||
interfaceEnabled: false,
|
||||
},
|
||||
});
|
||||
const own = await createApplication(tenant, '应用甲');
|
||||
const other = await createApplication(tenant, '应用乙');
|
||||
const foreignApp = await createApplication(foreign, '其他企业应用');
|
||||
const signature = (application, name, auditStatus = 'approved') =>
|
||||
db.smsSignature.create({
|
||||
data: { tenantId: application.tenantId, applicationId: application.id, name, auditStatus },
|
||||
});
|
||||
const registered = await signature(own, '【已登记无通道报备】');
|
||||
await signature(own, '【待审核签名】', 'pending');
|
||||
await signature(own, '【已删除签名】', 'deleted');
|
||||
await signature(other, target);
|
||||
await signature(foreignApp, target);
|
||||
await signature(own, '【跨应用隔离】');
|
||||
const message = (application, content, queuedAt, signatureId = null) => ({
|
||||
messageId: randomUUID(),
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
signatureId,
|
||||
phoneNumber: '13800000000',
|
||||
content,
|
||||
queuedAt,
|
||||
status: 'failed',
|
||||
errorCode: 'SIGNATURE',
|
||||
});
|
||||
const expected = [
|
||||
{ applicationId: own.id, signatureName: target, messageCount: 64 },
|
||||
{ applicationId: own.id, signatureName: '【已删除签名】', messageCount: 1 },
|
||||
{ applicationId: other.id, signatureName: '【跨应用隔离】', messageCount: 1 },
|
||||
...Array.from({ length: 10 }, (_, i) => ({
|
||||
applicationId: other.id,
|
||||
signatureName: `【分页${i}】`,
|
||||
messageCount: 1,
|
||||
})),
|
||||
];
|
||||
const summarize = (items) =>
|
||||
items
|
||||
.map(({ applicationId, signatureName, messageCount }) => ({ applicationId, signatureName, messageCount }))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
for (const date of [today, yesterday]) {
|
||||
const start = startOfDay(date);
|
||||
const end = startOfDay(addDays(date, 1));
|
||||
const rows = Array.from({ length: 64 }, (_, i) =>
|
||||
message(own, target + '隔离正文', i === 63 ? new Date(end.getTime() - 1) : start),
|
||||
);
|
||||
rows.push(
|
||||
message(own, '【已删除签名】正文', start),
|
||||
message(other, '【跨应用隔离】正文', start),
|
||||
message(own, registered.name + '正文', start),
|
||||
message(own, '【待审核签名】正文', start),
|
||||
message(other, target + '正文', start),
|
||||
message(foreignApp, target + '正文', start),
|
||||
message(own, '【已关联记录】正文', start, registered.id),
|
||||
{ ...message(own, '【无应用】正文', start), applicationId: null },
|
||||
...expected.slice(3).map((item) => message(other, item.signatureName + '正文', start)),
|
||||
...cases.filter(([, expectedSignature]) => expectedSignature === null).map(([text]) => message(own, text, start)),
|
||||
);
|
||||
await db.smsMessageRecord.createMany({ data: rows });
|
||||
assert.deepEqual(summarize((await service.unreportedSignaturesLive({ date })).items), summarize(expected));
|
||||
assert.deepEqual(summarize(await unreportedRows(db, date)), summarize(expected));
|
||||
}
|
||||
await db.smsMessageRecord.createMany({
|
||||
data: [
|
||||
message(own, '【下一天】正文', startOfDay(addDays(today, 1))),
|
||||
message(own, '【前一天】正文', new Date(startOfDay(yesterday).getTime() - 1)),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(summarize((await service.unreportedSignatures({ date: today })).items), summarize(expected));
|
||||
const generated = await writer.generate(yesterday);
|
||||
assert.equal(generated.skipped, false);
|
||||
const historical = await service.unreportedSignatures({ date: yesterday });
|
||||
assert.equal(historical.dataSource, 'report');
|
||||
assert.deepEqual(summarize(historical.items), summarize(expected));
|
||||
assert.equal(await db.unreportedSignatureDaily.count(), expected.length);
|
||||
pass(
|
||||
'64-message reproduction, date boundaries, tenant/application isolation, existing/deleted/linked signatures, real daily persistence',
|
||||
);
|
||||
|
||||
// Real controller and service over HTTP, without the application schedulers or sending processes.
|
||||
// Authentication is unchanged and belongs to the full application, not this isolated controller harness.
|
||||
class Harness {}
|
||||
Module({
|
||||
controllers: [SignatureRetirementController],
|
||||
providers: [
|
||||
{ provide: PrismaService, useValue: db },
|
||||
{ provide: SignatureRetirementService, useValue: service },
|
||||
],
|
||||
})(Harness);
|
||||
app = await NestFactory.create(Harness, { logger: false });
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const base = await app.getUrl();
|
||||
const get = async (query, status = 200) => {
|
||||
const response = await fetch(
|
||||
`${base}/admin/signature-retirement/unreported-signatures?${new URLSearchParams(query)}`,
|
||||
{ signal: AbortSignal.timeout(15000) },
|
||||
);
|
||||
assert.equal(response.status, status);
|
||||
return response.json();
|
||||
};
|
||||
for (const date of [today, yesterday]) {
|
||||
const first = await get({ date, page: '1', pageSize: '10' });
|
||||
const second = await get({ date, page: '2', pageSize: '10' });
|
||||
assert.equal(first.total, expected.length);
|
||||
assert.equal(second.total, expected.length);
|
||||
assert.deepEqual(summarize([...first.items, ...second.items]), summarize(expected));
|
||||
for (const keyword of ['宜都市万商市场投资有限公司', '应用甲', '编码验收企业']) {
|
||||
const result = await get({ date, keyword });
|
||||
const wanted = keyword.startsWith('宜都')
|
||||
? expected.slice(0, 1)
|
||||
: keyword === '应用甲'
|
||||
? expected.slice(0, 2)
|
||||
: expected;
|
||||
assert.deepEqual(summarize(result.items), summarize(wanted));
|
||||
}
|
||||
assert.equal((await get({ date, keyword: '不存在的签名' })).total, 0);
|
||||
assert.equal((await get({ date, keyword: "' OR 1=1 --" })).total, 0);
|
||||
}
|
||||
await get({ date: '2026-02-30' }, 400);
|
||||
await get({ date: today, page: '0' }, 400);
|
||||
pass(
|
||||
'real HTTP live/report results, independent pagination, signature/company/application search, empty results and invalid parameters',
|
||||
);
|
||||
|
||||
const frozenDate = addDays(today, -5);
|
||||
await db.smsMessageRecord.create({ data: message(own, target, startOfDay(frozenDate)) });
|
||||
await writer.generate(frozenDate, true);
|
||||
const before = await service.unreportedSignatures({ date: frozenDate });
|
||||
await db.smsMessageRecord.create({ data: message(own, target, startOfDay(frozenDate)) });
|
||||
assert.equal((await writer.generate(frozenDate)).skipped, true);
|
||||
await assert.rejects(() => writer.generate(frozenDate, true), /历史补建不得覆盖/);
|
||||
const after = await service.unreportedSignatures({ date: frozenDate });
|
||||
assert.deepEqual(after.items, before.items);
|
||||
assert.equal(after.items[0].messageCount, 1);
|
||||
pass('frozen history stays unchanged; no automatic historical rebuild');
|
||||
assert.equal(await db.smsSubmitRecord.count(), 0);
|
||||
pass('no supplier submissions created');
|
||||
} finally {
|
||||
if (app) await app.close();
|
||||
await db.$disconnect();
|
||||
}
|
||||
Reference in New Issue
Block a user