feat: 优化签名热力图与金额展示

This commit is contained in:
hectorzhao
2026-08-12 11:41:47 +08:00
parent e64b5e23fe
commit 0cd353450a
33 changed files with 344 additions and 121 deletions
+35 -5
View File
@@ -24,9 +24,9 @@ describe('ReportsService', () => {
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]);
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), refundCents: BigInt(100), costCents: BigInt(600), profitCents: BigInt(400) } });
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } });
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
@@ -57,6 +57,19 @@ describe('ReportsService', () => {
expect(profitQueries).not.toContain('SUM(submit."costAmountCents")');
});
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
);
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
expect(profitQueries).toContain('message."submitId" = submit."submitId"');
expect(profitQueries).not.toContain('"billingStatus"');
expect(profitQueries).not.toContain('billing.refund');
});
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({
dateFrom: '2026-07-01',
@@ -74,9 +87,12 @@ describe('ReportsService', () => {
});
it('keeps application and channel profit filters separate', async () => {
await expect(service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' })).resolves.toEqual(expect.objectContaining({
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
expect(result).toEqual(expect.objectContaining({
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
}));
expect(result.summary).not.toHaveProperty('refundCents');
expect(result.items[0]).not.toHaveProperty('refundCents');
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
@@ -91,15 +107,29 @@ describe('ReportsService', () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, refundCents: null, costCents: null, profitCents: null },
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null },
});
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
total: 0,
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
}));
});
it('exports income without refund columns', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业',
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1,
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
}]);
const exported = await service.exportProfit({ dimensionType: 'application' });
expect(exported.content).toContain('收入金额(元)');
expect(exported.content).not.toContain('净消费金额(元)');
expect(exported.content).not.toContain('返还金额(元)');
});
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
+35 -31
View File
@@ -56,18 +56,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async listProfit(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = profitWhere(query);
const [items, total, aggregate] = await Promise.all([
const [storedItems, total, aggregate] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyProfitReport.count({ where }),
this.prisma.dailyProfitReport.aggregate({
where,
_sum: { ...reportVolumeSumSelection, revenueCents: true, refundCents: true, costCents: true, profitCents: true },
_sum: { ...reportVolumeSumSelection, revenueCents: true, costCents: true, profitCents: true },
}),
]);
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
const summary = {
...volumeSummary(aggregate._sum),
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
refundCents: Number(aggregate._sum.refundCents ?? 0),
costCents: Number(aggregate._sum.costCents ?? 0),
profitCents: Number(aggregate._sum.profitCents ?? 0),
// 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。
@@ -100,7 +101,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
}
async exportQuality(query: ReportListQuery) {
@@ -169,13 +170,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId",
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
FROM "SmsBillingRecord"
GROUP BY "messageId"
), costs AS (
WITH costs AS (
SELECT
submit."messageRecordId",
SUM(submit."costUnitPrice" * CASE
@@ -230,18 +225,24 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(billing.revenue), 0)::bigint,
COALESCE(SUM(billing.refund), 0)::bigint,
-- 收入按每条最终成功短信的计费条数和发送时客户价快照计算,不能依赖随后可能变为 refunded 的账单状态。
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
0::bigint,
COALESCE(SUM(costs.cost), 0)::bigint,
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
(COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
CASE WHEN COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 /
SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
JOIN "SmsApplication" application ON application.id = message."applicationId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN costs ON costs."messageRecordId" = message.id
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
@@ -249,13 +250,6 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId",
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
FROM "SmsBillingRecord"
GROUP BY "messageId"
)
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
@@ -297,31 +291,41 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
-- 补发可能产生多次提交,收入只归属最终成功提交,避免同一短信在多个通道重复计收。
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
0::bigint,
COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0)::bigint,
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0))::bigint,
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0)) * 10000.0 /
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS audit_count,
@@ -58,7 +58,7 @@ describe('SignatureRetirementService dimensions', () => {
it('returns enterprise application metadata for heatmap hover and search', async () => {
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
tenant: { findMany: jest.fn().mockResolvedValue([]) },
@@ -76,6 +76,24 @@ describe('SignatureRetirementService dimensions', () => {
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
]));
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
});
it('persists daily observing snapshots without opening alert cycles', async () => {
const prisma = {
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
};
const observingService = new SignatureRetirementService(prisma as never);
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
});
it('maps the real unreported-signature aggregation to an independent page', async () => {
@@ -225,7 +225,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
const endKey = assertDateKey(date || shanghaiDateKey());
const startKey = addDays(endKey, -30);
const detections = await this.prisma.signatureRetirementDetection.findMany({
where: { detectionDate: { gte: databaseDate(startKey), lt: databaseDate(endKey) } },
where: { detectionDate: { gt: databaseDate(startKey), lte: databaseDate(endKey) } },
orderBy: [{ dimensionType: 'asc' }, { signatureId: 'asc' }, { channelKey: 'asc' }, { carrier: 'asc' }, { detectionDate: 'desc' }],
});
const [signatures, channels, tenants, approvedTasks] = await Promise.all([
@@ -249,7 +249,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
return {
date: endKey,
dimensions: [...dimensionMap.values()],
items: detections.map((item) => ({ ...item, signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })),
// 检测在次日04:00运行,因此检测日对应的活动自然日固定为T-1。
items: detections.map((item) => ({ ...item, activityDate: addDays(shanghaiDateKey(item.detectionDate), -1), signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })),
};
}
@@ -357,13 +358,23 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier);
const windowStartKey = addDays(detectionKey, -windowDays);
const windowStart = shanghaiStart(windowStartKey);
if (dimension.approvedAt > windowStart) {
const activityStart = shanghaiStart(addDays(detectionKey, -1));
const activityEnd = shanghaiStart(detectionKey);
const effectiveActivityStart = dimension.approvedAt > activityStart ? dimension.approvedAt : activityStart;
if (effectiveActivityStart >= activityEnd) {
ineligible += 1;
continue;
}
const counts = await this.activityCounts(dimension, windowStart, shanghaiStart(detectionKey));
const isAlert = counts.acceptedBusinessCount < threshold;
await this.persistDetection(detectionKey, dimension, windowDays, threshold, counts, isAlert);
const dailyCounts = await this.activityCounts(dimension, effectiveActivityStart, activityEnd);
if (dimension.approvedAt > windowStart) {
// 观察期只禁止预警,不能吞掉真实发送快照,否则热力图会错误显示无数据。
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, false, true);
ineligible += 1;
continue;
}
const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd);
const isAlert = windowCounts.acceptedBusinessCount < threshold;
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, isAlert, false, windowCounts);
if (isAlert) alerted += 1;
else healthy += 1;
}
@@ -482,7 +493,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 };
}
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean) {
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean, observing = false, alertCounts = counts) {
const detectionDate = databaseDate(dateKey);
const channelKey = dimension.channelId ?? '';
const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({
@@ -494,7 +505,9 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } } });
const suppressed = Boolean(suppression?.active && (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate));
let cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
if (isAlert) {
if (observing) {
cycle = null;
} else if (isAlert) {
if (!cycle) {
try {
cycle = await this.prisma.signatureRetirementCycle.create({ data: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, startedOn: detectionDate, lastDetectedOn: detectionDate } });
@@ -510,10 +523,10 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
cycle = null;
}
const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null;
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, counts.acceptedBusinessCount) : null;
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, alertCounts.acceptedBusinessCount) : null;
try {
await this.prisma.signatureRetirementDetection.create({
data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: observing ? 'observing' : isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
});
} catch (error) {
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。
@@ -559,7 +559,7 @@
- 在“数据详单”之后增加“报表对账”一级菜单,包含“对账单”和“利润报表”两个二级菜单;页面必须读取真实 NestJS API 与 PostgreSQL 报表表,不得在前端按明细临时拼接或使用静态数据。
- 对账单按发送日期、企业、企业应用汇总日发送条数和成功条数。发送条数、成功条数均按短信计费条数 `billingUnits` 统计,成功以最终 `delivered` 状态为准。
- 利润报表按发送日期汇总日发送条数、成功条数、消费金额、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。
- 利润报表按发送日期汇总日发送条数、成功条数、收入、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。收入必须逐条按“最终成功计费条数 × 该短信发送时的客户单价快照”计算后汇总,不能按当前应用单价倒算;退款状态不改变该成功收入口径。通道维度只将收入归属到短信最终提交所在通道,补发链路不得重复计算收入。利润报表页面、筛选结果汇总和 CSV 不展示返还数据。
- 企业应用维度的消费金额只统计仍为 `charged` 的客户账单,最终失败并退款的短信不再形成收入;成本金额按每次真实提交的通道成本单价快照乘以该次提交最终成功的短信分片数计算。补发只有产生成功分片时才增加对应通道成本,失败、未知或尚未收到成功回执的分片不计成本。
- 通道维度按实际上游 `accepted` 提交统计发送量,按分片回执统计成功量和成本;客户收入只归属最终有效提交,避免补发时重复计算收入。通道成本单价必须在提交记录创建时快照,后续修改通道单价不得改写历史成本;历史缺少分片审计但存在明确成功回执时,才按该次短信计费分片数兼容计算。
- 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额使用 `0.0001 元`整数金额单位持久化并按四位小数展示。
@@ -570,6 +570,7 @@
- 平均到达时长只使用成功且时间有效的短信,按 `deliveredAt - submittedAt` 计算;每个日期、每个维度组先计算 P95,剔除大于 P95 的最慢 5% 样本后再求平均。无成功或无有效时间样本时展示为空,不以 0 冒充。
- `SmsMessageRecord` 必须固化实际使用的 `drainageInfoId`。新短信在同签名已审核通过的引流信息中按正文精确包含 URL 匹配,优先最长 URL;最长 URL 出现多个同长度候选时视为歧义并不关联。历史短信使用同一规则回填,未命中或歧义统一归入“未关联引流信息”,不得把一条短信复制到签名下所有引流信息造成重复统计。
- 发送质量报表与对账、利润报表共用 T+1 及 T-4 至 T-1 滚动重算任务,每次重算在同一日期事务内重建四个质量维度。
- 签名活跃度热力图每天均记录已报备维度的单日真实提交尝试、上游受理业务短信和最终成功业务短信;报备通过后尚未满足完整观察窗口时状态为“观察中”,仍生成热力图快照但不产生预警消息或 Webhook。热力图每行展示 T-1 至 T-30 的上游受理业务短信合计,并按合计从大到小排序;观察窗口只控制是否预警,不得隐藏真实发送数据。
- 对账单、利润报表、发送质量报表均提供导出功能。导出必须由真实 API 按页面当前筛选条件查询完整结果并生成 CSV,不得只导出当前分页或在浏览器内拼接静态数据。
- 报备字段库采用自适应卡片布局,分开展示统计概览、签名/引流信息通用字段和字段定义;卡片明确展示通道引用数及通用配置数,已被引用的字段不可删除。
- 运营端和客户端用户管理页的新增用户按钮使用标准小尺寸操作按钮,不得占用大块页面空间。
@@ -1558,11 +1559,12 @@
## 2026-07-16 全平台金额精度要求
1. 企业应用客户单价、通道成本单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数,页面及导出文件统一展示 4 位小数。
1. 企业应用客户单价、通道成本单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数,页面及导出文件统一展示 4 位小数。利润报表中的收入同样固定展示 4 位小数。
2. 数据库和计费链路继续使用整数运算,最小金额单位统一为 `0.0001 元`,即 `1 元 = 10000 金额单位`。历史字段名中的 `Cents` 为兼容既有 API 暂不改名,但其数值语义同步调整为金额单位,不再表示人民币“分”。
3. PostgreSQL 金额列统一升级为 `BIGINT`。上线迁移时既有按分保存的数据乘以 100,应用换算除数由 100 改为 10000,确保迁移前后实际人民币金额完全一致。
4. 企业应用单价修改必须写入真实 `SmsApplication.customerUnitPrice`,例如 `0.0325 元/条` 保存为 `325`;后续预估、冻结、扣费、返还和利润统计均使用该整数值,不得在前端或后端再次四舍五入到分。
5. API 返回 `BIGINT` 金额时仅在 JavaScript 安全整数范围内转换为 JSON number;超过安全整数范围必须显式报错,避免静默丢失金额精度。
6. 所有运营端和客户端的只读金额文本,整数部分保持当前文字颜色,小数点及小数部分使用更淡的次级文字颜色;金额输入框和 CSV 等纯文本载体保持原始数值格式,不拆分字符。
## 2026-07-16 企业应用接口参数复制与下游接入约束
@@ -1959,7 +1961,7 @@
## 报表筛选结果全量汇总(2026-08-09)
- 对账单、利润报表和发送质量报表在每次搜索后都必须展示当前筛选条件匹配的全部结果汇总,不得只对当前分页明细在前端求和。汇总、总数、分页和CSV导出必须复用同一套日期、企业、应用、通道及统计维度筛选口径。
- 三类报表均汇总提交、发送、未知、成功和失败条数;利润报表另汇总净消费、返还、成本和利润金额。综合成功率必须按合计成功量/合计发送量重算,综合利润率必须按合计利润/合计净消费重算,不得对每行百分比求和或简单平均;分母为0时显示0%。
- 三类报表均汇总提交、发送、未知、成功和失败条数;利润报表另汇总收入、成本和利润金额,不展示返还明细或返还合计。综合成功率必须按合计成功量/合计发送量重算,综合利润率必须按合计利润/合计收入重算,不得对每行百分比求和或简单平均;分母为0时显示0%。
- 平均到达时长不属于可加总数据,本汇总区不对各日、各维度均值再求和;明细表仍保留每组的真实P95截尾平均到达时长。
## 新建企业省份与地市字典(2026-08-09)
+1 -1
View File
@@ -169,7 +169,7 @@
预警页签命名为“预警消息”,后端按消息创建时间的北京时间日期区间查询历史消息,并在同一查询中关联检测维度、企业、企业应用、签名和通道完成筛选、计数及每页10条分页;默认区间为今日至今日。抑制和取消抑制复用平台`Modal`,临时抑制以截止日期换算为后端天数,永久抑制不传天数,两者均要求原因;取消也要求原因,不使用浏览器原生弹窗。
热力图日期列按`T-1``T-30`由新到旧排列;行首只常驻签名以及通道维度必要的通道/运营商标识,企业和企业应用通过签名悬停提示查看。企业、通道模块分别在前端对后端返回的真实维度按企业、企业应用、签名过滤并独立分页。格子悬停明确展示提交条数和最终发送成功条数,不混淆上游接受。
热力图日期列按`T-1``T-30`由新到旧排列;行首只常驻签名以及通道维度必要的通道/运营商标识,企业和企业应用通过签名悬停提示查看。企业、通道模块分别在前端对后端返回的真实维度按企业、企业应用、签名过滤并独立分页。每行增加30日上游受理业务短信合计并按合计降序排列。格子悬停明确展示提交条数和最终发送成功条数,不混淆上游接受。报备后的完整观察窗口只控制清退预警资格,观察期仍按日生成`observing`快照并展示真实发送量,不创建预警周期、站内消息或Webhook。
页面底部增加独立的未报备签名查询。后端按所选北京时间自然日,以`SmsMessageRecord`为业务短信事实,按短信运营商检查该签名是否存在任一未删除通道上的当前运营商级`approved`任务;仍处于`approved`的历史通道级兼容任务视为已有真实旧报备,避免迁移期误报。其余按签名和消息实际企业应用聚合,后端完成关键字过滤、总数和分页,前端不使用静态数据或全量拉取伪分页。
+8 -3
View File
@@ -3456,7 +3456,7 @@ npm run verify:phase8
| 用例 | 细化执行点 | 必查断言 |
| --- | --- | --- |
| TC-REPORT-001 | 在同一发送日准备多个企业和应用的单条、长短信,覆盖 delivered、failed、unknown;次日执行报表刷新并按日期、企业、应用查询对账单。 | 只生成 T-1 及更早完整日期;发送和成功均按 `billingUnits` 汇总;成功只包含最终 delivered;企业与应用隔离正确;API 使用 PostgreSQL 报表表和服务端分页。 |
| TC-REPORT-002 | 准备短短信成功、三分片长短信仅两片成功、最终失败退款、同通道成功和跨通道补发成功短信,分别按企业应用和通道查看利润报表。 | 企业应用消费只包含 charged;退款不算收入;成本严格等于各次提交的通道成本单价快照乘以该次成功分片数,失败和未知分片成本为0;通道维度收入只归属最终提交且不重复;利润=消费-成本,利润率计算正确,收入为0时显示0%。 |
| TC-REPORT-002 | 准备不同客户单价的短短信成功、三分片长短信仅两片成功、最终失败退款、同通道成功和跨通道补发成功短信,分别按企业应用和通道查看利润报表。 | 收入逐条等于最终成功计费条数×该短信发送时的客户单价快照后汇总,不受 charged/refunded 状态切换影响;失败和未知短信不计收入;成本严格等于各次提交的通道成本单价快照乘以该次成功分片数,失败和未知分片成本为0;通道维度收入只归属最终提交且不重复;利润=收入-成本,利润率计算正确,收入为0时显示0%;页面、汇总与 CSV 均无返还字段。 |
| TC-REPORT-003 | 首次生成后,在 T-3 短信上补录 delivered 回执并将另一条 T-2 短信最终失败退款,再执行次日定时刷新。 | 每次刷新准确覆盖 T-4、T-3、T-2、T-1;对应日期旧行在事务内重建,成功数、消费、利润同步修正;T-5 及更早报表不被本次任务改写。 |
| TC-REPORT-004 | 先按成本价发送并 accepted,再修改通道单价,随后生成和重复刷新报表。 | `SmsSubmitRecord.costUnitPrice/costAmountCents` 保存提交时快照;历史成本不随通道当前单价变化;新提交使用新单价。 |
| TC-REPORT-005 | 打开运营端菜单和两张报表,切换日期、企业、应用及通道维度并翻页。 | “报表对账”位于“数据详单”之后且包含两个二级菜单;筛选和分页调用真实 `/admin/reports/*` API;页面展示生成时间及 T+1/T-4~T-1 口径,不使用 mock、静态数组或 localStorage 数据。 |
@@ -3650,7 +3650,7 @@ npm run verify:phase8
| TC-MONEY-001 | 运营端编辑企业应用,将客户单价填写为 `0.0325` 并保存,刷新列表后再次进入编辑页。 | 保存调用真实 NestJS API;数据库 `SmsApplication.customerUnitPrice=325`;列表和编辑页均展示 `0.0325`,不被舍入为 `0.03`。 |
| TC-MONEY-002 | 使用单价 `0.0325` 的应用发送 2 个计费条数。 | 预估、冻结及最终计费金额均为 `650` 金额单位,即 `0.0650 元`;返还时按相同精度原额冲回。 |
| TC-MONEY-003 | 分别设置余额、授信、充值、今日消费和今日返还为含 4 位小数的金额,查看运营端企业列表、详情、首页以及客户端首页和账单。 | 所有位置展示同一真实金额且固定为 4 位小数,不使用浮点累计或仅保留到分。 |
| TC-MONEY-004 | 打开短信详单、对账单、利润报表并导出 CSV。 | 消费金额、成本金额和利润均按 4 位小数显示;CSV 表头以“元”为单位,值固定 4 位小数,汇总结果与数据库整数金额单位一致。 |
| TC-MONEY-004 | 打开短信详单、对账单、利润报表并导出 CSV。 | 短信详单消费金额以及利润报表收入、成本和利润均按 4 位小数显示;CSV 表头以“元”为单位,值固定 4 位小数,汇总结果与数据库整数金额单位一致。 |
| TC-MONEY-005 | 在迁移前备份数据库并记录各金额列汇总,执行四位精度迁移后复核字段类型及汇总。 | 金额列升级为 `BIGINT`;迁移后整数汇总等于迁移前的 100 倍,按新除数换算后的人民币金额完全相等。 |
| TC-MONEY-006 | 在单价、授信和充值输入中分别填写超过 4 位小数、非法字符和超出 JavaScript 安全整数范围的值。 | 前后端拒绝无效值并返回可读错误;API 不静默舍入或输出已失真的金额。 |
| TC-IF-PARAM-001 | 运营端分别打开已开通 CMPP、HTTP 的企业应用参数弹窗并一键复制;模拟 Clipboard API 在 HTTP 页面被拒绝。 | CMPP 内容展示平台公网地址和端口而非上游通道地址;HTTP 内容包含应用、能力、QPS、白名单、投递模式和文档地址;降级复制成功且有明确提示。 |
@@ -4475,7 +4475,7 @@ npm run verify:phase8
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-REPORT-SUMMARY-001 | 在对账单准备超过一页的多日、多企业应用数据,分别按日期、企业和应用搜索并翻页 | 顶部提交、发送、未知、成功、失败合计等于PostgreSQL中全部筛选结果;翻页不改变汇总,改变筛选条件后同步刷新 |
| TC-REPORT-SUMMARY-002 | 在利润报表分别选择企业应用和通道维度,准备多行金额且至少一行收入为0 | 量类与金额类均按完整筛选结果求和;综合利润率=合计利润/合计净消费,不是行利润率求和或平均,合计收入为0时为0% |
| TC-REPORT-SUMMARY-002 | 在利润报表分别选择企业应用和通道维度,准备多行金额且至少一行收入为0 | 量类与金额类均按完整筛选结果求和;只展示收入、成本和利润金额合计,不展示返还合计;综合利润率=合计利润/合计收入,不是行利润率求和或平均,合计收入为0时为0% |
| TC-REPORT-SUMMARY-003 | 在发送质量报表的企业应用、通道、签名、引流信息四个Tab分别搜索和翻页 | 汇总条数来自真实后端全量聚合;综合成功率=合计成功/合计发送,不累加或平均各行成功率;汇总区不对平均到达时长求和 |
| TC-REPORT-SUMMARY-004 | 调用三个报表列表API,对比`items`当前页、`total``summary`和相同条件CSV | `summary`与CSV完整结果口径一致且不受`page/pageSize`影响;无匹配数据时所有合计和综合率均为0 |
@@ -4541,6 +4541,11 @@ npm run verify:phase8
| TC-SIGNATURE-RETIREMENT-016 | 检查顶部任务入口和预警入口 | 原待审核入口改为任务图标但计数、弹层和跳转完整;新增预警铃铛进入预警列表,两个计数互不混用 |
| TC-SIGNATURE-RETIREMENT-017 | 分别在北京时间04:00前后、08:00前后运行自动任务,并模拟服务跨过两个时点后重启 | 04:00只生成幂等检测快照且冻结规则版本和消息正文,不产生站内消息/Webhook;08:00才幂等创建站内消息并生成Webhook投递;晚启动按时点顺序补偿且不重复;页面和管理API均不存在手动检测入口 |
| TC-SIGNATURE-RETIREMENT-018 | 打开签名质量检测页,并分别翻动企业、通道热力图 | “签名通道发送质量”位于两张热力图之前;两张热力图各按10个维度分页,页码相互独立,翻页不改变另一张页码,30日列仍可横向滚动且数据与真实API一致 |
| TC-SIGNATURE-RETIREMENT-019 | 签名刚报备通过、尚未满足观察窗口,次日有真实发送数据并执行04:00检测 | 生成状态为“观察中”的单日检测快照,热力图展示真实受理条数,但不创建预警周期、站内消息或Webhook;满足观察窗口后才按窗口累计量判断预警 |
| TC-SIGNATURE-RETIREMENT-020 | 准备多个签名维度的T-1至T-30快照且合计不同,打开企业和通道热力图 | 每行展示30日受理短信合计,按合计降序排列;分页基于排序后的结果,单元格仍展示各自然日数据 |
| TC-UI-MONEY-001 | 遍历运营端和客户端包含余额、单价、消费、返还、充值、成本、收入和利润的页面 | 只读金额整数部分沿用主文字颜色,小数点及小数部分使用统一淡色;负号、币种符号和单位位置正确,输入框、复制值和CSV仍为完整纯文本数值 |
| TC-CLIENT-LOGIN-ANIMATION-001 | 打开客户端登录页并保持页面可见,再切换后台或启用减少动态效果 | Canvas动画在登录框背景平滑运行、不遮挡表单、不响应敏感输入;页面隐藏或组件卸载时停止帧循环,减少动态效果下显示静态背景 |
| TC-ENTERPRISE-SIGNATURE-STYLE-001 | 打开企业签名管理列表 | 企业名称和企业应用名称使用常规字重,签名名称及状态层级保持原样 |
| TC-SIGNATURE-RETIREMENT-019 | 检查两张热力图日期、行首和悬停信息 | 日期从左到右为`T-1``T-30`;行首不常驻企业和企业应用,悬停签名可看到企业、企业应用;通道维度仍能识别通道和运营商 |
| TC-SIGNATURE-RETIREMENT-020 | 分别在企业、通道热力图搜索企业、企业应用、签名并清空 | 每张热力图只过滤自身真实维度并回到第一页;三类关键字均可命中,清空恢复,另一张热力图的关键字和页码不变 |
| TC-SIGNATURE-RETIREMENT-021 | 悬停报备前、无快照、零量和非零量格子 | 报备前显示不适用;无快照说明当日无检测;真实快照明确显示提交条数、上游接受条数、发送成功条数和成功率,发送成功等于真实最终送达而非受理成功 |
+17
View File
@@ -3463,3 +3463,20 @@ git diff --check
- 发布前有61条`legacy_channel`任务和64个缺失运营商目标;发布后`legacy_channel=0`,新增`legacy_carrier_auto_split=64``legacy_scope_auto_split=61`条migration轨迹。16条由历史已通过任务新建的运营商任务`approvedAt`统一为北京时间`2026-08-10 22:56:14.239`且空值为0;原有运营商级任务未覆盖,历史任务全部保留为`legacy_split`
- API、Gateway、Nginx、PostgreSQL和MinIO均activeAPI/Gateway/MinIO健康、Redis PONG、Stream消费者1、`pending=0``lag=0`,运营端、客户端和公网API health均HTTP 200;部署后API/Gateway error和warning级日志为0,运行源码和前端产物均不存在`legacy-report-tasks`或“历史待确认”标记。
- 9条活动通道重启恢复后6条`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”继续为发布前已知的供应商`authentication`失败。本轮未修改通道账号、密码、启停状态、企业余额或客户连接,没有手工发送、补发或重投短信,也没有修改Webhook。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于业务源码提交。
## 2026-08-12 利润报表收入口径调整(未提交、未发布)
- 利润报表“净消费”统一更名为“收入”。收入按每条最终成功短信的`billingUnits × unitPrice`发送时快照逐条计算后汇总,失败和未知短信不计收入;不再以`SmsBillingRecord.billingStatus=charged/refunded`决定利润报表收入。不同历史单价必须分别计算,不能使用当前应用单价倒算。
- 通道维度继续仅将收入归属到短信最终提交所在通道,避免补发链路在多个通道重复计收;成本仍按各次提交的通道成本单价快照乘以成功分片数,利润=收入-成本,综合利润率=合计利润/合计收入。
- 页面明细、筛选结果汇总、前端类型、列表API和CSV均移除返还数据;CSV表头改为“收入金额(元)”。数据库`DailyProfitReport.refundCents`暂时保留作既有数据和回滚兼容,新重算快照统一写0,不执行破坏性migration。
- 本地PostgreSQL和Redis恢复后,使用正式`ReportsService`成功重算2026-08-08至2026-08-11。独立SQL按应用逐条复核`成功计费条数 × 客户单价快照`,与报表收入差异0条,重算行`refundCents`非0差异0条;本地现有成功样本单价均为0,非零及混合单价场景由专项自动化断言覆盖,未伪造数据库样本。
- 报表专项9/9、API全量35个suite/450项通过;前端TypeScript、API TypeScript正式构建及Vite 8.1.5生产构建通过,Vite仅保留既有大chunk提示。依赖包装器因既有`msgpackr-extract`构建脚本未审批而未用于验证,改为直接调用已安装的本地Jest、TypeScript和Vite入口,未修改依赖审批或供应链配置。
- 本轮未提交、未推送、未部署,未连接或修改预生产数据,未发送、补发或重投短信。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于本需求;依赖包装器临时生成的`pnpm-lock.yaml`已精确移除。
## 2026-08-12 热力图观察期、登录动画与金额样式(待发布)
- 修复签名报备通过后完整观察窗口内不生成快照的问题:04:00检测现在按T-1自然日保存单日提交、受理和成功量,观察期状态为`observing`,不创建预警周期、站内消息或Webhook;观察期结束后仍使用配置的15/30天窗口累计量判断预警。热力图将检测日映射到T-1活动日,每行增加30日受理短信合计并按合计降序排序。
- 企业签名管理列表中的企业、企业应用名称改为常规400字重;签名名称和状态层级不变。客户端登录页增加纯展示Canvas粒子连线动画,Canvas不接收点击、不读取输入,组件卸载时取消动画帧,系统减少动态效果或页面隐藏时停止位移。
- 新增统一`MoneyText`只读金额组件,运营端和客户端现有余额、授信、单价、消费、返还、充值、收入、成本、利润及短信计费等金额,小数点和小数部分使用统一次级文字色;输入框、CSV、复制文本和底层金额值不拆分、不改变。
- 本地正式`SignatureRetirementService`在真实PostgreSQL执行2026-08-12检测,生成11条alert、2条healthy、6条observing快照;执行前后站内消息均55条、Webhook投递均0条,证明检测阶段不外发。浏览器真实API验收热力图首列为08-11、显示30日合计且合计491/134/65/0按降序,6个观察期格子可见;企业与应用字重为400;金额小数色为`rgb(107, 114, 128)`;客户端登录Canvas为2560×1440并正常绘制,页面控制台error/warn为0。
- API全量35个suite/451项、签名清退与利润专项18/18项、前后端TypeScript、API正式构建、Vite 8.1.5生产构建、4份Gateway队列契约、Gateway `go test ./...``go vet ./...`通过;Vite仅保留既有约2.06MB单chunk提示,`git diff --check`仅有既有LF/CRLF提示。
-2
View File
@@ -361,7 +361,6 @@ export type ReconciliationReportSummary = ReportVolumeSummary;
export type ProfitReportSummary = ReportVolumeSummary & {
revenueCents: number;
refundCents: number;
costCents: number;
profitCents: number;
profitRateBps: number;
@@ -387,7 +386,6 @@ export type DailyProfitReport = {
successUnits: number;
failedUnits: number;
revenueCents: number;
refundCents: number;
costCents: number;
profitCents: number;
profitRateBps: number;
+2 -1
View File
@@ -42,7 +42,7 @@ export type SignatureRetirementDetection = {
acceptedBusinessCount: number;
deliveredBusinessCount: number;
approvedAt: string;
status: 'alert' | 'healthy';
status: 'alert' | 'healthy' | 'observing';
suppressed: boolean;
};
@@ -76,6 +76,7 @@ export type SignatureRetirementSuppression = {
};
export type SignatureRetirementHeatmapItem = SignatureRetirementDetection & {
activityDate: string;
signatureName?: string;
channelName?: string | null;
tenantName?: string;
+2 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session';
import { Button, Input, Modal } from '@/components/ui';
import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui';
type LoginPageProps = {
portal: Portal;
@@ -75,6 +75,7 @@ export function LoginPage({ portal }: LoginPageProps) {
return (
<main className="login-page">
{!isAdmin ? <ClientLoginCanvas /> : null}
<section className="login-panel">
<div className="login-brand">
<img alt={isAdmin ? 'CMPP 运营端 logo' : 'CMPP 客户端 logo'} src="/logo/logo1.png" />
+7 -4
View File
@@ -292,6 +292,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
@@ -304,11 +305,12 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
}));
total: dates.reduce((sum, dateKey) => sum + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)?.acceptedBusinessCount ?? 0), 0),
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.detectionDate.slice(0, 10)}`, item]));
useEffect(() => {
setPage(1);
@@ -336,7 +338,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
<div className="signature-retirement-heatmap__scroll">
<table>
<thead>
<tr><th></th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
<tr><th></th><th>30</th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
</thead>
<tbody>
{pagedRows.map((row) => (
@@ -348,12 +350,13 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
</span>
<Tag tone="neutral">{carrierLabels[row.carrier] ?? row.carrier}</Tag>
</th>
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
{dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt;
const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0;
const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`;
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n预警阈值:${item.threshold}` : '当日无检测快照';
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold}` : '当日无检测快照';
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>;
})}
</tr>
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, FileText, MessageSquare, Server } from 'lucide-react';
import { adminApi, type ClientSmsSignature, type ClientSmsTemplate, type EnterpriseApplication, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
export function AdminCustomerDetailPage() {
@@ -68,8 +68,8 @@ export function AdminCustomerDetailPage() {
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card"><Server size={22} /><div><span></span><strong>{tenant?.code ?? '-'}</strong><small>{tenant?.status ?? '-'}</small></div></div>
<div className="surface mini-status-card"><MessageSquare size={22} /><div><span></span><strong></strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.balanceCents)}</strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.creditCents)}</strong><small> 0</small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong><MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong><MoneyText>¥{formatCents(account?.creditCents)}</MoneyText></strong><small> 0</small></div></div>
</div>
<div className="surface section-stack">
+6 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, MoneyText, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
type AdminCustomersPageProps = {
@@ -85,15 +85,15 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const balance = record.account?.balanceCents ?? 0;
return (
<span className={balance < 0 ? 'status-danger' : ''}>
¥{formatCents(balance)}
<MoneyText>¥{formatCents(balance)}</MoneyText>
{balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag"></Tag> : null}
</span>
);
},
},
{ key: 'creditLimit', title: '授信额度', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.account?.creditCents ?? 0)}` },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todaySpendCents)}` },
{ key: 'todayRefund', title: '今日返还', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todayRefundCents)}` },
{ key: 'creditLimit', title: '授信额度', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.account?.creditCents ?? 0)}</MoneyText> },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.todaySpendCents)}</MoneyText> },
{ key: 'todayRefund', title: '今日返还', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.todayRefundCents)}</MoneyText> },
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{
key: 'actions',
@@ -160,7 +160,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<div className="surface mini-status-card"><Building2 size={22} /><div><span></span><strong>{records.length}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingUp size={22} /><div><span></span><strong>{activeCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingDown size={22} /><div><span></span><strong>{disabledCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong>¥{formatCents(totalBalance)}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong><MoneyText>¥{formatCents(totalBalance)}</MoneyText></strong><small></small></div></div>
</div>
<div className="surface ui-query-panel">
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Check, FileSearch, Search, X } from 'lucide-react';
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, MoneyText, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
@@ -194,7 +194,7 @@ export function AdminEnterpriseAuditPage() {
<div><span></span><strong>{detailRecord.bankAccountName}</strong></div>
<div><span></span><strong>{detailRecord.bankName}</strong></div>
<div><span></span><strong>{detailRecord.bankAccountNo}</strong></div>
<div><span></span><strong>{detailRecord.verificationAmount}</strong></div>
<div><span></span><strong><MoneyText>{detailRecord.verificationAmount}</MoneyText></strong></div>
</section>
<section>
<h3></h3>
+4 -3
View File
@@ -11,6 +11,7 @@ import {
Button,
Chart,
Modal,
MoneyText,
Pagination,
Table,
Tag,
@@ -130,7 +131,7 @@ export function AdminHome() {
</div>
),
},
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText> },
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
{
@@ -205,7 +206,7 @@ export function AdminHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<strong><MoneyText>¥{formatCurrency(todaySpend)}</MoneyText></strong>
<small></small>
</div>
<div className="surface metric-card">
@@ -372,7 +373,7 @@ export function AdminHome() {
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>¥{formatCurrency(selectedEnterprise.todaySpend)}</strong>
<strong><MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText></strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type ProfitReportSummary, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, MoneyText, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
@@ -78,19 +78,19 @@ export function AdminProfitReportsPage() {
<div className="admin-report-summary__heading"><strong></strong><span></span></div>
<div className="admin-report-summary__grid">{[
['提交合计', summary.submittedUnits.toLocaleString('zh-CN')], ['发送合计', summary.sentUnits.toLocaleString('zh-CN')], ['未知合计', summary.unknownUnits.toLocaleString('zh-CN')], ['成功合计', summary.successUnits.toLocaleString('zh-CN')], ['失败合计', summary.failedUnits.toLocaleString('zh-CN')],
['净消费合计', `¥${formatCents(summary.revenueCents)}`], ['返还合计', `¥${formatCents(summary.refundCents)}`], ['成本合计', `¥${formatCents(summary.costCents)}`], ['利润合计', `¥${formatCents(summary.profitCents)}`], ['综合利润率', `${(summary.profitRateBps / 100).toFixed(2)}%`],
].map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
['收入合计', `¥${formatCents(summary.revenueCents)}`], ['成本合计', `¥${formatCents(summary.costCents)}`], ['利润合计', `¥${formatCents(summary.profitCents)}`], ['综合利润率', `${(summary.profitRateBps / 100).toFixed(2)}%`],
].map(([label, value]) => <div key={label}><span>{label}</span><strong>{label.includes('合计') && ['收入合计', '成本合计', '利润合计'].includes(label) ? <MoneyText>{value}</MoneyText> : value}</strong></div>)}</div>
</div>
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={13}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={13}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={13}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={12}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={12}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={12}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td><MoneyText>¥{formatCents(row.revenueCents)}</MoneyText></td><td><MoneyText>¥{formatCents(row.costCents)}</MoneyText></td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}><MoneyText>¥{formatCents(row.profitCents)}</MoneyText></td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
@@ -100,7 +100,7 @@ export function AdminProfitReportsPage() {
);
}
const emptyProfitSummary: ProfitReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 };
const emptyProfitSummary: ProfitReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 };
function defaultDateRange(): DateRangeValue {
const end = new Date();
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Plus, ReceiptText, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, RechargeReceiptDialog, Tag, type DateRangeValue } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, MoneyText, Pagination, RechargeReceiptDialog, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatCents } from '@/utils/currency';
@@ -123,8 +123,8 @@ export function AdminRechargeRecordsPage() {
<tr key={record.id}>
<td><strong>{tenantName}</strong></td>
<td>{formatDateTime(record.paidAt ?? record.createdAt)}</td>
<td>¥{formatCents(record.amountCents)}</td>
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatCents(record.balanceAfterCents)}`}</td>
<td><MoneyText>¥{formatCents(record.amountCents)}</MoneyText></td>
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : <MoneyText>¥{formatCents(record.balanceAfterCents)}</MoneyText>}</td>
<td><Tag tone="warning"></Tag></td>
<td><RemarkCell value={record.remark ?? undefined} /></td>
<td>
+2 -2
View File
@@ -1,5 +1,5 @@
import { Copy, Eye, FileText, Pencil, Power, Send } from 'lucide-react';
import { DeleteRiskAction, Pagination, Tag } from '@/components/ui';
import { DeleteRiskAction, MoneyText, Pagination, Tag } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { successRateClassName } from '@/utils/successRate';
import { carrierLabelMap, carrierToneMap, statusLabelMap, statusToneMap } from './channelModel';
@@ -58,7 +58,7 @@ export function ChannelTable({
</div>
<div className="sms-channel-carrier-price">
<div>{channel.carriers.map((carrier) => <Tag key={carrier} tone={carrierToneMap[carrier]}>{carrierLabelMap[carrier]}</Tag>)}</div>
<strong>{formatCents(channel.unitPrice)} </strong>
<strong><MoneyText>{formatCents(channel.unitPrice)} </MoneyText></strong>
</div>
<div className="sms-channel-status-cell">
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Edit3, Settings2, Trash2 } from 'lucide-react';
import { Button, Pagination, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { Button, MoneyText, Pagination, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { formatAmount } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
import type { ConfirmAction, SmsApp } from './applicationTypes';
@@ -56,7 +56,7 @@ export function EnterpriseApplicationTable({
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` },
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)}` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => <MoneyText>{formatAmount(record.unitPrice)} </MoneyText> },
{
key: 'cmppStatus',
title: '客户连接状态',
@@ -60,8 +60,8 @@ export function EnterpriseSignaturesTable({
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div><span></span><strong>{signature.name}</strong></div>
<div><span></span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
<div><span></span><strong>{signature.application?.name ?? '-'}</strong></div>
<div><span></span><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
<div><span></span><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
<div><span></span><AuditStatusTag status={signature.auditStatus} /></div>
<div><span></span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
<div><span></span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { Download } from 'lucide-react';
import type { ReactNode } from 'react';
import type { SmsMessageRecord } from '@/api/adminApi';
import { Button, Pagination } from '@/components/ui';
import { Button, MoneyText, Pagination } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import {
getCarrierLabel,
@@ -85,7 +85,7 @@ export function SmsRecordList({
</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
<div><span></span><strong>{record.billingUnits} / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.billingUnits} / <MoneyText>¥{formatCents(record.amountCents)}</MoneyText></strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
</div>
<footer><button className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} type="button"></button></footer>
+2 -6
View File
@@ -3,7 +3,7 @@ import { ClipboardCopy, FileText } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
import { Button, Modal, Pagination, Tag } from '@/components/ui';
import { Button, Modal, MoneyText, Pagination, Tag } from '@/components/ui';
import { copyText } from '@/utils/clipboard';
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
@@ -35,10 +35,6 @@ function normalizeStatus(application: ClientSmsApplication): LinkStatus {
return application.cmppStatus ?? 'inactive';
}
function formatPrice(cents?: number | null) {
return `${formatCents(cents)}`;
}
function mapParams(params: ApplicationCmppParams): ParamRow[] {
return [
{ label: 'ID', value: params.applicationId },
@@ -157,7 +153,7 @@ export function ClientApplicationsPage() {
</div>
<div>
<dt></dt>
<dd>{formatPrice(application.customerUnitPrice)}</dd>
<dd><MoneyText>{formatCents(application.customerUnitPrice)} </MoneyText></dd>
</div>
<div>
<dt>CMPP连接状态</dt>
+4 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { WalletCards } from 'lucide-react';
import { Pagination, Tag } from '@/components/ui';
import { MoneyText, Pagination, Tag } from '@/components/ui';
import { clientApi, type RechargeOrder } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
@@ -40,11 +40,11 @@ export function ClientBillingPage() {
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong>¥{formatCents(balanceCents)}</strong><small></small></div>
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents)}</MoneyText></strong><small></small></div>
</div>
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong>¥{formatCents(balanceCents + creditCents)}</strong><small> 0 </small></div>
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents + creditCents)}</MoneyText></strong><small> 0 </small></div>
</div>
</div>
<div className="surface section-stack">
@@ -60,7 +60,7 @@ export function ClientBillingPage() {
<tr key={order.id}>
<td><span className="table-mono-id">{order.orderNo}</span></td>
<td>{formatDateTime(order.paidAt ?? order.createdAt)}</td>
<td>¥{formatCents(order.amountCents)}</td>
<td><MoneyText>¥{formatCents(order.amountCents)}</MoneyText></td>
<td><Tag tone={order.status === 'paid' ? 'success' : 'warning'}>{order.status === 'paid' ? '已入账' : order.status}</Tag></td>
<td>{order.remark || '-'}</td>
</tr>
+6 -6
View File
@@ -10,7 +10,7 @@ import {
WalletCards,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import { Button, Chart, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
import { formatDateTime } from '@/utils/dateTime';
@@ -101,8 +101,8 @@ export function ClientHome() {
<div className="dashboard-grid dashboard-grid--four">
<div className="surface metric-card metric-card--featured">
<span></span>
<strong>¥{formatAmount(availableBalance)}</strong>
<small> ¥{formatCents(account?.balanceCents)}</small>
<strong><MoneyText>¥{formatAmount(availableBalance)}</MoneyText></strong>
<small> <MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></small>
</div>
<div className="surface metric-card">
<span></span>
@@ -111,12 +111,12 @@ export function ClientHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCents(dashboard?.today.spendCents)}</strong>
<strong><MoneyText>¥{formatCents(dashboard?.today.spendCents)}</MoneyText></strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCents(todayRefundCents)}</strong>
<strong><MoneyText>¥{formatCents(todayRefundCents)}</MoneyText></strong>
<small>退</small>
</div>
</div>
@@ -175,7 +175,7 @@ export function ClientHome() {
</div>
<div>
<span></span>
<strong>{latestRecharge ? `¥${formatCents(latestRecharge.amountCents)}` : '暂无充值'}</strong>
<strong>{latestRecharge ? <MoneyText>¥{formatCents(latestRecharge.amountCents)}</MoneyText> : '暂无充值'}</strong>
</div>
</div>
<div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { Button, DateTimeInput, Input, Modal, MoneyText, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
import { formatCents } from '@/utils/currency';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
@@ -419,7 +419,7 @@ export function ClientSendPage() {
</div>
<div>
<span></span>
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / </strong>
<strong><MoneyText>¥{formatCents(selectedApplication?.customerUnitPrice)} / </MoneyText></strong>
</div>
</div>
<div className="preview-note"> 70 / 67 /</div>
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
type Particle = { x: number; y: number; vx: number; vy: number; radius: number; alpha: number };
export function ClientLoginCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return undefined;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let particles: Particle[] = []; let frame = 0; let width = 0; let height = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect(); const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width; height = rect.height; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); context.setTransform(ratio, 0, 0, ratio, 0, 0);
const count = Math.max(18, Math.min(42, Math.round(width / 34)));
particles = Array.from({ length: count }, (_, index) => ({ x: (index * 83.7) % Math.max(width, 1), y: (index * 47.3) % Math.max(height, 1), vx: 0.08 + (index % 4) * 0.025, vy: (index % 3 - 1) * 0.035, radius: 1.2 + (index % 3) * 0.45, alpha: 0.18 + (index % 5) * 0.045 }));
};
const draw = () => {
context.clearRect(0, 0, width, height);
for (const particle of particles) {
if (!reduceMotion.matches && !document.hidden) { particle.x = (particle.x + particle.vx + width) % width; particle.y = (particle.y + particle.vy + height) % height; }
context.beginPath(); context.fillStyle = `rgba(59, 130, 246, ${particle.alpha})`; context.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); context.fill();
}
for (let left = 0; left < particles.length; left += 1) for (let right = left + 1; right < particles.length; right += 1) {
const dx = particles[left].x - particles[right].x; const dy = particles[left].y - particles[right].y; const distance = Math.hypot(dx, dy);
if (distance <= 105) { context.beginPath(); context.strokeStyle = `rgba(59, 130, 246, ${0.09 * (1 - distance / 105)})`; context.moveTo(particles[left].x, particles[left].y); context.lineTo(particles[right].x, particles[right].y); context.stroke(); }
}
frame = window.requestAnimationFrame(draw);
};
const observer = new ResizeObserver(resize); observer.observe(canvas); resize(); draw();
return () => { observer.disconnect(); window.cancelAnimationFrame(frame); };
}, []);
return <canvas aria-hidden="true" className="client-login-canvas" ref={canvasRef} />;
}
+5 -4
View File
@@ -5,6 +5,7 @@ import { createUuid } from '@/utils/randomId';
import { Button } from './Button';
import { Input } from './Input';
import { Modal } from './Modal';
import { MoneyText } from './MoneyText';
import { Select } from './Select';
import { Textarea } from './Textarea';
@@ -130,7 +131,7 @@ export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onCl
<div className="manual-recharge-result" role="status">
<strong>{result.amountCents > 0 ? '充值已入账' : '余额冲正已入账'}</strong>
<span>{result.orderNo}</span>
<span>¥{formatCents(result.balanceAfterCents)}</span>
<span><MoneyText>¥{formatCents(result.balanceAfterCents)}</MoneyText></span>
<span>{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span>
</div>
) : review ? (
@@ -139,9 +140,9 @@ export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onCl
<dl>
<div><dt></dt><dd><strong>{review.tenant.name}</strong><span>{review.tenant.code} · {review.tenant.id}</span></dd></div>
<div><dt></dt><dd>{review.direction === 'topup' ? '余额充值' : '余额冲正'}</dd></div>
<div><dt></dt><dd>¥{formatCents(review.balanceCents)}</dd></div>
<div><dt></dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</dd></div>
<div><dt></dt><dd><strong>¥{formatCents(review.balanceAfterCents)}</strong></dd></div>
<div><dt></dt><dd><MoneyText>¥{formatCents(review.balanceCents)}</MoneyText></dd></div>
<div><dt></dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}><MoneyText>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</MoneyText></dd></div>
<div><dt></dt><dd><strong><MoneyText>¥{formatCents(review.balanceAfterCents)}</MoneyText></strong></dd></div>
</dl>
{remark.trim() ? <p className="manual-recharge-review__remark"><strong></strong>{remark.trim()}</p> : null}
</div>
+8
View File
@@ -0,0 +1,8 @@
import { Children, type ReactNode } from 'react';
export function MoneyText({ children, className }: { children: ReactNode; className?: string }) {
const text = Children.toArray(children).map((value) => typeof value === 'string' || typeof value === 'number' ? String(value) : '').join('');
const match = text.match(/^(.*?)(\.\d+)(\s*[^\d]*)$/);
if (!match) return <span className={className}>{text}</span>;
return <span className={className}>{match[1]}<span className="money-text__fraction">{match[2]}</span>{match[3]}</span>;
}
+4 -3
View File
@@ -4,6 +4,7 @@ import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
import { Button } from './Button';
import { Modal } from './Modal';
import { MoneyText } from './MoneyText';
type RechargeReceiptDialogProps = {
open: boolean;
@@ -57,7 +58,7 @@ export function RechargeReceiptDialog({
<strong>
{isCorrection ? '' : '+'}
<small>¥</small>
{formatReceiptAmount(record.amountCents)}
<MoneyText>{formatReceiptAmount(record.amountCents)}</MoneyText>
</strong>
</section>
@@ -78,11 +79,11 @@ export function RechargeReceiptDialog({
</div>
<div>
<dt></dt>
<dd>{balanceBefore === null ? '-' : `¥${formatCents(balanceBefore)}`}</dd>
<dd>{balanceBefore === null ? '-' : <MoneyText>¥{formatCents(balanceBefore)}</MoneyText>}</dd>
</div>
<div>
<dt></dt>
<dd>{balanceAfter === null || balanceAfter === undefined ? '-' : `¥${formatCents(balanceAfter)}`}</dd>
<dd>{balanceAfter === null || balanceAfter === undefined ? '-' : <MoneyText>¥{formatCents(balanceAfter)}</MoneyText>}</dd>
</div>
<div>
<dt></dt>
+2
View File
@@ -14,6 +14,8 @@ export type { ManualRechargeTarget } from './ManualRechargeDialog';
export { RechargeReceiptDialog } from './RechargeReceiptDialog';
export { Input } from './Input';
export { Modal } from './Modal';
export { MoneyText } from './MoneyText';
export { ClientLoginCanvas } from './ClientLoginCanvas';
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
export { Select } from './Select';
export { Table } from './Table';
+29
View File
@@ -2010,6 +2010,22 @@
padding-right: 8px;
}
.signature-summary .signature-summary__regular-value {
color: var(--color-text-strong);
font-weight: 400;
}
.money-text__fraction {
color: var(--color-text-muted);
font-size: 0.92em;
}
.signature-retirement-heatmap__total {
color: var(--color-text-strong);
font-weight: 700;
background: var(--color-surface-muted);
}
.carrier-report-summary {
align-items: flex-start;
display: inline-flex;
@@ -3300,6 +3316,8 @@
}
.login-page {
position: relative;
isolation: isolate;
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -3312,7 +3330,18 @@
var(--color-bg);
}
.client-login-canvas {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.login-panel {
position: relative;
z-index: 1;
box-sizing: border-box;
max-width: 428px;
min-width: 0;
@@ -0,0 +1,58 @@
#!/usr/bin/env node
require('../../api/node_modules/reflect-metadata');
const { PrismaService } = require('../../api/dist/prisma/prisma.service');
const { SignatureRetirementService } = require('../../api/dist/signature-retirement/signature-retirement.service');
const DAY_MS = 86_400_000;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function parseDateKey(value, name) {
if (!DATE_PATTERN.test(value || '')) throw new Error(`${name} must use YYYY-MM-DD`);
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) throw new Error(`${name} is invalid`);
return date;
}
function dateKey(date) {
return date.toISOString().slice(0, 10);
}
async function main() {
const [fromValue, toValue] = process.argv.slice(2);
const from = parseDateKey(fromValue, 'from');
const to = parseDateKey(toValue, 'to');
const days = Math.round((to.getTime() - from.getTime()) / DAY_MS) + 1;
if (days < 1 || days > 31) throw new Error('date range must contain 1 to 31 days');
const prisma = new PrismaService();
const service = new SignatureRetirementService(prisma);
await prisma.$connect();
try {
const before = await Promise.all([
prisma.signatureRetirementMessage.count(),
prisma.signatureRetirementWebhookDelivery.count(),
]);
for (let offset = 0; offset < days; offset += 1) {
const current = new Date(from.getTime() + offset * DAY_MS);
const result = await service.runDetection(dateKey(current));
process.stdout.write(`${dateKey(current)} ${JSON.stringify(result)}\n`);
}
const after = await Promise.all([
prisma.signatureRetirementMessage.count(),
prisma.signatureRetirementWebhookDelivery.count(),
]);
if (before[0] !== after[0] || before[1] !== after[1]) {
throw new Error(`notification side effect detected: messages ${before[0]} -> ${after[0]}, webhook deliveries ${before[1]} -> ${after[1]}`);
}
process.stdout.write(`notification side effects: none (messages=${after[0]}, webhookDeliveries=${after[1]})\n`);
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
process.stderr.write(`${error.stack || error.message || error}\n`);
process.exitCode = 1;
});