From 627fa7ec97a656731244f5d1a7fa93b27806cd16 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Thu, 17 Sep 2026 11:14:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9B=BA=E5=AE=9A=E8=B7=A8=E5=8D=88?= =?UTF-8?q?=E5=A4=9C=E6=97=A5=E6=8A=A5=E5=88=B7=E6=96=B0=E5=9F=BA=E5=87=86?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=85=E9=AA=8C=E6=94=B6=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../signature-analytics.service.ts | 143 +++++++++--------- docs/testing-progress.md | 2 + 2 files changed, 77 insertions(+), 68 deletions(-) diff --git a/api/src/signature-analytics/signature-analytics.service.ts b/api/src/signature-analytics/signature-analytics.service.ts index 41e099d..46bb72e 100644 --- a/api/src/signature-analytics/signature-analytics.service.ts +++ b/api/src/signature-analytics/signature-analytics.service.ts @@ -45,78 +45,85 @@ export class SignatureAnalyticsService implements OnModuleInit, OnModuleDestroy /** Explicit offline backfill only; no controller exposes this write operation. Existing frozen days cannot be overwritten. */ async generate(value: string, backfill = false) { - const date = analyticsDate(value); - if (!mutableDay(date) && !backfill) return { skipped: true }; - if (date >= todayKey()) throw new Error('日报只生成完整自然日'); + const startedAt = new Date(); + const date = analyticsDate(value, startedAt); + if (!mutableDay(date, startedAt) && !backfill) return { skipped: true }; + if (date >= todayKey(startedAt)) throw new Error('日报只生成完整自然日'); const businessDate = databaseDay(date); const existing = await this.db.signatureAnalyticsDay.findUnique({ where: { businessDate } }); if (backfill && existing?.publishedGenerationId) throw new Error('历史补建不得覆盖已发布日报'); await this.db.signatureAnalyticsDay.upsert({ where: { businessDate }, create: { businessDate }, update: {} }); - return await analyticsJob(this.db, 'daily', date, async (tx, generationId) => { - const sourceAsOf = new Date(); - await tx.signatureAnalyticsGeneration.create({ data: { id: generationId, businessDate, sourceAsOf } }); - const quality = await new OperationsQualityQueries(tx).signatureQualityLive({ date }, true); - const dimensions = await activityDimensions(tx, date); - const counts = await activityCounts(tx, dimensions, date); - const unreported = await unreportedRows(tx, date); - // All candidate rows and manifest publication share this transaction: readers never see half a day. - for (let i = 0; i < quality.items.length; i += 250) - await tx.signatureQualityDaily.createMany({ - data: quality.items.slice(i, i + 250).map((row) => ({ - businessDate, - generationId, - signatureId: row.signatureId, - signatureName: row.signatureName, - tenantId: row.tenantId, - tenantName: row.tenantName, - applicationNames: row.applicationNames ?? '', - total: row.total, - payload: JSON.parse(JSON.stringify(row)) as Prisma.InputJsonValue, - })), + return await analyticsJob( + this.db, + 'daily', + date, + async (tx, generationId) => { + const sourceAsOf = new Date(); + await tx.signatureAnalyticsGeneration.create({ data: { id: generationId, businessDate, sourceAsOf } }); + const quality = await new OperationsQualityQueries(tx).signatureQualityLive({ date }, true); + const dimensions = await activityDimensions(tx, date); + const counts = await activityCounts(tx, dimensions, date); + const unreported = await unreportedRows(tx, date); + // All candidate rows and manifest publication share this transaction: readers never see half a day. + for (let i = 0; i < quality.items.length; i += 250) + await tx.signatureQualityDaily.createMany({ + data: quality.items.slice(i, i + 250).map((row) => ({ + businessDate, + generationId, + signatureId: row.signatureId, + signatureName: row.signatureName, + tenantId: row.tenantId, + tenantName: row.tenantName, + applicationNames: row.applicationNames ?? '', + total: row.total, + payload: JSON.parse(JSON.stringify(row)) as Prisma.InputJsonValue, + })), + }); + for (let i = 0; i < dimensions.length; i += 250) + await tx.signatureActivityDaily.createMany({ + data: dimensions.slice(i, i + 250).map((d) => ({ + ...d, + businessDate, + generationId, + ...(counts.get(d.dimensionKey) ?? { + submittedAttempts: 0, + acceptedBusinessCount: 0, + deliveredBusinessCount: 0, + }), + applicability: 'applicable', + })), + }); + for (let i = 0; i < unreported.length; i += 250) + await tx.unreportedSignatureDaily.createMany({ + data: unreported.slice(i, i + 250).map((r) => ({ ...r, businessDate, generationId })), + }); + if (!backfill && !mutableDay(date)) throw new Error('日报已进入冻结区,拒绝跨日发布'); + if (backfill) { + const current = await tx.signatureAnalyticsDay.findUniqueOrThrow({ where: { businessDate } }); + if (current.publishedGenerationId) throw new Error('已有发布版本,拒绝覆盖'); + } + await tx.signatureAnalyticsDay.update({ + where: { businessDate }, + data: { + publishedGenerationId: generationId, + state: 'ready', + error: null, + generatedAt: new Date(), + sourceAsOf, + refreshFor: databaseDay(todayKey(startedAt)), + provenance: backfill ? 'backfill-current-source' : 'daily', + rowCounts: { quality: quality.items.length, activity: dimensions.length, unreported: unreported.length }, + }, }); - for (let i = 0; i < dimensions.length; i += 250) - await tx.signatureActivityDaily.createMany({ - data: dimensions.slice(i, i + 250).map((d) => ({ - ...d, - businessDate, - generationId, - ...(counts.get(d.dimensionKey) ?? { - submittedAttempts: 0, - acceptedBusinessCount: 0, - deliveredBusinessCount: 0, - }), - applicability: 'applicable', - })), - }); - for (let i = 0; i < unreported.length; i += 250) - await tx.unreportedSignatureDaily.createMany({ - data: unreported.slice(i, i + 250).map((r) => ({ ...r, businessDate, generationId })), - }); - if (!backfill && !mutableDay(date)) throw new Error('日报已进入冻结区,拒绝跨日发布'); - if (backfill) { - const current = await tx.signatureAnalyticsDay.findUniqueOrThrow({ where: { businessDate } }); - if (current.publishedGenerationId) throw new Error('已有发布版本,拒绝覆盖'); - } - await tx.signatureAnalyticsDay.update({ - where: { businessDate }, - data: { - publishedGenerationId: generationId, - state: 'ready', - error: null, - generatedAt: new Date(), - sourceAsOf, - refreshFor: databaseDay(todayKey()), - provenance: backfill ? 'backfill-current-source' : 'daily', - rowCounts: { quality: quality.items.length, activity: dimensions.length, unreported: unreported.length }, - }, - }); - return { - date, - generationId, - quality: quality.items.length, - activity: dimensions.length, - unreported: unreported.length, - }; - }); + return { + date, + generationId, + quality: quality.items.length, + activity: dimensions.length, + unreported: unreported.length, + }; + }, + startedAt, + ); } } diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 660e808..98c3960 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -5137,3 +5137,5 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页 - 性能:30000消息/600维度活动聚合,旧10343.912ms,新624.940ms,热缓存下降93.96%,accepted逐维度一致。原EXPLAIN单维度源访问30113行×600外推18067800,新批量60039;样本外推不冒称全批实测。未测整机CPU、365日真实大批次、峰值内存、p95和短信队列;不能据此宣布预生产CPU问题已完全解决。 - 原始证据保留于忽略目录`.local-data/signature-optimization-20260917/`:integration-6、uplink-real-3、browser-5、performance-1及最终门禁日志。前面失败(迁移SQL未生成、缺carriers、测试断言漏计新增样本、账号缺email、浏览器按钮名称/模糊标签定位)已修正后重跑;不删除原失败日志。Redis5.0.14.1的BullMQ版本建议及PG驱动在嵌套关系读取时的并发query弃用提示保留,未改依赖版本。 - 文档:优化方案第8节、上行诊断第6节、需求/测试用例/清退设计同步。本地提交仅纳入本轮代码、两项迁移、验收脚本及相关文档精确新增段落;已有metrics、版本、部署和发布工具修改保留。推送:未执行;测试部署:未执行;预生产部署:未执行。历史日报补建、真实外部MO投递、线上权限/供应商扩展码、午夜冻结过程、现场恢复/回退及完整容量指标未验证,按后续授权实施。 + +- 最终补充:API全量覆盖率语句67.76%/分支52.88%/函数68.62%/行70.54%,增量覆盖率87.98%/77.03%/95.91%/91.22%,均通过各自门槛;前端覆盖率88.48%/85.09%/84%/88.19%,163项通过。固定日报refreshFor为任务启动日后,类型检查、9项日期测试及真实PG模拟跨午夜通过:T-1跨日仍记录原启动日,T-3跨入T-4拒绝发布且回滚。最终browser-final仍为三尺寸28请求、页面异常0。最后停止本轮本机PG/Redis/预览服务,保留验收库及日志资产。测试基于受保护工作区,发布时仍由标准工具核验精确提交证据;未把本轮本地检查冒称部署验收。