fix: 修复模板唯一性及六项运营页面问题
CSS quality / css-quality (push) Waiting to run

This commit is contained in:
hectorzhao
2026-09-21 19:28:50 +08:00
parent 28951b4fc4
commit 3cacb6e8e7
38 changed files with 1122 additions and 194 deletions
+6
View File
@@ -429,6 +429,12 @@
"roots": [
"template-optout"
]
},
{
"file": "src/apps/admin/channels/ChannelFormModal.css",
"owners": ["src/apps/admin/channels/ChannelFormModal.tsx"],
"stylelintLegacy": false,
"roots": ["sms-channel-form"]
}
]
}
@@ -0,0 +1,471 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
const url = new URL(process.env.OPERATIONS_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_operations_six_'));
const redis = new URL(process.env.OPERATIONS_TEST_REDIS_URL || 'redis://127.0.0.1:16452');
assert(['127.0.0.1', 'localhost'].includes(redis.hostname));
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.toString(),
REDIS_URL: redis.toString(),
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
MINIO_ENDPOINT: '127.0.0.1:19400',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
SIGNATURE_ANALYTICS_ENABLED: 'false',
HOME_DASHBOARD_ENABLED: 'false',
GATEWAY_STARTUP_RECONNECT_DELAY_MS: '3600000',
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
CMPP_PROCESS_ROLE: 'api',
API_ENABLE_SEND_WORKER: 'false',
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
});
// Only isolated fixtures are written. No send API, worker, or Gateway is started.
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
Object.defineProperty(BigInt.prototype, 'toJSON', {
value() {
return Number(this);
},
configurable: true,
});
const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('./dist/app.module');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { UsersService } = require('./dist/users/users.service');
const { SessionService } = require('./dist/auth/session.service');
const { HomeProjection } = require('./dist/home-dashboard/home-projection');
const { todayKey, startOfDay } = require('./dist/signature-analytics/analytics-date');
const { hourlySendTrend } = require('./dist/home-dashboard/home-read');
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.setGlobalPrefix('api');
let browser;
const pass = (name) => console.log('PASS', name);
try {
await app.listen(Number(process.env.OPERATIONS_TEST_PORT || 17451), '127.0.0.1');
const base = (await app.getUrl()) + '/api';
const db = app.get(PrismaService),
sessions = app.get(SessionService),
users = app.get(UsersService);
const stamp = randomUUID().slice(0, 8),
key = () => randomUUID();
const tenant = await db.tenant.create({ data: { name: '六项验收企业' + stamp, code: key() } });
const makeApp = (name) =>
db.smsApplication.create({
data: {
tenantId: tenant.id,
name,
cmppAccount: key(),
cmppEnterpriseCode: '000001',
secretHash: 'unused',
interfaceEnabled: false,
},
});
const application = await makeApp('通知应用' + stamp),
otherApp = await makeApp('其他应用' + stamp);
const makeSignature = (applicationId, name) =>
db.smsSignature.create({ data: { tenantId: tenant.id, applicationId, name, auditStatus: 'approved' } });
const signature = await makeSignature(application.id, '【验收甲】'),
signature2 = await makeSignature(application.id, '【验收乙】');
const otherSignature = await makeSignature(otherApp.id, '【其他应用】');
const user = await users.create({
username: 'operations' + stamp,
email: stamp + '@example.invalid',
displayName: '隔离验收',
password: randomBytes(24).toString('hex'),
roleCode: 'platform_admin',
});
const session = await sessions.create(user.id, 'admin', 0),
cookie = sessions.cookieName('admin');
const headers = { 'content-type': 'application/json', cookie: cookie + '=' + session.token };
const req = async (route, body, method = body === undefined ? 'GET' : 'PUT', head = headers) => {
const response = await fetch(base + route, {
method,
headers: head,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, data: await response.json() };
};
const templateBody = (name, sig = signature) => ({
tenantId: tenant.id,
applicationId: sig.applicationId,
signatureId: sig.id,
name,
content: sig.name + '验证码${code123}',
variables: [{ name: 'code123', example: '中文示例', required: true }],
});
const create = (body) => req('/admin/enterprise-templates', body, 'POST');
const customer = await users.create({
username: 'client' + stamp,
email: 'c' + stamp + '@example.invalid',
displayName: '隔离客户',
password: randomBytes(24).toString('hex'),
roleCode: 'enterprise_admin',
tenantId: tenant.id,
});
const clientSession = await sessions.create(customer.id, 'client', 0);
const clientHeaders = {
'content-type': 'application/json',
cookie: sessions.cookieName('client') + '=' + clientSession.token,
};
const first = await create(templateBody('通知模板' + stamp));
assert.equal(first.status, 201, JSON.stringify(first));
const duplicate = await create(templateBody(' 通知模板' + stamp + ' ', signature2));
assert.equal(duplicate.status, 400);
assert.match(duplicate.data.message, /同一.*应用|当前.*应用/);
assert.equal((await create(templateBody('通知模板' + stamp, otherSignature))).status, 201);
assert.equal((await req('/admin/enterprise-templates/' + first.data.id, { name: '通知模板' + stamp })).status, 200);
const concurrent = await Promise.all([
create(templateBody('并发' + stamp)),
create(templateBody('并发' + stamp, signature2)),
]);
assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 400]);
for (const variable of ['中文', 'a_b', 'a-b', 'a b', '12']) {
const body = templateBody('非法' + key());
body.content = signature.name + '${' + variable + '}';
body.variables[0].name = variable;
assert.equal((await create(body)).status, 400);
}
const numeric = templateBody('数字变量' + stamp);
numeric.content = signature.name + '${123}';
numeric.variables[0].name = '123';
assert.equal((await create(numeric)).status, 201);
const extra = await create(templateBody('修改前' + stamp));
assert.equal((await req('/admin/enterprise-templates/' + extra.data.id, { name: first.data.name })).status, 400);
await db.smsTemplate.update({ where: { id: extra.data.id }, data: { auditStatus: 'deleted' } });
assert.equal((await create(templateBody('修改前' + stamp))).status, 201);
assert.equal(
(await req('/admin/enterprise-templates/' + extra.data.id + '/status', { status: 'approved' }, 'POST')).status,
400,
);
assert.equal((await req('/admin/enterprise-templates', undefined, 'GET', {})).status, 401);
const clientBody = templateBody('客户模板' + stamp);
delete clientBody.tenantId;
assert.equal((await req('/client/templates', clientBody, 'POST', clientHeaders)).status, 201);
assert.equal(
(
await req(
'/client/templates',
{ ...clientBody, signatureId: signature2.id, content: signature2.name + '验证码${code123}' },
'POST',
clientHeaders,
)
).status,
400,
);
assert.equal(
(
await req(
'/client/templates',
{ ...clientBody, name: '非法客户模板', content: signature.name + '${中文}', variables: [{ name: '中文' }] },
'POST',
clientHeaders,
)
).status,
400,
);
assert.equal((await req('/admin/enterprise-templates', undefined, 'GET', clientHeaders)).status, 401);
pass(
'real API application/name uniqueness across signatures, concurrent saves, rename, restore, cross-application and variable validation',
);
const channel = await db.smsChannel.create({
data: {
code: key(),
name: '验收通道' + stamp,
carrier: 'mobile',
carriers: ['mobile', 'unicom', 'telecom'],
status: 'active',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
},
});
const groups = [];
for (const [carrier, province] of [
['mobile', '全国'],
['unicom', '全国'],
['unicom', '湖北'],
['telecom', '全国'],
]) {
const group = await db.smsChannelGroup.create({
data: {
code: key(),
name: carrier + province + stamp,
carrier,
items: { create: { channelId: channel.id, carrier, province } },
},
});
groups.push(group);
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier },
});
}
const filters = {
enterpriseKeyword: tenant.name,
applicationKeyword: application.name,
channelKeyword: channel.name,
objectKeyword: signature.name,
pageSize: '1',
};
const details = await req('/admin/report-details?' + new URLSearchParams(filters));
assert.equal(details.status, 200);
assert.equal(details.data.total, 3);
assert.equal(details.data.items.length, 1);
assert.equal(details.data.items[0].virtual, true);
for (const field of ['enterpriseKeyword', 'applicationKeyword', 'channelKeyword', 'objectKeyword']) {
const empty = await req('/admin/report-details?' + new URLSearchParams({ ...filters, [field]: '不匹配' }));
assert.equal(empty.data.total, 0, field);
}
const page2 = await req('/admin/report-details?' + new URLSearchParams({ ...filters, page: '2' }));
assert.notEqual(page2.data.items[0].id, details.data.items[0].id);
pass('four independent AND search fields apply before pagination and retain virtual unreported rows');
const date = todayKey(new Date()),
start = startOfDay(date);
const beforeTrend = await hourlySendTrend(db, date);
const messages = [];
for (const [index, status] of ['submitted', 'unknown', 'delivered', 'failed', 'queued', 'rejected'].entries()) {
messages.push(
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
channelId: channel.id,
messageId: key(),
phoneNumber: '13800000000',
content: signature.name + '隔离记录',
status,
queuedAt: new Date(+start + (index ? 3600000 : 0)),
},
}),
);
}
await db.smsMessageRecord.create({
data: {
messageId: key(),
phoneNumber: '13800000000',
content: '前日',
status: 'delivered',
queuedAt: new Date(+start - 1),
},
});
await db.smsSubmitRecord.createMany({
data: [1, 2].map(() => ({
messageRecordId: messages[2].id,
channelId: channel.id,
submitId: key(),
submitStatus: 'submitted',
})),
});
const records = await req(
'/admin/operations/messages?' +
new URLSearchParams({ applicationId: application.id, status: 'unknown', page: '1', pageSize: '20' }),
);
assert.equal(records.status, 200);
assert.deepEqual(records.data.items.map((r) => r.status).sort(), ['submitted', 'unknown']);
const trend = await hourlySendTrend(db, date);
assert.equal(trend.length, 24);
assert.equal(trend[0].submittedCount - beforeTrend[0].submittedCount, 1);
assert.equal(trend[1].submittedCount - beforeTrend[1].submittedCount, 5);
assert.equal(trend[1].successCount - beforeTrend[1].successCount, 1);
assert.equal(trend[2].submittedCount - beforeTrend[2].submittedCount, 0);
await app.get(HomeProjection).tick();
const home = await req('/admin/operations/home/summary');
assert.equal(home.status, 200, JSON.stringify(home));
assert.deepEqual(home.data.hourlySendTrend, trend);
pass(
'unknown maps to submitted and legacy unknown; home uses Beijing hours, zero fill and business rows, not supplier retries',
);
// Force a failure after member deletion: the entire configuration transaction must roll back.
await db.$executeRawUnsafe(
`CREATE FUNCTION qa_reject_channel_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='sms_channel.update' THEN RAISE EXCEPTION 'qa rollback'; END IF; RETURN NEW; END $$`,
);
await db.$executeRawUnsafe(
`CREATE TRIGGER qa_reject_channel_audit BEFORE INSERT ON "OperationLog" FOR EACH ROW EXECUTE FUNCTION qa_reject_channel_audit()`,
);
assert.equal((await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'telecom'] })).status, 500);
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 4);
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, [
'mobile',
'unicom',
'telecom',
]);
await db.$executeRawUnsafe('DROP TRIGGER qa_reject_channel_audit ON "OperationLog"');
await db.$executeRawUnsafe('DROP FUNCTION qa_reject_channel_audit()');
assert.equal((await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'telecom'] })).status, 200);
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 2);
assert.equal(await db.channelRouteRule.count({ where: { applicationId: application.id } }), 4);
const audit = await db.operationLog.findFirst({
where: { resourceId: channel.id, action: 'sms_channel.update' },
orderBy: { createdAt: 'desc' },
});
assert.equal(audit.detail.removedGroupItems.length, 2);
await assert.rejects(
db.smsChannelGroupItem.create({ data: { channelId: channel.id, groupId: groups[1].id, carrier: 'unicom' } }),
/not compatible/,
);
pass(
'carrier reduction is atomic, removes national/province memberships, preserves routes and other carriers, records audit and rejects stale membership writes',
);
const { Client } = require('pg');
const writer = new Client({ connectionString: url.toString() });
await writer.connect();
let settled = false,
insertion;
try {
await db.$transaction(async (tx) => {
await tx.smsChannel.update({ where: { id: channel.id }, data: { carriers: ['mobile'] } });
insertion = writer
.query('INSERT INTO "SmsChannelGroupItem" (id,"groupId","channelId",carrier) VALUES ($1,$2,$3,$4)', [
key(),
groups[3].id,
channel.id,
'telecom',
])
.then(
() => ({ success: true }),
(error) => ({ code: error.code }),
)
.finally(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(settled, false);
await tx.smsChannelGroupItem.deleteMany({ where: { channelId: channel.id, groupId: groups[3].id } });
});
assert.equal((await insertion).code, '23514');
} finally {
await writer.end();
}
await db.smsChannel.update({ where: { id: channel.id }, data: { carriers: ['mobile', 'telecom'] } });
await db.smsChannelGroupItem.create({ data: { channelId: channel.id, groupId: groups[3].id, carrier: 'telecom' } });
pass('real concurrent member insertion waits for carrier change then rejects stale capability');
if (process.env.OPERATIONS_TEST_BROWSER_URL) {
const uiUrl = new URL(process.env.OPERATIONS_TEST_BROWSER_URL);
assert(['localhost', '127.0.0.1'].includes(uiUrl.hostname));
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
const evidence = process.env.OPERATIONS_TEST_EVIDENCE_DIR;
assert(evidence && path.isAbsolute(evidence));
mkdirSync(evidence, { recursive: true });
browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext();
await context.addCookies([
{ name: cookie, value: session.token, url: uiUrl.origin, httpOnly: true, sameSite: 'Lax' },
]);
const auth = await req('/admin/auth/session');
assert.equal(auth.status, 200);
await context.addInitScript(
(value) => localStorage.setItem('cmpp-auth-session:admin', JSON.stringify(value)),
auth.data,
);
const page = await context.newPage(),
errors = [];
page.on('pageerror', (e) => errors.push(e.message));
for (const [width, height] of [
[1600, 1000],
[1366, 768],
[390, 844],
]) {
await page.setViewportSize({ width, height });
await page.goto(uiUrl.origin + '/admin');
await page.getByRole('heading', { name: '小时发送曲线', exact: true }).waitFor();
await page.locator('[aria-label="小时发送曲线"] canvas').waitFor();
const curve = await page.getByRole('heading', { name: '小时发送曲线', exact: true }).boundingBox();
const rank = await page.getByRole('heading', { name: /企业消费排行/ }).boundingBox();
assert(curve.y < rank.y);
await page.screenshot({ path: path.join(evidence, `home-${width}.png`), fullPage: true });
await page.reload();
await page.locator('[aria-label="小时发送曲线"] canvas').waitFor();
await page.goto(uiUrl.origin + '/admin/report-tasks');
for (const label of ['企业', '应用', '通道', '报备对象'])
await page.getByRole('textbox', { name: label, exact: true }).waitFor();
await page.getByRole('textbox', { name: '企业', exact: true }).fill(tenant.name);
await page.getByRole('button', { name: '查询', exact: true }).click();
await page.screenshot({ path: path.join(evidence, `reports-${width}.png`), fullPage: true });
await page.goto(uiUrl.origin + '/admin/enterprise-templates');
await page.getByRole('textbox', { name: '企业应用', exact: true }).fill(application.name);
await page.getByRole('textbox', { name: '模板名称', exact: true }).fill(first.data.name);
await page.getByRole('button', { name: '查询', exact: true }).click();
await page
.getByRole('article')
.filter({ hasText: first.data.name })
.filter({ hasText: application.name })
.getByRole('button', { name: '编辑', exact: true })
.click();
await page.getByRole('dialog').getByRole('button', { name: /^签名/ }).click();
await page.getByPlaceholder('搜索短信签名').fill('验收甲');
await page.getByRole('option', { name: signature.name, exact: true }).waitFor();
await page.screenshot({ path: path.join(evidence, `template-search-${width}.png`), fullPage: true });
await page.getByRole('option', { name: signature.name, exact: true }).click();
await page.getByRole('textbox', { name: /模板内容/ }).fill(signature.name + '${中文}');
assert(await page.getByRole('button', { name: '保存', exact: true }).isDisabled());
await page.getByRole('button', { name: '取消', exact: true }).click();
await page.getByRole('button', { name: '放弃并关闭', exact: true }).click();
await page.goto(uiUrl.origin + '/admin/channels');
await page.getByRole('textbox', { name: '通道名称', exact: true }).fill(channel.name);
await page.getByRole('button', { name: '查询', exact: true }).click();
await page
.getByRole('article')
.filter({ hasText: channel.name })
.getByRole('button', { name: '编辑', exact: true })
.click();
await page.getByRole('checkbox', { name: '电信', exact: true }).uncheck();
await page
.getByRole('alert')
.filter({ hasText: /通道组/ })
.waitFor();
assert(await page.getByRole('button', { name: '确认', exact: true }).isDisabled());
await page.screenshot({ path: path.join(evidence, `carrier-warning-${width}.png`), fullPage: true });
await page.getByRole('checkbox', { name: /我已确认/ }).check();
assert(await page.getByRole('button', { name: '确认', exact: true }).isEnabled());
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).last().click();
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 2);
await page.goto(uiUrl.origin + '/admin/analytics');
await page.getByRole('heading', { name: '签名通道发送质量', exact: true }).waitFor();
await page
.getByRole('columnheader', { name: '业务短信', exact: true, includeHidden: true })
.waitFor({ state: 'attached' });
assert.equal(
await page.getByRole('columnheader', { name: '通道提交', exact: true, includeHidden: true }).count(),
0,
);
await page.screenshot({ path: path.join(evidence, `quality-${width}.png`), fullPage: true });
await page.goto(uiUrl.origin + '/admin/sms-records');
await page.getByRole('button', { name: '发送状态', exact: true }).click();
await page.getByRole('option', { name: '未知', exact: true }).click();
const filteredResponse = page.waitForResponse(
(r) =>
r.url().includes('/api/admin/operations/messages?') &&
new URL(r.url()).searchParams.get('status') === 'unknown',
);
await page.getByRole('button', { name: '查询', exact: true }).click();
const filtered = await (await filteredResponse).json();
assert(filtered.items.some((r) => r.status === 'submitted'));
await page.getByText('提交成功', { exact: true }).first().waitFor();
await page.screenshot({ path: path.join(evidence, `unknown-${width}.png`), fullPage: true });
}
assert.deepEqual(errors, []);
pass(
'real authenticated UI: three viewport sizes, refresh, routing, searchable signatures, validation, channel warning and cancel',
);
}
} finally {
await browser?.close();
await app.close();
}
+5 -2
View File
@@ -208,8 +208,9 @@ try {
const reduced = await req('/admin/channels/' + channel.id, { carriers: ['mobile'] });
assert.equal(reduced.status, 200, await reduced.text());
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, ['mobile']);
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: unicom.id, channelId: channel.id } }), 1);
pass('carrier reduction saves with active group references preserved');
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: unicom.id, channelId: channel.id } }), 0);
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: group.id, channelId: channel.id } }), 1);
pass('carrier reduction removes incompatible members and preserves supported group members');
const batch = await db.smsBatchTask.create({
data: { tenantId: tenant.id, applicationId: application.id, taskNo: key(), content, phoneTotal: 1 },
});
@@ -373,6 +374,8 @@ try {
.click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor();
await page.getByRole('checkbox', { name: '联通', exact: true }).uncheck();
assert(await page.getByRole('button', { name: '确认', exact: true }).isDisabled());
await page.getByRole('checkbox', { name: /我已确认移除运营商/ }).check();
await page.screenshot({ path: `.local-data/template-optout-20260920/channel-${width}.png`, fullPage: true });
await page.getByRole('button', { name: '确认', exact: true }).click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor({ state: 'hidden' });