feat: add channel report batch briefs

This commit is contained in:
hectorzhao
2026-09-02 18:29:05 +08:00
parent 2dff1be750
commit 7cb5dd376e
27 changed files with 1083 additions and 153 deletions
@@ -111,16 +111,71 @@ export class ReportBatchGenerationService {
async getBatch(batchId: string) {
const batch = await this.prisma.reportMaterialBatch.findUnique({
where: { id: batchId },
include: { exportFiles: true, items: true },
include: { exportFiles: { include: { items: true } }, items: true },
});
if (!batch) throw new NotFoundException('报备批次不存在');
const tasks = await this.collectBatchTasks(batchId);
const successCount = tasks.filter((task) => task.status === 'approved').length;
const channelIds = batch.exportFiles
.map((file) => file.channelId)
.filter((id): id is string => Boolean(id));
const channels = channelIds.length
? await this.prisma.smsChannel.findMany({
where: { id: { in: channelIds } },
select: { id: true, name: true },
})
: [];
const channelNameById = new Map(channels.map((channel) => [channel.id, channel.name]));
const batchItemById = new Map(batch.items.map((item) => [item.id, item]));
const dateParts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
})
.formatToParts(batch.createdAt)
.reduce<Record<string, string>>((parts, part) => ({ ...parts, [part.type]: part.value }), {});
const batchDate = `${dateParts.year}${dateParts.month}${dateParts.day}`;
const briefs = batch.exportFiles
.filter((file): file is typeof file & { channelId: string } => Boolean(file.channelId))
.map((file) => {
const lines = [...file.items]
.sort((left, right) => left.rowNumber - right.rowNumber)
.flatMap((entry, index) => {
const batchItem = batchItemById.get(entry.batchItemId);
if (!batchItem) return [];
const snapshot = jsonRecord(batchItem.snapshot);
const signature = jsonRecord(snapshot.signature);
const rawSignatureName = String(signature.name ?? '').trim();
const signatureName = rawSignatureName.replace(/^【|】$/g, '');
const smsContents = jsonRecord(snapshot.briefSmsContentByChannel);
const smsContent = String(smsContents[file.channelId] ?? '');
if (batchItem.reportType === 'drainage') {
const drainage = jsonRecord(snapshot.drainage);
const drainageValue = String(drainage.url ?? drainage.siteName ?? '').trim();
return [`${index + 1}.引流信息:${drainageValue},短信内容:${smsContent}`];
}
return [`${index + 1}.短信签名:【${signatureName}】,短信内容:${smsContent}`];
});
return {
channelId: file.channelId,
channelName: channelNameById.get(file.channelId) ?? file.channelId,
fileId: file.id,
fileName: file.fileName,
itemCount: lines.length,
content: [
`尊敬的供应商您好,今天是${batchDate},辛苦报备以下签名或引流信息:`,
...lines,
`报备材料在表格里,报备批次号${batch.batchNo}`,
].join('\n'),
};
});
return {
...batch,
reportTotal: tasks.length,
successCount,
successRate: tasks.length ? successCount / tasks.length : 0,
briefs,
};
}
@@ -305,6 +360,7 @@ export class ReportBatchGenerationService {
}
}
const exportedFiles = [];
const briefSmsContentByItem = new Map<string, Record<string, string>>();
const incomplete = new Set<string>(
prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id),
);
@@ -312,9 +368,25 @@ export class ReportBatchGenerationService {
for (const [channelId, items] of channelMap) {
const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items);
exportedFiles.push(result.file);
for (const entry of result.briefItems) {
const current = briefSmsContentByItem.get(entry.batchItemId) ?? {};
current[channelId] = entry.smsContent;
briefSmsContentByItem.set(entry.batchItemId, current);
}
failedTargetCount += result.incompleteBatchItemIds.length;
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
}
for (const item of prepared) {
await this.prisma.reportMaterialBatchItem.update({
where: { id: item.batchItem.id },
data: {
snapshot: {
...jsonRecord(item.snapshot),
briefSmsContentByChannel: briefSmsContentByItem.get(item.batchItem.id) ?? {},
} as Prisma.InputJsonValue,
},
});
}
for (const item of prepared) {
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue;
if (item.reportType === 'signature')
@@ -73,6 +73,7 @@ export class ReportChannelExportService {
const reportTypes = [...new Set(items.map((item) => item.reportType))];
const workbook = new ExcelJS.Workbook();
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
const briefItems: Array<{ batchItemId: string; smsContent: string }> = [];
const incompleteBatchItemIds: string[] = [];
let totalRows = 0;
for (const reportType of reportTypes) {
@@ -94,6 +95,9 @@ export class ReportChannelExportService {
const values = fields.map(
(field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '',
);
const smsContentIndex = fields.findIndex((field) => field.name.trim() === '短信内容');
const smsContentValue = smsContentIndex >= 0 ? values[smsContentIndex] : '';
const smsContent = isFileRef(smsContentValue) ? '' : String(smsContentValue ?? '');
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
const missingReason =
fields.length === 0
@@ -186,6 +190,7 @@ export class ReportChannelExportService {
}
row.height = targetHeight;
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
briefItems.push({ batchItemId: item.batchItem.id, smsContent });
for (const entry of tasks)
await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
}
@@ -219,7 +224,7 @@ export class ReportChannelExportService {
rowNumber: entry.rowNumber,
})),
});
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds, briefItems };
}
async getSingleMaterialDetail(data: SingleReportMaterialDto) {
@@ -213,6 +213,7 @@ describe('ReportMaterialsService', () => {
reportMaterialBatchItem: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })),
update: jest.fn().mockResolvedValue({}),
},
smsChannel: {
findUnique: jest
@@ -245,6 +246,28 @@ describe('ReportMaterialsService', () => {
transform: null,
defaultValue: null,
},
{
code: 'sms_content_primary',
name: '短信内容',
exportName: '短信内容一',
required: false,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: '第一条短信内容',
},
{
code: 'sms_content_secondary',
name: '短信内容',
exportName: '短信内容二',
required: false,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: '第二条短信内容',
},
]),
},
channelSignatureReportTask: {
@@ -301,6 +324,18 @@ describe('ReportMaterialsService', () => {
where: { id: 'signature-1' },
data: { pendingReport: false },
});
expect(prisma.reportMaterialBatchItem.update).toHaveBeenCalledTimes(1);
expect(prisma.reportMaterialBatchItem.update).toHaveBeenCalledWith({
where: { id: 'batch-item-1' },
data: {
snapshot: expect.objectContaining({
briefSmsContentByChannel: {
'channel-a': '第一条短信内容',
'channel-b': '第一条短信内容',
},
}),
},
});
expect(uploadedWorkbooks).toHaveLength(2);
for (const buffer of uploadedWorkbooks) {
const workbook = new ExcelJS.Workbook();
@@ -585,6 +620,71 @@ describe('ReportMaterialsService', () => {
expect(prisma.reportMaterialBatch.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 10, take: 10 }));
});
it('returns one copyable brief per channel from the immutable batch snapshot', async () => {
const batch = {
id: 'batch-brief-1',
batchNo: 'RB20260902090000TEST',
createdAt: new Date('2026-09-02T01:00:00.000Z'),
items: [
{
id: 'batch-item-signature',
signatureId: 'signature-1',
drainageItemId: null,
reportType: 'signature',
materialVersion: 1,
snapshot: {
signature: { name: '【测试签名】' },
briefSmsContentByChannel: { 'channel-1': '【测试签名】您本次提交的验证码1234' },
},
},
{
id: 'batch-item-drainage',
signatureId: 'signature-1',
drainageItemId: 'drainage-1',
reportType: 'drainage',
materialVersion: 1,
snapshot: {
signature: { name: '测试签名' },
drainage: { url: 'https://example.com' },
briefSmsContentByChannel: { 'channel-1': '' },
},
},
],
exportFiles: [
{
id: 'file-1',
channelId: 'channel-1',
fileName: '通道一.xlsx',
items: [
{ batchItemId: 'batch-item-signature', rowNumber: 2 },
{ batchItemId: 'batch-item-drainage', rowNumber: 3 },
],
},
],
};
const prisma = {
reportMaterialBatch: { findUnique: jest.fn().mockResolvedValue(batch) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', name: '通道一' }]) },
};
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
const result = await service.getBatch('batch-brief-1');
expect(result.briefs).toEqual([
expect.objectContaining({
channelId: 'channel-1',
channelName: '通道一',
itemCount: 2,
content:
'尊敬的供应商您好,今天是2026年9月2日,辛苦报备以下签名或引流信息:\n' +
'1.短信签名:【测试签名】,短信内容:【测试签名】您本次提交的验证码1234;\n' +
'2.引流信息:https://example.com,短信内容:;\n' +
'报备材料在表格里,报备批次号RB20260902090000TEST。',
}),
]);
});
it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => {
const prisma = { smsSignature: { findUnique: jest.fn() } };
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
@@ -72,8 +72,8 @@ export class AdminSmsConfigController {
}
@Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('signatureSort') signatureSort?: 'asc' | 'desc', @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, signatureSort, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query);
}
+89 -5
View File
@@ -74,8 +74,6 @@ export class SmsSignatureService {
) {}
async listSignatures(queryOrTenantId?: string | SignatureListQuery, summaryOnly = false) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
const signatureSort =
query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined;
const signatures = await this.prisma.smsSignature.findMany({
where: {
id: query.signatureId,
@@ -159,7 +157,7 @@ export class SmsSignatureService {
select: { reportType: true, materialVersion: true, snapshot: true },
},
},
orderBy: signatureSort ? [{ name: signatureSort }, { id: 'asc' }] : { createdAt: 'desc' },
orderBy: { createdAt: 'desc' },
...(query.page && query.pageSize
? {
skip: (query.page - 1) * query.pageSize,
@@ -442,11 +440,97 @@ export class SmsSignatureService {
]
: undefined,
};
const [items, total] = await Promise.all([
const [items, total, pendingReportDetailTotal] = await Promise.all([
this.listSignatures({ ...query, page, pageSize }, true),
this.prisma.smsSignature.count({ where }),
this.countPendingReportDetails(where),
]);
return { items, total, page, pageSize };
return { items, total, page, pageSize, pendingReportDetailTotal };
}
private async countPendingReportDetails(where: Prisma.SmsSignatureWhereInput) {
const signatures = await this.prisma.smsSignature.findMany({
where: { AND: [where, { auditStatus: 'approved', pendingReport: true }] },
select: {
id: true,
applicationId: true,
materialVersion: true,
application: { select: { status: true } },
reportTasks: {
where: { reportType: 'signature' },
select: { channelId: true, carrier: true, status: true, approvalScope: true },
},
reportBatchItems: {
where: { reportType: 'signature', batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { materialVersion: true, snapshot: true },
},
},
});
const applicationIds = [
...new Set(signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id))),
];
const routes = applicationIds.length
? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' },
select: {
applicationId: true,
group: {
select: {
status: true,
items: {
select: {
channel: { select: { id: true, carrier: true, carriers: true, status: true } },
},
},
},
},
},
})
: [];
let total = 0;
for (const signature of signatures) {
if (!signature.applicationId || signature.application?.status !== 'active') continue;
const generatedTargets = new Set<string>();
for (const item of signature.reportBatchItems.filter(
(entry) => entry.materialVersion === signature.materialVersion,
)) {
const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined;
if (!Array.isArray(businessKeys)) continue;
for (const value of businessKeys) {
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
if (!match) continue;
for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean))
generatedTargets.add(`${match[1]}:${carrier}`);
}
}
const channels = [
...new Map(
routes
.filter((route) => route.applicationId === signature.applicationId && route.group?.status === 'active')
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status === 'active')
.map((channel) => [channel.id, channel]),
).values(),
];
for (const channel of channels) {
for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) {
const task =
signature.reportTasks.find(
(candidate) => candidate.channelId === channel.id && candidate.carrier === carrier,
) ??
signature.reportTasks.find(
(candidate) =>
candidate.channelId === channel.id &&
candidate.carrier === null &&
candidate.approvalScope === 'legacy_channel',
);
if (task?.status === 'abandoned') continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
total += 1;
}
}
}
return total;
}
async getSignature(id: string) {
@@ -144,7 +144,6 @@ export interface SignatureListQuery {
applicationKeyword?: string;
signatureKeyword?: string;
drainageKeyword?: string;
signatureSort?: 'asc' | 'desc';
submittedAtFrom?: string;
submittedAtTo?: string;
page?: number;
+53 -2
View File
@@ -781,7 +781,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网', signatureSort: 'asc' })).resolves.toEqual([
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([
expect.objectContaining({
id: 'sig-1',
tenant: expect.objectContaining({ name: '租户A' }),
@@ -804,7 +804,7 @@ describe('SmsConfigService', () => {
reportTasks: expect.objectContaining({ select: expect.any(Object) }),
reportBatchItems: expect.any(Object),
}),
orderBy: [{ name: 'asc' }, { id: 'asc' }],
orderBy: { createdAt: 'desc' },
}));
});
@@ -1223,6 +1223,7 @@ describe('SmsConfigService', () => {
const result = await service.listSignaturesPage({ page: 1, pageSize: 10 });
expect(result.total).toBe(1);
expect(result.pendingReportDetailTotal).toBe(0);
expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' }));
expect(result.items[0]).not.toHaveProperty('materials');
expect(result.items[0]).not.toHaveProperty('reportTasks');
@@ -1230,6 +1231,56 @@ describe('SmsConfigService', () => {
expect(result.items[0]).not.toHaveProperty('drainageReportTargets');
});
it('returns the filtered total of signature channel-carrier details still awaiting batch generation', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findMany.mockImplementation(({ where }) => {
if (where?.AND) {
return Promise.resolve([
{
id: 'sig-pending',
applicationId: 'app-1',
materialVersion: 2,
application: { status: 'active' },
reportTasks: [
{ channelId: 'channel-1', carrier: 'mobile', status: 'abandoned', approvalScope: 'carrier_specific' },
],
reportBatchItems: [],
},
]);
}
return Promise.resolve([]);
});
prisma.channelRouteRule.findMany.mockResolvedValue([
{
applicationId: 'app-1',
group: {
status: 'active',
items: [
{
channel: {
id: 'channel-1',
status: 'active',
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
reportFields: [],
},
},
],
},
},
] as never);
const service = new SmsConfigService(prisma as never);
const result = await service.listSignaturesPage({ signatureKeyword: '测试', page: 1, pageSize: 10 });
expect(result.pendingReportDetailTotal).toBe(1);
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { AND: [expect.objectContaining({ name: { contains: '测试' } }), { auditStatus: 'approved', pendingReport: true }] },
}),
);
});
it.each([
'【带 空格】',
' 【外部空格】',
@@ -155,7 +155,7 @@
`api/package-lock.json` 重建审计后为 2 moderate、5 high,涉及 ExcelJS 间接 UUID、Prisma 可选工具链、brace-expansion、Swagger 间接 js-yaml 等。公告并不等于当前业务路径全部可利用,但应建立“依赖路径、运行时是否打包/调用、可升级版本、兼容回归”的矩阵,不能只以根项目审计为 0 宣告供应链闭环。
### CQ-MAINT-001 / P2:超大服务和风格门禁仍未治理
### CQ-MAINT-001 / P2:超大服务和风格门禁仍未治理
`send-inbound-entry.service.ts` 约 84.2 KiB`send-gateway-submit.service.ts` 约 52.2 KiB;职责仍集中。建议先补行为锁定测试,再按入站持久化、关联/去重、重试路由、下游投递和计费拆分。另应增加 ESLint 与 format check;当前 `npm run lint` 名称实际只执行结构脚本和 TypeScript 检查。
@@ -0,0 +1,196 @@
# CMPP 平台代码质量复评报告 V3
- 报告日期:2026-08-28
- 复评时间:2026-08-28 15:3616:08Asia/Shanghai
- 复评基线:`3834d67a308acf89d0e6cb2e6009c89c93b2c139`
- 主要业务整改提交:`ad27acad7e02b787185c972c710c73c867429c36`
- 锁文件修正提交:`226527f1cef122e4dbd163da782485e8c0254dc5`
- 对比基线:V2 基线 `3af145abe5eb8cae567bb18e29116b4940889258`
- Git 状态:本地 `main``origin/main` 超前 3 个提交;当前提交尚未推送
- 工作区保护:保留既有 `docs/code-quality-reassessment-20260828-v2.md` 修改、未跟踪 `=``pnpm-lock.yaml`
- 明确未执行:提交、推送、部署、服务重启、数据库业务写入、短信发送、压力测试、预生产或生产操作
## 1. 执行摘要
本轮综合判定为:**代码质量有条件通过,持续治理尚未闭环;不建议仅凭本报告进入预生产发布。**
与 V2 相比,本轮取得了明确进步:
- 客户端凭据、Webhook、日志导出、文件上传、用户维护等写接口补充 class DTO、严格运行时校验和负向测试。
- 浏览器客户端请求不再发送或回退租户 ID,服务端可信租户边界进一步收紧。
- 登录保护从账号维度扩展为账号、来源 IP、账号+IP 和验证码请求频率,并使用 Redis 原子计数和摘要键。
- 建立 Vitest/Testing Library/jsdom/MSW 前端测试基座,4 个测试文件、34 项测试通过。
- API 增至 51 个测试套件、563 项测试并全部通过。
- 根依赖审计保持 0;API 审计从 V2 的 7 项降至 5 项,剩余项均位于 Prisma CLI 开发/迁移链。
- npm 10.9.4、两份正式 `package-lock.json`、ESLint flat config 和 Prettier 配置已建立。
- 测试环境已部署 `226527f...`;本次独立读取到的远端前端资源与本地构建资源一致,外部 API health 最终连续 5 次返回 200。
但本次独立复测发现两项不能忽略的问题:
1. **增量 lint/format 门禁存在假绿。** 默认比较 `HEAD` 时,提交后的整改文件不会进入检查;显式设置基线后,Windows 包装器又无错误信息地退出 1。绕过包装器直接检查 37 个整改代码文件,发现 6 个 ESLint error、2 个 warning,以及 2 个文件未通过 Prettier。
2. **客户端删除接口仍缺严格 DTO。** `ClientDeletionGovernanceController` 使用 TypeScript type `DeleteTargetDto`,没有 `strictValidationPipe`,额外字段、格式和长度仍不会按本轮统一规则拒绝。
综合参考分:**86/100**,较 V2 的 82/100 提升 4 分。P0 为 0,但仍有 2 项 P1 和若干 P2/验收边界。
## 2. 质量评级
| 维度 | V2 | V3 | 结论 |
|---|---:|---:|---|
| 业务正确性与并发设计 | B+ | B+ | API/Gateway 全量回归通过;未重跑真实 CMPP 全链。 |
| 安全性 | B+ | A- | 客户端租户边界、DTO 和登录保护明显增强;删除接口仍有校验缺口。 |
| 自动化测试 | B- | B+ | 前端 34 项、API 563 项通过;API “全源”指标仍未成为标准脚本默认口径。 |
| 前端性能 | B+ | B+ | 路由分包保持,入口 107.28 KiB gzip,图表异步包 181.65 KiB gzip。 |
| 可维护性 | C+ | C+ | 引入 ESLint/Prettier,但门禁未真实通过;两个 SendChain Service 未拆分。 |
| 数据库工程 | B+ | B+ | Prisma schema 和 95 项迁移发布记录正常;本轮未独立执行真实数据查询计划。 |
| 依赖与仓库卫生 | B- | B | 根审计 0API 风险降至 Prisma CLI 5 项;正式包管理器已固定,旧未跟踪 pnpm 锁仍在。 |
| 运行与发布就绪 | B- | B | 测试环境公开入口及资源匹配;内部服务/队列状态仅有发布记录,本次 SSH 未独立复核。 |
## 3. V2 遗留项复核
| V2 项目 | V3 状态 | 复评结论 |
|---|---|---|
| 客户端写接口严格校验不完整 | **大部分关闭,仍有缺口** | 凭据、Webhook、导出、文件、用户等已补齐;客户端删除接口遗漏。 |
| 前端自动化测试为 0 | **已关闭** | 4 个文件、34 项通过;覆盖登录、请求错误、租户头、路由异常和用户列表关键状态。 |
| API 依赖公告 2 moderate/5 high | **部分关闭** | 当前为 2 moderate/3 high,共 5 项;均聚合在 Prisma CLI 链并有风险矩阵。 |
| 无真实 ESLint/Prettier | **实现未验收通过** | 配置与脚本已增加,但增量包装器有缺陷,整改代码本身仍有 lint/format 失败。 |
| SendChain 超大服务 | **未处理,按方案延期** | 文件仍约 86,253 和 53,421 字节;需要独立行为锁定与重构任务。 |
| 双锁文件口径 | **部分关闭** | `packageManager` 固定 npm 10.9.4,正式仅跟踪两份 npm lock;旧未跟踪 pnpm lock 仍受保护保留。 |
| 测试环境与登录后验收 | **部分关闭** | 测试环境已部署并健康;登录后深层页面和真实 CMPP 全链仍未复验。 |
## 4. 本轮独立验证结果
### 4.1 构建与自动化测试
| 检查项 | 结果 |
|---|---|
| 前端 TypeScript | 通过 |
| Vite production build | 通过 |
| Bundle budget | 通过;入口 107.28 KiB gzipChart 181.65 KiB gzip |
| 前端 Vitest | 4/4 文件、34/34 项通过 |
| 前端纳入覆盖范围的核心模块 | statements 88.34%、branches 84.71%、functions 84.00%、lines 88.02% |
| API TypeScript build | 通过 |
| Prisma validate | 通过;仓库 95 项 migration |
| API Jest | 51/51 套、563/563 项通过 |
| API 默认 coverage 脚本口径 | 70.44% / 53.77% / 71.76% / 73.61% |
| API 显式全源口径 | 60.46% / 51.54% / 60.75% / 63.04% |
| API 增量覆盖配置 | 86.92% / 77.03% / 95.91% / 90.07% |
| Gateway `go test ./... -count=1` | 通过 |
| Gateway `go vet ./...` | 通过 |
| Gateway 队列契约 | 5/5 通过 |
| 结构质量/依赖缓解/安全部署门禁 | 通过 |
| `npm ci --dry-run --include=dev` | 根项目与 API 均通过 |
覆盖率顺序均为 statements / branches / functions / lines。
首次并发执行前端测试时,4 个 Vitest worker 因本机同时运行多个高负载任务而启动超时,未执行测试;资源释放后以单 worker 串行重跑全部通过。首次从仓库根目录直接启动 API Jest 时误扫描受保护的 `outputs/` 历史快照;改为在 `api/` 目录按项目配置运行后全部通过。两次错误执行均未计为产品失败。
### 4.2 覆盖率口径校正
`api/jest.config.cjs` 仍未配置 `collectCoverageFrom`,因此标准 `test:coverage` 的 70.44% 只统计测试实际加载的源码,不能称为严格“全源覆盖率”。本报告显式加入 `src/**/*.ts` 后的全源结果为 60.46% / 51.54% / 60.75% / 63.04%,较 V2 的 59.84% / 51.29% / 60.15% / 62.60% 小幅提升,并继续满足 59% / 50% / 60% / 62% 门槛。
增量配置确实对指定新增模块建立了 80% / 70% / 80% / 80% 门槛,但运行时仍执行全部 51 套测试;覆盖率只收集列出的 7 个文件。该机制有效,但文档应准确称为“指定整改模块覆盖率”,不应与全源口径混用。
### 4.3 依赖审计
使用项目固定的 npm 10.9.4 直接审计正式锁文件:
- 根项目:0 项公告。
- API5 项公告,2 moderate、3 high。
- 5 项均为 `prisma -> @prisma/config/@prisma/dev -> deepmerge-ts/valibot` 聚合路径,不进入 NestJS HTTP 请求运行时。
- 自动修复建议是 Prisma 7.9.0 降级到 6.12.0,属于破坏性主版本变更,本轮不执行 `audit fix --force` 的决定合理。
- `api/package-lock.json` 已实际解析 UUID 11.1.1、find-my-way 9.7.0Swagger、js-yaml、fast-uri、brace-expansion 的既有公告已关闭。
判定:供应链风险已从“未治理”转为“有记录的开发工具链风险接受”,但仍需监控 Prisma 上游修复。
### 4.4 测试环境只读探测
- `curl --noproxy '*'` 绕过系统代理后,第一组 `/api/health` 3 次均为 HTTP 200。
- 同组客户端登录页、运营登录页和验证码接口均返回 200;运营登录页有一次总耗时约 9 秒。
- 随后一次 health 请求发生 10 秒连接超时;立即复测 5 次均返回 200,总耗时约 17~19 ms。
- 远端登录 HTML 引用 `index-BvZGIM6l.js``index-DyAqoJbu.css`,与本地 V3 构建产物一致。
- SSH 当前仍认证失败,无法独立读取 `.deployed-commit`、systemd、Redis Stream、migration 和服务器日志。
- 发布记录显示测试环境部署标记为 `226527f...`,95 项迁移一致、11 项服务 active、三条 Stream `pending=0/lag=0`、发布窗口 error 日志为 0;这些内部状态本次未独立复核。
判定:外部入口总体健康,单次超时暂记为网络瞬态,不设 P0;预生产前仍需用可用只读凭据复核内部状态。
边界说明:外部 smoke 曾请求一次验证码 GET 接口,该接口会在 Redis 写入一个 5 分钟 TTL 的一次性验证码记录;未提交登录、未创建用户或业务数据,未触发短信、余额、应用、通道或 MinIO 业务写入。
## 5. 当前问题清单
### CQ-QG-001 / P1:增量 lint/format 门禁假绿且整改代码未通过
**证据**
- `tools/quality/run-changed-code-quality.mjs:7-14` 默认基线为 `HEAD`,正常提交后执行 `npm run lint` 时不会检查已提交整改代码。
- 本次默认运行输出 `No changed code files require lint/format`,不能证明 `ad27aca` 中的代码通过。
- 设置 `QUALITY_BASE_REF=3af145a...` 后,Windows 包装器无错误信息退出 1;脚本没有输出 `spawnSync` 的 error。
- 绕过包装器直接对 37 个整改文件执行 ESLint,发现 6 个 error、2 个 warning
- `api/src/auth/auth.controller.ts`2 个未使用变量。
- `api/src/open-api/open-api.service.ts`1 个未使用 import。
- `src/api/client/client.api.ts`2 个未使用 import。
- `src/apps/client/ClientUsersPage.test.tsx`1 个未使用变量。
- `src/apps/LoginPage.tsx`Fast Refresh 导出警告、`useEffect` 缺少 `refreshCaptcha` 依赖警告。
- Prettier 检查发现 `api/src/auth/auth.service.spec.ts``api/src/open-api/open-api.service.spec.ts` 未格式化。
**影响**
整改记录中的“ESLint/Prettier 门禁通过”不是当前可重复证据。CI 如果没有显式传入基线会漏检提交;Windows 本地即使传入基线也会无诊断失败。
**关闭要求**
1. 门禁显式接收并验证 base/headCI 使用 merge-base;本地提供全量或最近提交模式。
2. Windows 调用方式可执行,并在 spawn 失败时输出 `result.error`
3. 修复 6 个 error、2 个 warning 和 2 个格式文件后,直接 ESLint/Prettier 与包装脚本均通过。
4. 为“无变更”“有错误”“Windows 启动失败”和“CI 基线”增加脚本测试。
### CQ-API-001 / P1:客户端删除接口仍未纳入严格运行时校验
`api/src/deletion-governance/deletion-governance.controller.ts:34-38` 的客户端删除接口使用 `DeleteTargetDto`,但没有 `@UsePipes(strictValidationPipe)``api/src/deletion-governance/deletion-governance.service.ts:10-18` 的 DTO 仍为 TypeScript type,没有 class-validator 规则。请求中的额外字段、超长 reason、错误布尔类型、非法时间和幂等键格式不会按统一客户端策略拒绝。
关闭要求:建立 client deletion class DTO、严格管道和负向测试,并把结构门禁扩展为检查所有 `@Controller('client...')` 的 body 写接口,而不是仅靠人工清单。
### CQ-COV-001 / P2API 标准覆盖率脚本仍不是全源口径
标准 `test:coverage` 未配置 `collectCoverageFrom`,容易把 70.44% 误写为全源覆盖率;严格全源只有 60.46%。应在正式配置中固化全源 include/exclude,并保留增量覆盖作为第二道门禁。
### CQ-MAINT-001 / P2SendChain 超大服务仍未拆分
`send-inbound-entry.service.ts` 约 84.2 KiB`send-gateway-submit.service.ts` 约 52.2 KiB。本轮明确延期是合理的风险控制,但不能记为质量治理完成。后续需先补行为锁定和性能基线,再分职责重构。
### CQ-REPO-001 / P2:本地提交尚未推送且旧 pnpm 锁仍存在
本地比 `origin/main` 超前 3 个提交,远端凭据拒绝已在进度文档记录;测试环境部署了代码提交 `226527f...`,但远端 Git 仍停留在 V2 基线。灾备、协作和可追溯性因此不完整。另有未跟踪旧 `pnpm-lock.yaml`,虽未纳入正式流程,仍可能误导人工命令。
### CQ-ACC-001 / P2:登录后浏览器和真实全链仍未复验
测试环境只验证了公开页面、验证码和 health;没有输入密码或代解验证码,没有验证登录后 loading/empty/error/refresh、权限和关键写操作,也没有重跑 PostgreSQL/Redis/MinIO/Gateway/CMPP 模拟器全链。自动化测试不能替代这些最终验收证据。
## 6. 发布建议
当前建议:**允许继续整改和测试环境验证,暂停预生产发布。**
最小充分关闭顺序:
1. 修复增量 lint/format 包装器并清零当前 lint/format 失败。
2. 补齐客户端删除 DTO、严格校验和负向测试。
3. 把 API 严格全源覆盖率固化为标准门禁,修正文档口径。
4. 使用可用只读凭据复核测试环境部署标记、11 项服务、95 项迁移、Redis Stream 和 error 日志。
5. 在真实登录态下完成客户端/运营端关键页面和请求状态验收。
6. 取得授权后解决远端 Git 凭据并推送;推送不是本次复评授权范围。
7. SendChain 拆分继续作为独立高风险任务,不与上述门禁修复混合。
## 7. 最终判定
- 原始安全 P0:**0 项未关闭**。
- 当前 P0**0 项**。
- 当前 P1:**2 项**——质量门禁假绿、客户端删除校验遗漏。
- 当前 P2/验收边界:API 覆盖率口径、SendChain 重构、Git/锁文件治理、登录后与真实全链验收。
- 自动化:前端 34 项、API 563 项、Gateway 全包测试均通过。
- 构建与包体:通过。
- 依赖:根项目 0API 5 项 Prisma CLI 风险已记录接受。
- 测试环境公开入口:通过,存在一次短暂超时后连续 5 次快速恢复。
- 代码质量:**86/100,有条件通过**。
- 持续治理闭环:**未通过**。
- 预生产发布建议:**暂停**。
本报告只对应固定提交和本次可重复证据,不把整改记录、单元测试或公开 health 冒充真实后端全链与登录后功能验收。
@@ -76,10 +76,10 @@
"canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da"
}
],
"tableJsxSha256": "3f321d3490479e62db729ebc49d4cc7eef46c24c9eaf5add7a0d046965de3109",
"tableJsxSha256": "8db30a6b19c42b487e4f98e949c117b630014efa5054c07d4b4940c01da143db",
"apiCalls": [
"listEnterpriseSignaturesPage",
"listTenants",
"listTenantOptions",
"listEnterpriseApplicationOptions",
"updateEnterpriseSignature",
"createEnterpriseSignature",
@@ -102,7 +102,6 @@
"error",
"expandedSignatureId",
"signatureKeyword",
"signatureSort",
"appliedSignatureKeyword",
"signatureModal",
"reportStatusTarget",
@@ -111,7 +110,10 @@
"tenants",
"page",
"importOpen",
"message"
"message",
"materialChangedSignature",
"pendingReportDetailTotal",
"listRequestSequence"
],
"files": [
"signature.types.ts",
@@ -73,7 +73,6 @@ type SignatureListState = {
drainageKeyword: string;
};
page: number;
signatureSort: 'asc' | 'desc';
revision: number;
};
```
@@ -83,7 +82,6 @@ type SignatureListState = {
- 查询:写入 trim 后的 filters、`page=1`,并递增 revision
- 重置:清空输入和已应用 filters、`page=1`,并递增 revision
- 翻页:只更新 page
- 排序:更新 signatureSort、`page=1`,并递增 revision
- 保存、删除、报备状态修改成功:只递增 revision。
revision 用于解决“当前已经在第一页,重复点击查询/重置仍需主动刷新”的场景。这样可以避免 `setPage(1)` 与直接 `loadData()` 并存造成双重触发。
+15 -11
View File
@@ -1,5 +1,7 @@
# CMPP 平台第一版 Linux 上线部署与回滚方案
> 本文保留第一版的部署示例,不是当前预生产的逐步执行清单。当前实例的磁盘分工、持久化目录、备份入口、服务依赖和实施状态,以[部署手册的“预生产磁盘与目录规划”](production-deployment.md#预生产磁盘与目录规划)为准。下列Compose与简化systemd示例不能直接覆盖预生产现有配置。
## 部署组件
- FrontendReact + TypeScript + Vite 静态资源。
@@ -20,9 +22,10 @@
logs/
api/
gateway/
backups/
```
备份必须位于运行目录之外。当前预生产入口为 `/opt/cmpp-platform-backups`,实际指向数据盘的 `/data/cmpp-platform-backups`;不再使用 `/opt/cmpp-platform/backups`。2026-08-31晚间第二阶段已将PostgreSQL、Redis、MinIO迁入数据盘并用绑定挂载保留原访问路径;应用日志和监控数据未迁移。路径、启动保护与未实施项以部署手册为准。
## 环境变量
```bash
@@ -131,31 +134,33 @@ pg_isready -d "$DATABASE_URL"
## 备份恢复
数据库备份
当前预生产数据库备份的环境加载、存储检查和固定入口见[部署手册的回滚章节](production-deployment.md#回滚)。已正确加载 `DATABASE_URL` 后,SQL格式备份示例为
```bash
pg_dump "$DATABASE_URL" > /opt/cmpp-platform/backups/cmpp-$(date +%F-%H%M%S).sql
set -e
/usr/local/sbin/cmpp-backup-storage-check
pg_dump "${DATABASE_URL%%\?*}" > /opt/cmpp-platform-backups/cmpp-$(date +%F-%H%M%S).sql
```
数据库恢复:
数据库恢复必须另经授权并确认目标数据库、配套运行代码与停写方案。对于已验证的SQL格式备份,命令示例为(需替换为实际文件名)
```bash
psql "$DATABASE_URL" < /opt/cmpp-platform/backups/cmpp-YYYY-MM-DD-HHMMSS.sql
psql -v ON_ERROR_STOP=1 "${DATABASE_URL%%\?*}" < /opt/cmpp-platform-backups/cmpp-YYYY-MM-DD-HHMMSS.sql
```
MinIO 备份建议使用 `mc mirror` 将 bucket 同步到备份目录或对象存储
custom格式的 `database.dump` 应使用 `pg_restore`,不能传给上面的SQL恢复命令。当前磁盘备份不等于异机灾备;MinIO另行建立对象备份时,须明确存储元数据/版本保留要求、目标位置和恢复验证,不能只凭同步命令成功就认定可恢复
## 回滚方案
1. 停止 API、Send Worker 和 Gateway
1. 先核对当前架构与在途业务,按已授权的停写方案协调API、Gateway及全部已启用Worker;第一版三进程列表不足以覆盖当前拆分架构
2. 切回上一版代码或镜像 tag。
3. 如果已执行数据库迁移,优先使用兼容回滚:
- 保留新增列和新增表。
- 回滚应用代码到上一版。
- 禁止直接删除生产数据表。
4. 如迁移导致不可兼容故障,使用上线前数据库备份恢复。
5. 清理 Redis 中未消费的新版本队列 key,避免旧版本误消费不兼容消息
6. 启动上一版服务并执行健康检查。
5. 保留Redis持久化、未消费队列、Streams、pending和幂等状态;禁止直接删除队列key或手工ACK/XDEL。协议不兼容时先确定配套恢复方案,不能通过丢弃消息实现回滚
6. 按当前部署手册的服务依赖和启动顺序启动上一版服务并执行健康检查。
7. 抽查发送任务、短信记录、账务流水、回执记录。
## 上线检查清单
@@ -163,8 +168,7 @@ MinIO 备份建议使用 `mc mirror` 将 bucket 同步到备份目录或对象
1. `npm run verify:phase8` 通过。
2. Prisma migrate deploy 成功。
3. API health、Gateway health、Redis ping、PostgreSQL ready 全部通过。
4. 创建测试短信任务并确认手机号维度短信记录生成
4. 如已取得明确的短信测试授权,创建受控测试任务并验证手机号维度记录;否则标记该项未执行,不得擅自发送或重投短信。
5. Gateway submit result、receipt、uplink 事件接口可写入。
6. 查询、统计、追踪、对账接口可访问。
7. 已完成数据库和 MinIO 备份。
+63
View File
@@ -0,0 +1,63 @@
# 预生产数据盘第一阶段(2026-08-31)
本文件保留第一阶段的实施证据与当时状态。长期目录分工、后续部署约束和待实施规划统一维护于[部署手册的“预生产磁盘与目录规划”](production-deployment.md#预生产磁盘与目录规划);更新规划不表示第二阶段已实施。
## 授权与范围
- 主机:`8.160.169.106:12022`。用户授权现在执行第一阶段,第二阶段等待今晚明确指令,不自动执行、不设定时迁移。
- 仅初始化独立100GiB数据盘、挂载 `/data`、迁移现有版本备份及其目录中的操作记录、设置缺盘保护。
- 当前代码、上一个版本运行目录、PostgreSQL、Redis、MinIO的运行数据仍在系统盘;未改业务配置、未重启业务服务、未发送或重投短信。
## 磁盘与挂载
- 系统盘:`/dev/nvme0n1`60GiB;根分区 `/dev/nvme0n1p2`
- 数据盘设备标识:`/dev/disk/by-id/nvme-Alibaba_Cloud_Elastic_Block_Storage_0jli8w4nwo7s3niwyqz0`,当时解析为 `/dev/nvme1n1`107374182400字节。
- 初始化前已核对设备标识、容量、无分区、无挂载、无holders、无打开设备描述符;只读 `wipefs --no-act` 未报告签名,首尾及中间采样为零。采样不能证明全盘无任何历史内容;本次依据用户明确授权初始化该数据盘。
- GPT分区 `/dev/nvme1n1p1`ext4,标签 `cmpp-data`,保留块1%。UUID`ef4ee3bb-a19b-4aeb-b00c-aa2b995611c2`
- `/etc/fstab` 新增:
```fstab
UUID=ef4ee3bb-a19b-4aeb-b00c-aa2b995611c2 /data ext4 defaults,nofail,nodev,nosuid,x-systemd.device-timeout=10s 0 2
```
- 执行了 `systemctl daemon-reload``data.mount` 启停验证,没有重启服务器或业务服务。最终 `findmnt --verify --verbose` 无错误、无警告;尚未通过整机重启验证。
- 系统盘上未挂载时的空 `/data` 目录设置 immutable 属性。实际数据盘挂载后正常可写;缺盘时即使root也不能直接向空挂载目录创建文件,防止备份回落系统盘。不得移除此保护或在缺盘时重新创建备份入口。
## 备份入口与完整性
- 固定入口:`/opt/cmpp-platform-backups -> /data/cmpp-platform-backups`
- 仅保留上一版本 `4d526b83abe73a495945137e88fd7b679565d0bf` 的恢复点 `preprod-protocol-flush-fix-20260830T135502Z`,以及既有 `operations` 操作记录。上一个版本的运行目录 `/opt/cmpp-platform.previous-protocol-flush-20260830T135712Z` 未迁移。
- 复制使用低I/O优先级并限速10MiB/s;对21个文件/目录条目核对全部文件SHA-256、属主、权限、mtime及扩展属性一致。
- `renameat2(RENAME_EXCHANGE)` 原子切换目录与符号链接;切换前无进程持有源备份路径,切换后再次校验,再删除系统盘的备份副本。
- 原备份 `SHA256SUMS` 全部通过,`pg_restore --list` 和运行目录/系统配置两份tar可读性通过。未执行数据库恢复演练。
- 原路径实际写入探针确认落在数据盘,探针已删除。
## 后续备份规则
备份脚本必须先执行以下检查,失败就终止,不允许自动改写到系统盘:
```bash
set -e
/usr/local/sbin/cmpp-backup-storage-check
# 后续备份统一写入 /opt/cmpp-platform-backups,实际存储位于 /data。
```
- 服务器检查脚本核对 `/data` 确实挂载、UUID、磁盘标记、备份目录所在文件系统及读写挂载选项。
- 已实测数据盘卸载时检查失败,直接以root向底层 `/data` 创建目录也失败;重新挂载后恢复正常。
- 本轮未发现引用现有备份入口或 `pg_dump` 的cron/systemd备份作业;没有新建自动备份计划。新增发布/备份脚本应显式调用检查,不得另建系统盘备份目录。
- 本机另一块数据盘上的备份仍属于同机备份,不等价于异机灾备。
## 最终验证(09:35 CST
- 系统盘:59G文件系统,已用13G,可用44G,24%。数据盘:98G文件系统,已用575M,可用97G1%。
- API、Send Worker、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、Security Agent、MinIO、Nginx、PostgreSQL、Redis共11项服务activeMainPID均与操作前一致。
- API/Callback/Gateway健康正常,PostgreSQL接受连接,Redis PONG。09:30开始至最终检查的所选8项服务error级journal计数0;未据此声称已完成真实短信端到端测试。
- `/opt/cmpp-platform``/var/lib/pgsql``/var/lib/redis``/var/lib/minio` 均仍位于系统盘。
- `.deployed-commit` 保持 `1a5063a7c635280b912021c42c33a07c99f4c5dd`
## 证据与第二阶段
- 服务器配置前副本、UUID和文件校验记录:`/etc/cmpp-platform/storage-stage1-20260831/`
- 本地证据:`outputs/data-disk-stage1-{baseline,mount,copy,switch,final}-20260831.txt`
- 初始化脚本中的一次额外 `wipefs` 列筛选调用因本机参数语义不同失败;初始化前独立的只读签名检查及设备检查已完成。后续检查使用 `wipefs --no-act <device>`,不要复用错误的 `-o TYPE` 参数。
- 第二阶段未开始。收到用户明确指令后重新核对业务状态、数据增长、持久化配置及恢复资产,再确定数据库/Redis/MinIO一致性迁移与切换窗口;本次授权不延伸到第二阶段。
+64
View File
@@ -0,0 +1,64 @@
# 预生产数据盘第二阶段(2026-08-31晚间)
## 授权、范围与结果
- 用户明确要求“开始执行第二阶段”;主机`8.160.169.106:12022`,仅迁移PostgreSQL、Redis、MinIO三项持久化存储。
- 实际业务停写窗口:`2026-08-31 22:31:57—22:33:14 +08:00`,约77秒。API在窗口内不可用,CMPP上下游连接经历断开与重连,不能表述为零中断。
- 当前业务程序、应用日志、Prometheus和系统日志未迁移,Nginx及Security Agent未重启;未发送、补发或手工重投短信,未手工ACK/XDEL或清理业务队列。
- 22:36复核11项服务active,供应商连接9/9,客户端连接恢复4个(刚恢复时短暂为2个),API/Callback/Gateway/MinIO健康、PostgreSQL接受连接、Redis PONG,外部API健康通过。
## 迁移前真实状态
- 部署标记`1a5063a7c635280b912021c42c33a07c99f4c5dd`;第二阶段未发布新的业务产物。
- 数据盘UUID `ef4ee3bb-a19b-4aeb-b00c-aa2b995611c2`,第一阶段挂载和备份保护正常。
- PostgreSQL 15.18PGDATA `/var/lib/pgsql/data`,不存在外部表空间或WAL符号链接;数据库`postgres``cmpp_platform`随完整父目录迁移。
- Redis目录`/var/lib/redis`RDB `dump.rdb`AOF关闭;未借迁盘改持久化策略。
- 对象存储驱动为MinIO,目录`/var/lib/minio`,62个业务对象,总大小40846589字节。未切换到local备用驱动。
- 下游连接4个、上游9/9,在途提交0。命令Stream长度75533但pending/lag均0,是已消费历史条目,不能据长度清队列;结果及协议日志Stream长度0、pending/lag均0。
- Inbox completed 4613Submit Outbox published 867UpstreamReceiptInbox matched 140861 / unmatched 7;7条既有未匹配回执未作为迁盘问题删除或强制处理。
## 恢复资产与执行顺序
- 本次恢复点:`/data/cmpp-platform-backups/preprod-storage-stage2-20260831T142327Z`(也可通过原固定备份入口访问)。旧版本`4d526b8`原恢复点继续保留。
- 停写前已保存PostgreSQL custom dump、全局角色、当前运行目录、系统配置和fstab,`SHA256SUMS.online``pg_restore --list`及两份tar可读性通过。配置备份第一次遇到不存在的`/etc/sysconfig/pgsql`而中止;去除错误输入、重新生成并校验后才进入停写窗口。
- 停Send Worker、Outbox和API,确认Gateway在途及结果Outbox为0,再停Gateway;核对Stream无待处理后停Callback和Protocol Log Worker。
- 在冻结状态保存110张public表的精确行数、MinIO对象清单及三条Stream的长度/组状态;执行Redis SAVE,再停止MinIO、Redis、PostgreSQL。
- 确认九个写入相关服务MainPID均为0,`pg_controldata`显示`shut down`后,以40MiB/s限速做完整冷复制。不是把普通在线目录复制当作一致备份。
- PostgreSQL 2225个文件/目录条目、2373269966文件字节;Redis 2条目、184085437字节;MinIO 249条目、40914208字节。三组全部文件SHA-256、类型、属主、权限、mtime和扩展属性与源目录一致。
- 原目录改名为以下冷副本,再建立绑定挂载;它们未删除,也不再是服务使用的数据目录:
- `/var/lib/pgsql.systemdisk-stage2-20260831`
- `/var/lib/redis.systemdisk-stage2-20260831`
- `/var/lib/minio.systemdisk-stage2-20260831`
- 恢复点中的`cold-storage.tar.gz`包含上述三项同一停写窗口的原数据副本,约611MiB;压缩包可读性及`tar --compare`源文件内容比对通过。`SHA256SUMS.final`覆盖36项恢复文件并全部校验通过,完整恢复点约1006MiB。在线逻辑dump与独立Redis文件不能替代这套一致冷备。
## 当前路径与启动保护
| 服务 | 原访问路径 | 实际数据盘目录 | systemd挂载单元 |
|---|---|---|---|
| PostgreSQL | `/var/lib/pgsql` | `/data/postgresql` | `var-lib-pgsql.mount` |
| Redis | `/var/lib/redis` | `/data/redis` | `var-lib-redis.mount` |
| MinIO | `/var/lib/minio` | `/data/minio` | `var-lib-minio.mount` |
- 原服务参数、账号、业务路径不变。fstab绑定挂载使用`bind,nofail,x-systemd.requires=data.mount,x-systemd.after=data.mount`
- 每个服务的`/etc/systemd/system/<unit>.service.d/50-cmpp-data-disk.conf`配置`RequiresMountsFor``After``BindsTo`,并以root权限在`ExecStartPre`调用存储检查;数据盘缺失不能因`nofail`启动空库。
- `/etc/cmpp-platform/data-disk.uuid`保存预期UUID。`/usr/local/sbin/cmpp-data-storage-check`核验数据盘、对应绑定挂载、源/目标设备号及inode一致、读写挂载选项、PG_VERSION/RDB/MinIO格式标记。
- 三个系统盘底层空挂载点均设置immutable;第一阶段底层`/data`的保护保持不变。
- 在隔离mount namespace内实测:缺少绑定挂载、缺少数据盘、错绑同盘其他目录、只读绑定挂载均被拒绝;底层系统盘写入失败。宿主机挂载及三个服务PID在这些测试中未改变。
- `findmnt --verify --verbose`无错误或警告。此次未重启整机,开机过程尚未通过真实重启验收;未通过真实拔盘触发线上停机测试。
## 恢复前后数据核对
- 新位置启动三项存储后、恢复业务前,110张public表精确行数逐项一致;其中短信记录74457、提交记录75532、回执记录148281、上游回执Inbox 140868。
- 三条Redis Stream在冻结前后长度、consumer group、pending、lag、last-delivered-id及entries-read完整一致。恢复Worker后协议日志组consumer数从2变3是新进程注册,pending/lag仍0,未删除旧consumer。
- MinIO 62个业务对象的key/size/etag清单一致,并完成独立随机对象的上传、下载内容比对与删除;未操作已有业务对象。
- PostgreSQL以临时表事务执行真实写读并ROLLBACK;Redis使用随机诊断key写读删除,附带60秒TTL;无短信发送探针。
- 依次启动Callback/Protocol Log Worker/Outbox、Gateway、API、Send WorkerAPI启动后恢复供应商连接。启动健康轮询出现短暂端口未监听,随后全部通过;没有将启动瞬态当作已验收服务。
- 封存冷备后的最终数据盘约4.0G使用、93G可用(5%);系统盘约24%、可用44G,其中仍保留约2.4GiB原数据冷副本。容量是本次观察值,后续以实际df为准。
## 后续部署与回退限制
- 本地及服务器`production-deploy.sh``production-bootstrap.sh`增加已有数据盘安装的前置检查,失败发生在初始化/发布写入之前;原systemd drop-in不被这两个脚本覆盖。新增可复用检查源文件为`tools/deploy/check-data-storage.sh`
- 两个脚本和检查器已通过bash语法检查,两个入口在隔离缺挂载环境中均拒绝继续。未执行完整部署、Git提交或推送;当前运行目录的运维脚本有本次明确记录的修改,业务产物部署标记不变,不能声称运行目录所有文件仍与该commit逐字节一致。
- 新位置已恢复正常业务写入,禁止直接切回系统盘旧目录或冷备覆盖新数据。若需回迁,必须重新停写,将新位置的最新一致数据反向同步,核验后再修改挂载。
- 暂保留的原副本是迁移恢复证据;后续清理需核对本次冷备及业务状态。备份和业务数据同在一块数据盘不能替代异机灾备。
- 服务器执行资产:`/etc/cmpp-platform/storage-stage2-20260831/`;本地原始证据:`outputs/data-disk-stage2-*.txt``migration.log`、表行数、Stream/对象清单、文件校验清单、前后配置均位于本次恢复点。
+69 -1
View File
@@ -11,6 +11,67 @@
- Gateway 控制服务:仅本机 `127.0.0.1:8090`
- CMPP 入站端口:`17890`,由 Go Gateway 启动真实 CMPP 3.0 Server,接收企业应用下游 connect/login 和 submit。
## 预生产磁盘与目录规划
本节是预生产 `8.160.169.106` 的存储规范入口;实施证据见 [第一阶段记录](preprod-data-disk-stage1-20260831.md)和[第二阶段记录](preprod-data-disk-stage2-20260831.md),阶段完成状态见 [测试与运维进度](testing-progress.md)。旧版部署示例与本节冲突时,以本节为准。
### 当前状态与实施边界
- 以下已实施状态来自2026-08-31晚间第二阶段验证,不代表每次阅读时已重新核验服务器。
- 60GiB系统盘承载操作系统、运行程序、应用日志和监控历史数据;100GiB数据盘挂载 `/data`,已承接PostgreSQL、Redis、MinIO、版本恢复备份及原备份目录内的操作记录。
- 用户明确授权后已完成第二阶段三项存储迁移;应用日志、监控历史数据及其他表内待实施项不包含在本次迁移中,不得把规划当成实施事实。
- 本次迁移的系统盘原数据副本及一致性冷备暂作恢复资产保留,不属于新增旧代码版本。后续清理或进一步迁移需按明确指令执行,不能直接切回已落后于新写入的旧数据。
### 目录分工
| 内容 | 当前路径或位置 | 规划位置 | 实施状态与要求 |
|---|---|---|---|
| 操作系统、软件包、服务配置及密钥 | 系统盘;配置主要在 `/etc` | 继续留在系统盘 | 配置按最小权限保存,不因数据迁移改为公开可读 |
| 当前运行程序与依赖 | `/opt/cmpp-platform` | 继续留在系统盘 | 发布切换只处理程序,不携带持久化数据 |
| 上一个版本运行目录 | `/opt/cmpp-platform.previous-*` | 继续留在系统盘 | 稳态仅保留上一个有效版本;不得积累失败、incoming及更早版本目录 |
| 数据库、Redis、源码与配置的成套恢复备份 | `/opt/cmpp-platform-backups` | `/data/cmpp-platform-backups` | **已实施**;原入口是指向数据盘的符号链接,发布脚本不得替换成普通目录 |
| 备份目录内业务操作留痕 | `/opt/cmpp-platform-backups/operations` | `/data/cmpp-platform-backups/operations` | **已实施**;不纳入旧版本自动清理范围 |
| PostgreSQL业务数据及WAL | `/var/lib/pgsql/data` | `/data/postgresql/data` | **第二阶段已实施**`/data/postgresql`绑定挂载到`/var/lib/pgsql`,无外置表空间或WAL链接,完整冷复制及110张表行数核对通过 |
| Redis持久化文件 | `/var/lib/redis` | `/data/redis` | **第二阶段已实施**;绑定挂载保留旧路径,保持RDB开启、AOF关闭,停写SAVE后冷复制,三条Stream状态一致 |
| MinIO对象及存储元数据 | `/var/lib/minio` | `/data/minio` | **第二阶段已实施**;绑定挂载保留旧路径,完整目录及62个业务对象清单核对通过,独立探针读写删除通过 |
| 本地对象存储备用驱动的上传文件 | 配置项 `OBJECT_STORAGE_LOCAL_ROOT` | 如启用则规划为 `/data/cmpp-platform/object-storage` | 条件规划;第一阶段未启用或切换存储驱动,禁止擅自改用local替代MinIO |
| API、各Worker、Gateway应用日志 | `/opt/cmpp-platform/logs/<service>` | `/data/log/cmpp-platform/<service>` | **待单独实施**;需同步日志输出路径、权限和轮转,不能因迁盘取消保留上限 |
| Prometheus历史指标 | 仓库安装脚本使用 `/var/lib/prometheus/metrics2` | `/data/prometheus/metrics2` | **待核验并单独实施**;当前值以实际服务参数为准,保留时间及容量双重限制 |
| 系统journal、系统及安全日志 | `/var/log` 等系统路径 | 继续留在系统盘 | 设置轮转及容量上限;不能将安全审计日志当旧发布文件删除 |
待实施目标子目录应在相应迁移时创建,不提前启动服务写入。目前备份入口使用符号链接,三项存储使用绑定挂载;其他规划路径不假设已有链接、挂载或服务配置支持。
### 挂载、权限与缺盘保护
- 使用磁盘UUID持久化挂载,不把 `/dev/nvme1n1` 的枚举顺序作为永久身份。当前预生产UUID与fstab记录见第一阶段文档;其他主机不得照抄该UUID或格式化命令。
- 第一阶段 `/data` 采用 `nofail`,仅保证缺盘时系统仍可启动;这不意味着未来依赖数据盘的数据库可以在缺盘时启动。
- 现有备份保护由 `/usr/local/sbin/cmpp-backup-storage-check` 和未挂载时底层空 `/data` 的immutable属性共同提供。不得移除保护、重建系统盘备份目录或在检查失败后改用其他路径继续备份。
- 第二阶段已为`postgresql``redis``cmpp-minio`配置`50-cmpp-data-disk.conf`,通过`RequiresMountsFor``After``BindsTo`关联`data.mount`及各自绑定挂载;`ExecStartPre`调用`/usr/local/sbin/cmpp-data-storage-check`核验UUID、挂载源目录、读写属性和原有存储标记。后续服务迁入也必须在启动前建立同等保护。
- 挂载失败、UUID不匹配或数据盘只读时,备份及相应迁入服务应明确失败并告警,禁止初始化新数据库、空Redis或空MinIO来掩盖故障。
- 各数据目录归对应服务的实际运行用户,保留原权限、ACL、扩展属性与适用的SELinux上下文;核验systemd沙箱的 `ReadWritePaths` 等限制。禁止对整个 `/data` 执行统一业务用户授权或 `chmod 777`
- 三项原路径通过fstab中的绑定挂载兼容,底层空挂载目录保持immutable,缺盘时不能写回系统盘。部署与安装脚本检测`/etc/cmpp-platform/data-disk.uuid`或PostgreSQL存储drop-in后,先执行存储检查;不得删除标记或drop-in绕过检查。尚未以整机重启验证开机过程。
### 备份、保留与容量规则
- 所有后续发布备份统一使用固定入口 `/opt/cmpp-platform-backups`,先执行存储检查,失败即中止;禁止使用随发布切换的 `/opt/cmpp-platform/backups`
- 发布前生成数据库、运行代码和配置的成套恢复点;涉及Redis队列或对象存储的变更,还须包含相应一致性恢复资产。SHA-256、数据库备份目录和压缩包可读性检查通过不等于已完成实际恢复演练。
- 版本保留目标是“当前运行版本 + 上一个有效运行版本 + 上一个版本的完整恢复备份”。发布期间允许暂时保留原有效恢复点与新候选恢复点;新恢复点校验、发布验收和版本对应关系确认前,不能先删唯一有效备份。
- 失败发布、临时传输包、incoming及更早恢复点不长期保留;先确认不被进程引用、无待诊断事故或未完成回退,并按当次明确授权清理。文档中的保留目标不等于已安装自动清理任务,也不授权定时删除业务操作记录。
- 日志和监控数据需要轮转、压缩及容量上限。应用日志保留期不得替代数据库中操作日志的业务保留规则,短信、账务和审计数据不得因磁盘告警直接删除。
- 系统盘与数据盘分别监控使用率、可用字节、inode、读写异常和增长速度。规划告警分级为70%预警、80%严重、90%紧急;属于**待配置并验证的运维目标**,本次文档更新不表示告警已上线。
- 发布或迁移前按实际数据量核算源/目标临时副本、恢复备份、数据库WAL与Redis持久化重写所需空间;余量不足则停止该次操作,不以删除唯一恢复点腾空间。
- 数据和备份同在这块100GiB盘后会共用容量及故障风险。同机备份不等于异机灾备,异机或对象存储副本须另行实施并验证恢复能力;当前不声称已经配置。
### 第二阶段与后续发布验收
1. 收到明确指令后,重新核对实际磁盘、服务配置、运行用户、数据量及业务流量;为每个服务记录源路径、目标路径、复制方式、切换条件和回退点。
2. 先建立可验证的一致性恢复资产。不得把普通在线目录复制视为PostgreSQL或Redis的一致备份;停写窗口或复制同步方式另定。
3. 短信切换前确认入口接收策略、在途提交、供应商回执、持久化Inbox/Outbox和Redis Streams;不得清队列、手工ACK/XDEL或重投真实短信来缩短迁移窗口。
4. 切换后验证实际文件落盘位置、挂载依赖、用户权限、数据库和对象读写、队列积压、供应商连接及API健康;真实短信测试需单独授权。所有检查通过前保留源数据副本,不能只凭HTTP 200删除它。
5. 若新位置已接受业务写入,回退必须包含新增数据的一致性处理,不能直接切回已经落后的旧目录。
6. 同步部署脚本、systemd配置及安装器,防止下次发布把数据目录、日志路径或挂载依赖恢复成旧默认值。当前仓库部署脚本仍向 `$APP_DIR/logs` 输出日志,不能把本文目标路径当作已经实现的脚本行为。
7. 完成状态、配置证据和未验证项写入阶段记录及 `testing-progress.md`;本节维护长期规范,不将一次实施记录当成永久实时状态。
## 首次部署
1. 本机提交并推送 `main`
@@ -166,8 +227,15 @@ redis-cli XINFO GROUPS gateway.submit.results
1. 数据库备份:
预生产 `8.160.169.106` 已于 2026-08-31 完成数据盘第一阶段:`/opt/cmpp-platform-backups` 指向 `/data/cmpp-platform-backups`。后续备份先执行存储检查,再写入该固定入口;不得重新写入随运行目录切换的 `/opt/cmpp-platform/backups`。其他环境需先完成相应存储配置,不可直接套用预生产的磁盘 UUID。配置、验证及第二阶段边界见 [数据盘第一阶段记录](preprod-data-disk-stage1-20260831.md)。
```bash
pg_dump "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)" > /opt/cmpp-platform/backups/cmpp-$(date +%F-%H%M%S).sql
set -e
/usr/local/sbin/cmpp-backup-storage-check
set -a
. /etc/cmpp-platform/cmpp-platform.env
set +a
pg_dump "${DATABASE_URL%%\?*}" > /opt/cmpp-platform-backups/cmpp-$(date +%F-%H%M%S).sql
```
2. 回滚代码:
@@ -15,7 +15,8 @@
5. 增加单条通道报备明细的签名资料导出,按目标通道的真实字段配置生成一行报备文件。
6. 优化“短信通道管理—报备详情”的分页、今日发送排序、查询、状态修改和报备资料查看体验。
7. 企业签名新增或报备资料真实变化并提交成功后,提示用户前往报备资料池生成批次。
8. 企业签名列表展示最新材料版本尚待生成批次的签名报备明细
8. 企业签名搜索区下方集中展示最新材料版本尚待生成批次的签名报备明细总数,不在每条签名行内重复展示
9. 每个报备批次按通道生成可查看、可复制的供应商简报,并将简报取值固化在批次资料快照中。
本轮不改变短信发送、队列、计费、通道路由和供应商协议链路,不发送、补发、重投或重新入队短信。
@@ -33,7 +34,7 @@
- 保留 XLSX 解析、MinIO 原文件/图片存储、图片/文件字段映射和通道 XLSX 嵌图能力。
- 本轮不新增文件格式、文件清理、压缩包、批量下载或回执文件解析能力。
- 已生成批次详情展示和下载现有 `ReportExportFile`不改变文件生成与保存逻辑。
- 已生成批次详情继续展示和下载现有 `ReportExportFile`并按每个通道展示本批次简报;不改变 XLSX 文件格式和保存逻辑。
### 2.3 已有签名导入视为补资料
@@ -97,16 +98,22 @@
点击“查看批次”打开大尺寸批次详情层。首版沿用平台现有 Modal 体系,不新增独立路由;如果真实批次规模导致弹层不可用,再升级为独立详情页。
详情分为个区域:
详情分为个区域:
1. 批次摘要
- 批次号、生成时间、生成状态、所选资料数、通道数、文件数。
- 报备总数、通过数、成功率。
- 部分失败或失败时展示真实 `errorMessage`,不得用统一成功提示覆盖。
2. 通道文件
2. 通道简报
- 每个通道一份,日期取批次生成时间,批次号取 `batchNo`
- 签名资料行格式为“短信签名:【签名】,短信内容:内容”;引流资料行格式为“引流信息:URL或号码,短信内容:内容”。
- “短信内容”只从该通道按 `sortOrder ASC, createdAt ASC` 排列的字段库中取第一个名称为“短信内容”的字段;没有则留空,后续同名字段不参与简报。
- 简报取值写入现有批次 JSON 快照,不新增数据库字段或 migration;字段库或签名资料后续变化不得改写历史批次简报。
- 页面支持查看全文和一键复制,复制失败时保留可手工选择的原文。
3. 通道文件
- 按通道显示文件名、行数、生成状态和下载入口。
- 文件对象缺失时显示“文件不可用”,不渲染失效下载按钮。
3. 报备明细
4. 报备明细
- 一行对应一个真实 `ChannelSignatureReportTask`
- 展示签名/引流对象、企业、应用、通道、运营商、文件行号、资料版本、当前状态和更新时间。
- 支持按关键字、报备类型、状态和通道筛选,使用后端分页。
@@ -130,7 +137,7 @@
页面能力包括:
- 按企业、应用、签名/引流对象、通道、运营商、状态和关键字筛选;
- 单选、当前页选择规则明确的批量选择、批量修改报备状态
- 单选、当前页全选/取消全选、批量修改报备状态;翻页或重新查询后清空当前选择,避免误操作其他页数据
- 展示真实任务号;尚未落库的未报备行显示“首次操作后生成”,不得伪造任务号;
- 查看资料字段、材料版本、当前状态及状态轨迹;
- 从批次详情进入时限定当前批次,从资料池进入时限定当前资料对象;
@@ -287,9 +294,9 @@ reportPoolAvailableAfter: "immediate" | "approval"
由后端在提交事务内判断材料是否变化并返回最终材料版本,前端只负责展示与跳转。
#### 3.10.2 企业签名列表的待生成报备明细数
#### 3.10.2 企业签名的待生成报备明细
企业签名列表增加“待生成报备明细数”列。该数字按每个签名最新材料版本计算,统计尚需进入报备批次的有效签名明细:
企业签名搜索条件区域下方增加一个“待生成报备明细数”汇总,不在签名列表逐行增加字段。该数字按当前查询条件中的签名最新材料版本计算,统计尚需进入报备批次的有效签名明细:
```text
企业应用 × 签名 × 通道 × 运营商
@@ -305,15 +312,13 @@ reportPoolAvailableAfter: "immediate" | "approval"
6. 一个组合即使存在多条历史批次或状态记录,也只能计数一次。
7. 数量必须由后端随签名分页列表批量计算并返回,禁止前端逐行请求或加载全量任务后统计。
建议企业签名分页响应每行增加:
企业签名分页响应增加页级汇总
```text
pendingReportDetailCount: number
pendingReportMaterialVersion: number | null
pendingReportBlockedReason: string | null
pendingReportDetailTotal: number
```
点击数量进入“通道报备明细”,自动带入当前签名、最新材料版本及“待生成范围;页面另提供“前往报备资料池”入口。数量为 0 时不伪装成可点击链接
点击“查看明细”进入“通道报备明细”待生成范围。签名表头不提供排序按钮,列表继续使用系统默认创建时间倒序
## 4. 已有签名补资料 Bug 修复
@@ -651,6 +656,9 @@ GET /api/admin/channels/:channelId/report-details
- 批次明细使用真实后端分页和筛选。
- 批次内修改状态后,任务、记录、通过数和成功率同步。
- 生成中、部分失败、失败、文件缺失和历史数据正确展示。
- 每个通道显示一份可复制简报;日期与批次生成日期一致,批次号一致,签名和引流行格式正确。
- 同一通道存在多个“短信内容”字段时只取字段顺序第一项;没有该字段时短信内容为空。
- 批次生成后修改字段库或签名资料,历史批次简报保持原快照内容。
### 9.6 短信通道管理—报备详情
@@ -716,5 +724,7 @@ GET /api/admin/channels/:channelId/report-details
10. “查看报备资料”严格按当前通道的签名或引流字段配置顺序展示,历史剩余字段放在独立区域。
11. 修改状态弹层只重做信息层级和交互,不新增状态枚举,不擅自改变原因必填规则。
12. 企业签名新增或报备资料真实变化并成功提交后显示引导弹窗,目标页面固定为“报备工作台—报备资料池”;待审核对象必须说明审核前不可生成。
13. 企业签名列表展示的是最新材料版本尚待生成批次的“通道 × 运营商”明细数,不是签名数,也不是历史任务总数
14. 状态记录性能索引、今日发送排序及待生成明细计数所需索引均以真实查询计划为准,不预先创建无证据索引或汇总表
13. 企业签名搜索条件下方展示一个最新材料版本尚待生成批次的“通道 × 运营商”明细总数;签名列表不展示逐行待生成字段,也不提供签名排序按钮
14. 通道报备明细的“全选”只选择当前页面,翻页或重新查询后清空选择
15. 每个批次按通道生成简报;简报日期取批次生成日期,签名与引流信息分别使用已确认格式,“短信内容”只取通道字段库顺序中的第一个同名字段。
16. 状态记录性能索引、今日发送排序及待生成明细计数所需索引均以真实查询计划为准,不预先创建无证据索引或汇总表。
+7 -2
View File
@@ -4999,7 +4999,7 @@ npm run verify:phase8
| TC-ENTERPRISE-SIGNATURE-DENSITY-001 | 首次打开运营端企业签名管理 | 不请求或展示顶部全部/待审核/报备中/异常统计;短信签名Tab不显示总数;列表使用紧凑表头与行布局 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-002 | 查看签名及展开的引流信息 | 两级表头统一使用“审核状态”;签名及引流信息的移动/联通/电信列同时显示汇总状态及已通过通道数/总通道数,不显示日期;引流信息列只显示条数,不显示异常数 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-003 | 查看签名查询和签名列 | 查询标签及表头统一为“签名”,不显示签名用途;签名统一显示完整中文黑括号,例如“【聆界科技】”,已有括号不得重复包裹 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-004 | 点击签名表头旁的向上或向下三角按钮并翻页 | 请求携带signatureSort=asc/desc,后端先按签名及ID稳定排序再分页;切换排序回第一页,不只重排当前页,不产生重复请求 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-004 | 查看签名表头并翻页 | 签名表头不显示排序按钮,请求携带signatureSort;后端按创建时间倒序分页,不在前端重排当前页,不产生重复请求 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-005 | 查看签名及引流信息操作列 | 两级操作列都只展示“报备状态、编辑、删除”;不显示“报备详情”;三个入口继续调用原真实后端流程 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-006 | 1600×1000桌面视口查看并展开首条签名 | 表头与数据列对齐,操作按钮不换行,展开表格无裁切;页面无框架错误层,控制台无相关错误;窄视口由列表容器横向滚动,不挤压错列 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-007 | 某一运营商下所有当前目标通道的报备任务均为abandoned | 该签名及其引流信息在对应运营商列汇总为“放弃报备”,同时显示0/总通道数;仅部分通道放弃时不得误判为全部放弃 |
@@ -5009,7 +5009,7 @@ npm run verify:phase8
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-REPORT-WORKBENCH-001 | 打开运营端导航及四个报备页面 | 一级菜单为“报备工作台”,二级菜单依次为“报备资料池、报备批次、通道报备明细、状态记录”;资料池和批次不再共用页签 |
| TC-REPORT-WORKBENCH-002 | 新增审核通过签名或修改报备相关资料 | 后端返回资料变化标识;页面提示到报备资料池生成批次;企业签名列表展示最新版本尚未生成的通道×运营商明细数并可下钻 |
| TC-REPORT-WORKBENCH-002 | 新增审核通过签名或修改报备相关资料 | 后端返回资料变化标识;页面提示到报备资料池生成批次;搜索条件下方集中展示当前条件内最新版本尚未生成的通道×运营商明细数并可下钻,签名列表不展示该字段 |
| TC-REPORT-WORKBENCH-003 | 查看存在有效应用路由但尚未生成任务的签名 | 通道报备明细按企业应用×签名×通道×运营商显示虚拟“未报备”行,可单选或多选后通过真实状态接口创建/更新任务并写状态记录 |
| TC-REPORT-WORKBENCH-004 | 将一条通道运营商明细设为放弃报备后预检批次 | 仅该通道运营商组合被排除,其他有效组合仍可生成;不得发送、补发、重投或重新入队短信 |
| TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表显示真实文件、通道和进度;“打开明细”展示该批次对应任务,可批量修改状态及按签名明细导出 |
@@ -5019,6 +5019,11 @@ npm run verify:phase8
| TC-REPORT-WORKBENCH-009 | 导入命中已有签名且用途列未映射或为空 | 识别为补资料;未提供字段保持原值,提供的动态字段覆盖同名值并追加新字段;用途不得被空字符串清空;审核前不改真实签名 |
| TC-REPORT-WORKBENCH-010 | 在状态记录按批次、操作人、状态、入口、对象和时间搜索 | 返回真实状态记录及操作人;可追溯人工修改来源;分页、空数据、失败和历史无入口记录均正确展示 |
| TC-REPORT-WORKBENCH-011 | 桌面及390px窄屏查看四页和状态弹窗 | 桌面表格可扫描;窄屏核心主体、状态、今日发送和操作可访问,无按钮遮挡;控制台无新增错误,所有业务数据来自真实API/PostgreSQL |
| TC-REPORT-WORKBENCH-012 | 查看企业签名表头和签名列表 | 签名列不显示升序、降序或其他排序按钮;列表不显示逐行待生成明细字段;默认按创建时间倒序 |
| TC-REPORT-WORKBENCH-013 | 通道报备明细当前页有10条数据,点击“全选当前页” | 当前页10条全部选中,按钮变为“取消全选”,批量数为10;翻页或查询后选择清空,不选中其他页面数据 |
| TC-REPORT-WORKBENCH-014 | 生成同时包含签名和引流资料、覆盖多个通道的批次 | 每个通道生成一份简报;日期取批次生成日期,批次号一致;签名行和引流行分别按已确认格式展示,且可一键复制完整原文 |
| TC-REPORT-WORKBENCH-015 | 通道配置0个、1个或多个名称为“短信内容”的字段 | 0个时简报短信内容为空;1个时取该字段;多个时严格按`sortOrder ASC, createdAt ASC`取第一个字段值,后续同名字段不参与 |
| TC-REPORT-WORKBENCH-016 | 批次生成后修改字段库或签名/引流资料 | 历史批次简报仍使用生成时固化的批次快照,不随当前资料改变;不新增数据库migration,不触发短信发送或队列 |
## TC-HIGH-FREQUENCY-QUERY-20260902 高频查询与按需详情
+35
View File
@@ -4182,6 +4182,21 @@ git diff --check
- 最终`.deployed-commit=1a5063a7c635280b912021c42c33a07c99f4c5dd`95/95项migrationAPI、Send Worker、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、Security Agent、MinIO、Nginx、PostgreSQL和Redis共11项均active,三项拆分服务均enabled。API、Callback、Gateway健康,9464/9465/9467/9468指标端点可用,Callback池`max=12,total=1,idle=1,waiting=0`,供应商连接9/9,数据库活动连接15/100Submit Outbox为0;命令、结果、协议日志三条Stream均`pending=0/lag=0`,修复发布窗口8项服务error级journal均为空。
- 首页、客户端登录、运营端登录及API健康从工作站直连均HTTP 200;实际资源仍为`index-C_Eutz1B.js``index-BB9q6lcg.css`,工作站与服务器SHA-256一致。Chrome只读导航该私网HTTP页面再次超时,因此不声称完成浏览器DOM/控制台验收。全程未发送、补发或重投短信,未修改余额、通道、客户或签名/引流业务配置。
## 2026-08-31 预生产磁盘清理与数据盘第一阶段
- 用户授权只保留上一个版本后,核验`4d526b8`的上一运行目录和完整恢复点,删除52项旧发布/失败/回滚目录及旧部署备份,释放约31.2GiB,系统盘占用80%降至25%;保留业务操作记录,未修改数据库或重启服务。历史章节所列更早恢复点是历史证据,不代表清理后仍可用。
- 用户随后明确授权仅执行数据盘第一阶段,第二阶段等待今晚指令。100GiB数据盘已建GPT/ext4分区并按UUID挂载`/data``/etc/fstab`验证通过,完成挂载单元启停验证,未重启整机。
- 固定备份入口`/opt/cmpp-platform-backups`原子切换为指向`/data/cmpp-platform-backups`的符号链接;约573MiB备份以10MiB/s限速迁移,21个文件/目录条目的内容、属主、权限、mtime和扩展属性一致,SHA256SUMS、数据库备份目录及两份tar可读性全部通过,再删除系统盘副本。
- `/usr/local/sbin/cmpp-backup-storage-check`核验挂载及UUID;底层空`/data`设置immutable。已实测缺盘时检查拒绝执行且root不能写回系统盘,挂载后原备份入口实际写入数据盘。未创建自动备份或自动迁移计划。
- 09:35复核系统盘24%(可用44G),数据盘1%(可用97G);11项服务active且PID与操作前一致,API/Callback/Gateway健康、PostgreSQL接受连接、Redis PONG,所检查服务error级journal计数0。代码版本仍为`1a5063a`PostgreSQL/Redis/MinIO和运行目录均未迁移,未执行第二阶段。
- 配置、保护机制、验证范围及证据路径见`docs/preprod-data-disk-stage1-20260831.md`。本轮只修改服务器存储配置/备份入口和本地运维文档,未提交、推送或发布代码。
## 2026-08-31 预生产存储规范文档补齐(仅本地文档)
- `docs/production-deployment.md`新增“预生产磁盘与目录规划”,统一系统盘/数据盘分工、目标目录、挂载和权限保护、版本保留、容量管理及第二阶段验收要求;区分09:35已记录的第一阶段状态和待实施规划。
- 第一阶段记录增加到正式规范的链接;早期Linux部署方案标记为历史示例,统一固定备份入口、区分SQL/custom备份恢复,并纠正“删除未消费Redis队列”的旧回滚指引。
- 本次仅修改本地Markdown文档,没有访问或修改服务器、调整脚本、启用告警/清理任务、迁移业务数据、提交或推送。第二阶段仍未执行,须等待用户明确指令。
## 2026-08-31 六项运营/客户端修复(测试环境发布准备)
- 本轮仅授权测试环境 `100.93.204.60`,不访问、不部署预生产。开始时核验 main/HEAD `119d577` 与测试部署标记 `5328bb09bf89170b4368407e896dbce9527a7b5c`;原有4份已修改文档及3项未跟踪文件完整保留,不纳入本轮提交。
@@ -4207,6 +4222,17 @@ git diff --check
- 回归结果维持API 581项、前端45项通过,测试用例见 `TC-PORTAL-001..010`。浏览器插件在网络恢复后仍多次导航、截图及DOM读取超时,真实页面视觉效果、浏览器控制台及登录后交互验收尚未完成,留待用户在测试环境验收,不标记为已通过。
- 证据:服务器 `/tmp/cmpp-sixfix-deploy.log``/tmp/cmpp-sixfix-before.json``/tmp/cmpp-sixfix-after.json` 及恢复点清单。提交时仅纳入本轮代码/用例/进度追加,原有4份修改文档及3项未跟踪文件仍保留为未提交状态。
## 2026-08-31 预生产数据盘第二阶段完成
- 用户明确授权后重新核验预生产`8.160.169.106`,仅迁移PostgreSQL、Redis、MinIO。停写窗口22:31:57至22:33:1477秒);API短时不可用、CMPP连接断开后重连,未宣称零中断。
- 当前版本恢复点`/data/cmpp-platform-backups/preprod-storage-stage2-20260831T142327Z`包含在线PostgreSQL custom dump、全局角色、运行目录、系统配置、冻结前后表/Stream/对象清单、冷文件校验清单及三服务一致性冷备`cold-storage.tar.gz`。配置备份首次因不存在的`/etc/sysconfig/pgsql`中止,重新生成并校验后才进入停写窗口;不是带着不完整备份切换。
- 停入口/Worker并核对Gateway在途和Outbox为0,停Gateway/Callback/Protocol Log Worker,执行Redis SAVE后停止三项存储。PostgreSQL状态`shut down`,以40MiB/s复制:PG 2225条目/2373269966字节、Redis 2条目/184085437字节、MinIO 249条目/40914208字节;全部文件内容SHA-256、属主、权限、mtime及扩展属性一致。
- `/data/postgresql``/data/redis``/data/minio`分别绑定挂载至原`/var/lib/pgsql``/var/lib/redis``/var/lib/minio`fstab和三个`50-cmpp-data-disk.conf`建立启动/存续依赖,ExecStartPre检查UUID、绑定源、只读状态和既有存储标记。底层空挂载点immutable;隔离mount namespace中缺盘、缺绑定、错绑定和只读负向检查通过,宿主挂载及存储服务PID未受负向测试影响。
- 恢复业务前110张public表精确行数一致,三条Stream的长度及组状态一致,62个对象清单一致;PostgreSQL临时表事务、Redis随机key和MinIO随机对象的真实读写/回滚或删除通过。未发送或重投短信,不以探针冒充端到端短信验收。
- 最终11项服务active,上游9/9、下游4个,命令/结果/协议Stream pending/lag均0API/Callback/Gateway/MinIO健康、PostgreSQL接受连接、Redis PONG、外网API健康通过。重连初期下游短暂为2个,随后已恢复4个;恢复后所查服务error级journal为0。
- 数据盘承载业务数据,系统盘仍保留约2.4GiB三项原数据冷副本作本次迁移保护;禁止新位置接受写入后直接切回旧副本。上一代码版本`4d526b8`恢复点继续保留;日志、监控数据未迁移,整机重启与异机恢复未测试。
- 本地及线上两份部署/安装脚本加入存储前置检查,新增`tools/deploy/check-data-storage.sh`;bash语法和缺挂载拒绝执行验证通过。业务产物标记仍为`1a5063a`,仅运维脚本及存储配置改动;未Git提交、推送或发布新业务产物。详见`docs/preprod-data-disk-stage2-20260831.md``outputs/data-disk-stage2-*.txt`
## 2026-08-31 迁移后预生产六项修复发布准备
- 用户明确授权推送最新代码并发布预生产,提醒数据迁移已完成。重新读取迁移记录及线上事实:预生产业务标记仍为 `1a5063a`,三项存储分别绑定到 `/data/postgresql``/data/redis``/data/minio`,数据盘UUID为 `ef4ee3bb-a19b-4aeb-b00c-aa2b995611c2`,备份入口解析到 `/data/cmpp-platform-backups`12项服务active,三条Stream pending/lag为0。
@@ -4303,3 +4329,12 @@ git diff --check
- 实际线上主资源 `index-AXwrLCns.js``index-BbnYYqYM.css` 及企业签名分块 `AdminEnterpriseSignaturesPage-DfsTejUl.js` 的SHA-256分别为 `5327e20e224dcabd9bebf2c6e1296e164e40ba7380df089b49e7c21926378661``719da5355b1cbfb780438935f31935fa028f9468377a68b9192a9693951da3ab``fc3c26c4e6abc86d3c94e77b60e3866d26b82ad8462da5da406a6e1258344ec7`,工作站HTTP回读与服务器一致;外部API健康连续3次HTTP 200。
- Browser插件不可用,Chrome接管导航超时;显式绕过本机代理后,工作区Playwright Chrome确认测试环境运营登录页HTTP 200、页面非空、无框架错误层。测试机保存的管理员凭据已失效并返回“用户名或密码错误”,两端临时凭据随即删除;未重置密码、未创建账号,因此登录后的企业签名按钮和真实Network P50/P95尚未在本轮浏览器验收,不以自动化组件测试冒充线上交互通过。
- 全程未发送、补发或重投短信,未修改余额、客户、通道、签名或引流业务配置。原有未提交文档及未跟踪文件继续保留,不覆盖、不夹带。
## 2026-09-02 报备工作台批量操作与通道简报(测试环境发布准备)
- 企业签名管理移除签名排序入口和逐行“待生成明细”,恢复按创建时间倒序;搜索条件下方集中展示当前筛选范围内、最新资料版本尚待生成批次的“企业应用 × 签名 × 通道 × 运营商”明细总数,并可进入通道报备明细。
- 通道报备明细增加“全选当前页/取消全选”,翻页、查询或重新加载后清空选择,不跨页隐式选中。报备批次详情按通道展示可复制简报;日期取批次创建时间的北京时间,签名和引流资料使用各自格式,短信内容取通道字段库排序后的第一个“短信内容”字段,没有时留空。
- 简报短信内容在批次生成时写入既有批次明细JSON快照,历史批次不会随当前字段库或资料变化;没有新增数据库字段或migration。引流资料优先展示快照中的引流URL,无URL时回退站点名称。
- 定向API 2套83项、前端2文件4项通过;全量API 51套592项通过。全量前端首次11文件50项通过且另1个工作进程启动超时,超时文件单独重跑3项通过,合计12文件53项业务测试通过;前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、R4页面契约、包体积及`git diff --check`通过。Vite仅保留既有Chart分块超过500kB提示,入口gzip约107.61KiB,低于250KiB预算。
- Browser插件不可用,按前端调试技能回退到本机Playwright Chrome,对本地生产构建完成1600×1000和390×844核验:企业签名页无排序和逐行待生成字段、汇总条可见;当前页全选后批量数正确;批次内签名/引流简报及复制入口可见;页面无新增控制台错误。隔离页面数据只用于布局和交互核验,不冒充真实API/PostgreSQL验收。
- 发布范围仅限测试环境`100.93.204.60`,不推送远端、不访问预生产。发布前已只读确认测试机当前标记`ad89e8fed793857daf18ea6cebb995edfe3b74d4`、核心服务健康、三条Redis Stream均`pending=0 / lag=0`。后续只从既有可报备资料生成少量测试批次,不创建通道、客户或签名资料,不发送、补发、重投或重新入队短信;部署恢复资产、最终标记、测试批次及真实接口结果待完成后追加。
+2 -2
View File
@@ -40,8 +40,8 @@ export const adminGovernanceApi = {
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; signatureSort?: 'asc' | 'desc'; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsSignature> & { pendingReportDetailTotal: number }>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
getEnterpriseSignature: (id: string) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`),
+8
View File
@@ -155,6 +155,14 @@ export type ReportMaterialBatch = {
rowCount: number;
channelId?: string | null;
}>;
briefs?: Array<{
channelId: string;
channelName: string;
fileId: string;
fileName: string;
itemCount: number;
content: string;
}>;
items?: Array<{
id: string;
reportType: 'signature' | 'drainage';
@@ -40,12 +40,12 @@ export function AdminEnterpriseSignaturesPage() {
const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
const [signatureSort, setSignatureSort] = useState<'asc' | 'desc'>('asc');
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [total, setTotal] = useState(0);
const [pendingReportDetailTotal, setPendingReportDetailTotal] = useState(0);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
@@ -63,14 +63,14 @@ export function AdminEnterpriseSignaturesPage() {
drainageKeyword: appliedDrainageKeyword,
},
targetPage = page,
targetSort = signatureSort,
) {
const sequence = ++listRequestSequence.current;
try {
const signatureResult = await adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize });
const signatureResult = await adminApi.listEnterpriseSignaturesPage({ ...filters, page: targetPage, pageSize });
if (sequence !== listRequestSequence.current) return;
setSignatures(signatureResult.items);
setTotal(signatureResult.total);
setPendingReportDetailTotal(signatureResult.pendingReportDetailTotal);
setError('');
} catch (failure) {
if (sequence !== listRequestSequence.current) return;
@@ -216,14 +216,8 @@ export function AdminEnterpriseSignaturesPage() {
onOpenDrainageReport={(signature, item) => void openDrainageReport(signature, item)}
setExpandedSignatureId={setExpandedSignatureId}
setPage={setPage}
setSignatureSort={(nextSort) => {
setSignatureSort(nextSort);
if (page === 1) void loadData(undefined, 1, nextSort);
else setPage(1);
}}
onEditSignature={(signature) => void editSignature(signature)}
onOpenSignatureReport={(signature) => void openSignatureReport(signature)}
signatureSort={signatureSort}
total={total}
totalPages={totalPages}
visibleSignatures={visibleSignatures}
@@ -322,6 +316,15 @@ export function AdminEnterpriseSignaturesPage() {
</div>
</div>
<div className="enterprise-signature-pending-summary" role="status">
<span></span>
<strong>{pendingReportDetailTotal}</strong>
<span></span>
<Button onClick={() => navigate('/admin/report-tasks?scope=pending')} size="sm" variant="ghost">
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
+54 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { Check, Copy, Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import {
Breadcrumb,
@@ -44,6 +44,7 @@ export function AdminReportBatchesPage() {
const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const [copiedChannelId, setCopiedChannelId] = useState('');
const pageSize = 20;
function load(target = page, filters = appliedFilters) {
@@ -75,11 +76,32 @@ export function AdminReportBatchesPage() {
setDetail(batchDetail);
setTasks(taskPage.items);
setSelected(new Set());
setCopiedChannelId('');
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次明细加载失败');
}
}
async function copyBrief(channelId: string, content: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(content);
} else {
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
if (!document.execCommand('copy')) throw new Error('浏览器不支持自动复制');
textarea.remove();
}
setCopiedChannelId(channelId);
window.setTimeout(() => setCopiedChannelId((current) => (current === channelId ? '' : current)), 1800);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '简报复制失败,请手工选择复制');
}
}
async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id));
if (!chosen.length) return;
@@ -309,6 +331,37 @@ export function AdminReportBatchesPage() {
title={`批次明细 · ${detail.batchNo}`}
>
<div className="page-stack">
<section className="report-batch-briefs" aria-label="通道报备简报">
<div className="report-batch-briefs__title">
<div>
<h3></h3>
<p className="muted"></p>
</div>
</div>
{detail.briefs?.length ? (
detail.briefs.map((brief) => (
<article className="report-batch-brief" key={brief.channelId}>
<header>
<div>
<strong>{brief.channelName}</strong>
<span>{brief.itemCount} </span>
</div>
<Button
icon={copiedChannelId === brief.channelId ? <Check size={15} /> : <Copy size={15} />}
onClick={() => void copyBrief(brief.channelId, brief.content)}
size="sm"
variant="ghost"
>
{copiedChannelId === brief.channelId ? '已复制' : '复制简报'}
</Button>
</header>
<pre>{brief.content}</pre>
</article>
))
) : (
<p className="muted"></p>
)}
</section>
<div className="report-batch-toolbar">
<strong>
{tasks.length} {selected.size}
+24 -11
View File
@@ -176,6 +176,7 @@ export function AdminReportTasksPage() {
.then((result) => {
setTasks(result.items);
setTotal(result.total);
setSelected(new Set());
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备明细加载失败'));
@@ -348,6 +349,7 @@ export function AdminReportTasksPage() {
),
},
];
const allCurrentPageSelected = tasks.length > 0 && tasks.every((task) => selected.has(task.id));
return (
<section className="page-stack admin-sms-task-page report-task-page">
@@ -357,18 +359,29 @@ export function AdminReportTasksPage() {
<h1></h1>
<p> × / × × </p>
</div>
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
<div className="page-heading-actions">
<Button
disabled={!tasks.length}
onClick={() =>
setSelected(allCurrentPageSelected ? new Set() : new Set(tasks.map((task) => task.id)))
}
}}
>
{selected.size}
</Button>
variant="ghost"
>
{allCurrentPageSelected ? '取消全选' : '全选当前页'}
</Button>
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
}
}}
>
{selected.size}
</Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
@@ -0,0 +1,119 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminReportBatchesPage } from './AdminReportBatchesPage';
import { AdminReportTasksPage } from './AdminReportTasksPage';
const { adminApi, clipboardWriteText } = vi.hoisted(() => ({
adminApi: {
changeReportTaskStatuses: vi.fn(),
exportSingleReportMaterial: vi.fn(),
getReportMaterialBatch: vi.fn(),
getSingleReportMaterialDetail: vi.fn(),
listReportDetailsPage: vi.fn(),
listReportMaterialBatches: vi.fn(),
listReportMaterialBatchTasks: vi.fn(),
},
clipboardWriteText: vi.fn(),
}));
vi.mock('@/api/adminApi', () => ({ adminApi, fileDownloadUrl: (id: string) => `/api/files/${id}` }));
const task = (id: string) => ({
id,
tenantId: 'tenant-1',
signatureId: `signature-${id}`,
channelId: 'channel-1',
carrier: 'mobile',
reportType: 'signature',
status: 'pending',
createdAt: '2026-09-02T01:00:00.000Z',
updatedAt: '2026-09-02T01:00:00.000Z',
signature: {
id: `signature-${id}`,
name: `签名${id}`,
tenant: { id: 'tenant-1', name: '测试企业' },
application: { id: 'app-1', name: '测试应用' },
},
channel: { id: 'channel-1', name: '测试通道' },
});
describe('report workbench pages', () => {
beforeEach(() => {
Object.values(adminApi).forEach((method) => method.mockReset());
clipboardWriteText.mockReset().mockResolvedValue(undefined);
});
it('selects and clears every detail on the current page', async () => {
adminApi.listReportDetailsPage.mockResolvedValue({ items: [task('1'), task('2')], total: 2, page: 1, pageSize: 10 });
render(
<MemoryRouter>
<AdminReportTasksPage />
</MemoryRouter>,
);
const user = userEvent.setup();
const selectPage = await screen.findByRole('button', { name: '全选当前页' });
await user.click(selectPage);
expect(screen.getByRole('button', { name: '取消全选' })).toBeVisible();
expect(screen.getByRole('button', { name: '批量修改状态(2' })).toBeEnabled();
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).toBeChecked());
await user.click(screen.getByRole('button', { name: '取消全选' }));
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
});
it('shows and copies the channel brief returned by the batch detail API', async () => {
const batch = {
id: 'batch-1',
batchNo: 'RB20260902090000TEST',
status: 'completed',
selectedCount: 1,
channelCount: 1,
fileCount: 1,
reportTotal: 1,
successCount: 0,
successRate: 0,
createdAt: '2026-09-02T01:00:00.000Z',
exportFiles: [],
};
const content =
'尊敬的供应商您好,今天是2026年9月2日,辛苦报备以下签名或引流信息:\n' +
'1.短信签名:【测试签名】,短信内容:【测试签名】您本次提交的验证码1234;\n' +
'报备材料在表格里,报备批次号RB20260902090000TEST。';
adminApi.listReportMaterialBatches.mockResolvedValue({ items: [batch], total: 1, page: 1, pageSize: 20 });
adminApi.getReportMaterialBatch.mockResolvedValue({
...batch,
briefs: [
{
channelId: 'channel-1',
channelName: '测试通道',
fileId: 'file-1',
fileName: '测试通道.xlsx',
itemCount: 1,
content,
},
],
});
adminApi.listReportMaterialBatchTasks.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 100 });
render(
<MemoryRouter>
<AdminReportBatchesPage />
</MemoryRouter>,
);
const user = userEvent.setup();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteText },
});
await user.click(await screen.findByRole('button', { name: '打开明细' }));
expect(await screen.findByText('测试通道')).toBeVisible();
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
await user.click(screen.getByRole('button', { name: '复制简报' }));
await waitFor(() => expect(clipboardWriteText).toHaveBeenCalledWith(content));
expect(screen.getByRole('button', { name: '已复制' })).toBeVisible();
});
});
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { ClientSmsSignature } from '@/api/adminApi';
@@ -51,8 +50,6 @@ function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTa
setDeleteTarget: vi.fn(),
setExpandedSignatureId: vi.fn(),
setPage: vi.fn(),
setSignatureSort: vi.fn(),
signatureSort: 'asc',
...overrides,
};
return {
@@ -84,20 +81,14 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
screen.getAllByRole('button', { name: /报备状态|编辑|删除/ }).forEach((button) => {
expect(button).toHaveClass('ui-button--sm', 'enterprise-signature-action-button');
});
expect(screen.getByRole('button', { name: '4 条' })).toBeVisible();
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
});
it('requests a real descending sort from the signature column control', async () => {
const setSignatureSort = vi.fn();
renderTable({ expandedSignatureId: '', setSignatureSort });
await userEvent.click(screen.getByRole('button', { name: '签名序' }));
expect(setSignatureSort).toHaveBeenCalledWith('desc');
expect(screen.getByRole('button', { name: '签名升序' }).querySelector('.lucide-triangle')).toBeInTheDocument();
expect(
screen
.getByRole('button', { name: '签名降序' })
.querySelector('.enterprise-signature-table__sort-triangle--down'),
).toBeInTheDocument();
expect(document.querySelector('.lucide-arrow-up, .lucide-arrow-down')).not.toBeInTheDocument();
it('keeps the signature header plain without sorting controls or per-row pending counts', () => {
renderTable({ expandedSignatureId: '' });
expect(screen.getByText('签名')).toBeVisible();
expect(screen.queryByRole('button', { name: '签名序' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '签名降序' })).not.toBeInTheDocument();
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
});
});
@@ -1,7 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
import { ChevronDown, ChevronRight, Edit3, Plus, Triangle } from 'lucide-react';
import { ChevronDown, ChevronRight, Edit3, Plus } from 'lucide-react';
import type { ClientSmsSignature } from '@/api/adminApi';
import { useNavigate } from 'react-router-dom';
import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
import {
AuditStatusTag,
@@ -24,10 +23,8 @@ type EnterpriseSignaturesTableProps = {
onOpenDrainageReport: (signature: ClientSmsSignature, item: DrainageInfo) => void;
setExpandedSignatureId: Dispatch<SetStateAction<string>>;
setPage: Dispatch<SetStateAction<number>>;
setSignatureSort: (sort: 'asc' | 'desc') => void;
onEditSignature: (signature: ClientSmsSignature) => void;
onOpenSignatureReport: (signature: ClientSmsSignature) => void;
signatureSort: 'asc' | 'desc';
total: number;
totalPages: number;
visibleSignatures: ClientSmsSignature[];
@@ -45,51 +42,17 @@ export function EnterpriseSignaturesTable({
onOpenDrainageReport,
setExpandedSignatureId,
setPage,
setSignatureSort,
onEditSignature,
onOpenSignatureReport,
signatureSort,
total,
totalPages,
visibleSignatures,
}: EnterpriseSignaturesTableProps) {
const navigate = useNavigate();
return (
<div className="signature-list admin-enterprise-signature-list">
<div className="enterprise-signature-table__head" role="row">
<span aria-hidden="true" />
<span className="enterprise-signature-table__sortable">
<span className="enterprise-signature-table__sort-actions">
<Button
aria-pressed={signatureSort === 'asc'}
icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />}
iconOnly
onClick={() => setSignatureSort('asc')}
size="sm"
variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}
>
</Button>
<Button
aria-pressed={signatureSort === 'desc'}
icon={
<Triangle
aria-hidden="true"
className="enterprise-signature-table__sort-triangle--down"
fill="currentColor"
size={11}
/>
}
iconOnly
onClick={() => setSignatureSort('desc')}
size="sm"
variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}
>
</Button>
</span>
</span>
<span></span>
<span></span>
<span></span>
<span></span>
@@ -97,7 +60,6 @@ export function EnterpriseSignaturesTable({
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleSignatures.map((signature) => {
@@ -148,18 +110,6 @@ export function EnterpriseSignaturesTable({
<div data-label="引流信息">
<strong>{payload.links.length} </strong>
</div>
<div data-label="待生成明细">
<Button
disabled={!signature.pendingReportDetailCount}
onClick={() =>
navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(signature.id)}&scope=pending`)
}
size="sm"
variant="ghost"
>
{signature.pendingReportDetailCount ?? 0}
</Button>
</div>
<div className="signature-actions">
<Button
className="enterprise-signature-action-button"
+51 -14
View File
@@ -2007,9 +2007,9 @@
.admin-enterprise-signature-list .signature-summary {
gap: var(--space-2);
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) minmax(92px, 0.5fr) 224px;
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
min-height: 64px;
min-width: 1260px;
min-width: 1160px;
padding-inline: var(--space-3);
}
@@ -2060,25 +2060,34 @@
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
gap: var(--space-2);
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) minmax(92px, 0.5fr) 224px;
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
min-height: 46px;
min-width: 1260px;
min-width: 1160px;
padding-inline: var(--space-3);
}
.enterprise-signature-table__sortable,
.enterprise-signature-table__sort-actions {
align-items: center;
display: flex;
}
.enterprise-signature-table__sortable { gap: 4px; }
.enterprise-signature-table__sort-actions { gap: 2px; }
.enterprise-signature-table__sort-actions .ui-button { min-height: 26px; min-width: 26px; padding: 4px; }
.enterprise-signature-table__sort-triangle--down { transform: rotate(180deg); }
.enterprise-signature-table__signature { min-width: 0; }
.enterprise-signature-table__signature strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.enterprise-signature-pending-summary {
align-items: center;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: flex;
gap: var(--space-2);
min-height: 46px;
padding: var(--space-2) var(--space-4);
}
.enterprise-signature-pending-summary strong {
color: var(--color-primary);
font-size: var(--font-size-xl);
font-variant-numeric: tabular-nums;
}
.enterprise-signature-pending-summary .ui-button { margin-left: auto; }
.carrier-report-count {
align-items: center;
border-radius: var(--radius-sm);
@@ -5589,6 +5598,30 @@
padding: var(--space-4);
}
.report-batch-briefs { display: grid; gap: 12px; }
.report-batch-briefs__title { display: flex; justify-content: space-between; gap: 12px; }
.report-batch-briefs__title h3 { margin: 0 0 4px; }
.report-batch-brief {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: 10px;
padding: 14px;
}
.report-batch-brief header { align-items: center; display: flex; justify-content: space-between; gap: 12px; }
.report-batch-brief header > div { display: grid; gap: 2px; }
.report-batch-brief header span { color: var(--color-text-muted); font-size: var(--font-size-xs); }
.report-batch-brief pre {
background: var(--color-surface-subtle);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.75;
margin: 0;
overflow-wrap: anywhere;
padding: 12px;
white-space: pre-wrap;
}
.report-material-summary {
display: grid;
gap: var(--space-1);
@@ -5606,6 +5639,10 @@
.report-material-detail-list > div {
grid-template-columns: minmax(0, 1fr);
}
.enterprise-signature-pending-summary { align-items: flex-start; flex-wrap: wrap; }
.enterprise-signature-pending-summary .ui-button { margin-left: 0; width: 100%; }
.report-batch-brief header { align-items: flex-start; flex-direction: column; }
.report-batch-brief header .ui-button { width: 100%; }
}
.channel-report-table__head {