feat: 增加模板通道拒收策略并修复运营页面
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
const url = new URL(process.env.OPT_OUT_TEST_DATABASE_URL || '');
|
||||
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_optout_'));
|
||||
const redis = new URL(process.env.OPT_OUT_TEST_REDIS_URL || 'redis://127.0.0.1:16436');
|
||||
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',
|
||||
SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED: 'true',
|
||||
});
|
||||
// No workers/publishers/Gateway are started. Commands stay in this isolated database.
|
||||
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||
Object.assign(process.env, {
|
||||
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',
|
||||
});
|
||||
require('reflect-metadata');
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
value() {
|
||||
return Number(this);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const { NestFactory } = require('@nestjs/core'),
|
||||
{ AppModule } = require('./dist/app.module');
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service'),
|
||||
{ SessionService } = require('./dist/auth/session.service');
|
||||
const { UsersService } = require('./dist/users/users.service'),
|
||||
{ SendChainService } = require('./dist/send-chain/send-chain.service');
|
||||
const { completionContext } = require('./dist/send-chain/completion-context');
|
||||
const app = await NestFactory.create(AppModule, { logger: ['error'] });
|
||||
app.setGlobalPrefix('api');
|
||||
const pass = (name) => console.log('PASS', name);
|
||||
try {
|
||||
await app.listen(Number(process.env.OPT_OUT_TEST_PORT || 0), '127.0.0.1');
|
||||
const base = (await app.getUrl()) + '/api',
|
||||
db = app.get(PrismaService),
|
||||
chain = app.get(SendChainService);
|
||||
const stamp = randomUUID().slice(0, 8),
|
||||
key = () => randomUUID();
|
||||
const tenant = await db.tenant.create({ data: { name: '拒收策略验收企业', code: key() } });
|
||||
const application = await db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: '拒收策略隔离应用',
|
||||
cmppAccount: key(),
|
||||
cmppEnterpriseCode: '000001',
|
||||
secretHash: 'unused',
|
||||
interfaceEnabled: false,
|
||||
templateMismatchMode: 'direct_send',
|
||||
},
|
||||
});
|
||||
const signature = await db.smsSignature.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, name: '【拒收验收】', auditStatus: 'approved' },
|
||||
});
|
||||
const content = signature.name + '文'.repeat(65); // 71 characters, two parts.
|
||||
assert.equal(content.length, 71);
|
||||
const template = await db.smsTemplate.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
name: '拒收策略验收模板' + stamp,
|
||||
content,
|
||||
auditStatus: 'approved',
|
||||
billingUnits: 2,
|
||||
},
|
||||
});
|
||||
const channel = await db.smsChannel.create({
|
||||
data: {
|
||||
name: '隔离通道甲' + stamp,
|
||||
code: key(),
|
||||
carrier: 'mobile',
|
||||
carriers: ['mobile', 'unicom'],
|
||||
status: 'active',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: key(),
|
||||
passwordCipher: 'unused',
|
||||
srcId: '1069',
|
||||
sendRegion: '全国',
|
||||
unitPrice: 325,
|
||||
config: { serviceId: 'SMS' },
|
||||
},
|
||||
});
|
||||
const backup = await db.smsChannel.create({
|
||||
data: {
|
||||
name: '隔离通道乙',
|
||||
code: key(),
|
||||
carrier: 'mobile',
|
||||
carriers: ['mobile'],
|
||||
status: 'active',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: key(),
|
||||
passwordCipher: 'unused',
|
||||
srcId: '1069',
|
||||
sendRegion: '全国',
|
||||
unitPrice: 325,
|
||||
config: { serviceId: 'SMS' },
|
||||
},
|
||||
});
|
||||
for (const c of [channel, backup]) {
|
||||
await db.cmppConnectionState.create({
|
||||
data: { channelId: c.id, connectionId: key(), status: 'connected', currentConnections: 1 },
|
||||
});
|
||||
await db.channelSignatureReportTask.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
signatureId: signature.id,
|
||||
channelId: c.id,
|
||||
carrier: 'mobile',
|
||||
approvalScope: 'carrier_specific',
|
||||
status: 'approved',
|
||||
},
|
||||
});
|
||||
}
|
||||
const group = await db.smsChannelGroup.create({
|
||||
data: {
|
||||
code: key(),
|
||||
name: '隔离移动组',
|
||||
carrier: 'mobile',
|
||||
items: {
|
||||
create: [
|
||||
{ channelId: channel.id, carrier: 'mobile', priority: 1 },
|
||||
{ channelId: backup.id, carrier: 'mobile', priority: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const unicom = await db.smsChannelGroup.create({
|
||||
data: {
|
||||
code: key(),
|
||||
name: '隔离联通组',
|
||||
carrier: 'unicom',
|
||||
items: { create: { channelId: channel.id, carrier: 'unicom' } },
|
||||
},
|
||||
});
|
||||
await db.channelRouteRule.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile' },
|
||||
});
|
||||
await db.channelRouteRule.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, groupId: unicom.id, carrier: 'unicom' },
|
||||
});
|
||||
const users = app.get(UsersService),
|
||||
sessions = app.get(SessionService);
|
||||
const user = await users.create({
|
||||
username: 'optout' + 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 = (path, body, method = body === undefined ? 'GET' : 'PUT', head = headers) =>
|
||||
fetch(base + path, { method, headers: head, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
const path = '/admin/enterprise-templates/' + template.id + '/opt-out-policy';
|
||||
assert.equal((await req(path, undefined, 'GET', {})).status, 401);
|
||||
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);
|
||||
assert.equal(
|
||||
(await req(path, undefined, 'GET', { cookie: sessions.cookieName('client') + '=' + clientSession.token })).status,
|
||||
401,
|
||||
);
|
||||
assert.equal((await req(path, { rules: [], preserveFragments: false })).status, 400);
|
||||
assert.equal(
|
||||
(await req(path, { rules: [{ channelId: 'foreign', action: 'add' }], preserveFragments: true })).status,
|
||||
400,
|
||||
);
|
||||
const rules = [
|
||||
{ channelId: channel.id, action: 'add' },
|
||||
{ channelId: backup.id, action: 'remove' },
|
||||
];
|
||||
assert.equal((await req(path, { rules, preserveFragments: true })).status, 200);
|
||||
assert.deepEqual((await req(path).then((r) => r.json())).rules, rules);
|
||||
assert.equal(
|
||||
await db.operationLog.count({ where: { resourceId: template.id, action: 'sms_template.opt_out_policy.update' } }),
|
||||
1,
|
||||
);
|
||||
pass('real HTTP policy save/read, authentication, scope, mandatory fragment protection and durable audit');
|
||||
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');
|
||||
const batch = await db.smsBatchTask.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, taskNo: key(), content, phoneTotal: 1 },
|
||||
});
|
||||
const message = await db.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
batchTaskId: batch.id,
|
||||
messageId: key(),
|
||||
phoneNumber: '13800138000',
|
||||
carrier: 'mobile',
|
||||
content,
|
||||
billingUnits: 2,
|
||||
unitPrice: 425,
|
||||
amountCents: 850,
|
||||
},
|
||||
});
|
||||
// No templateId intentionally: direct_send must still match its template.
|
||||
const route = await chain.selectChannelForMessage(message);
|
||||
assert.equal(route.contentPolicy.content, content + '拒收请回复R');
|
||||
await chain.submitMessageToGateway(message, route, 0);
|
||||
const first = await db.smsSubmitRecord.findFirstOrThrow({ where: { messageRecordId: message.id } });
|
||||
const written = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
|
||||
const outbox = await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } });
|
||||
assert.equal(written.originalContent, content);
|
||||
assert.equal(written.content, outbox.payload.content);
|
||||
assert.equal(first.sentContent, written.content);
|
||||
assert.equal(written.billingUnits, 2);
|
||||
assert.equal(Number(written.amountCents), 850);
|
||||
assert.equal(Number(first.costAmountCents), 650);
|
||||
const route2 = await chain.selectChannelForMessage(written, { excludeChannelIds: [channel.id] });
|
||||
await chain.submitMessageToGateway(written, route2, 1, first.id);
|
||||
const retried = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
|
||||
assert.equal(retried.content, content);
|
||||
assert.equal(retried.originalContent, content);
|
||||
assert.equal(
|
||||
(await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } })).payload.content,
|
||||
content + '拒收请回复R',
|
||||
);
|
||||
assert.equal(await db.gatewaySubmitOutbox.count({ where: { messageRecordId: message.id, status: 'pending' } }), 2);
|
||||
pass('single submit and alternate-channel retry: real transactional content/Outbox snapshots and unchanged charges');
|
||||
const before = await db.smsSubmitRecord.count();
|
||||
await assert.rejects(
|
||||
db.$transaction((tx) =>
|
||||
completionContext.run({ tx, messageRecordId: message.id }, async () => {
|
||||
await chain.submitMessageToGateway(retried, route, 2);
|
||||
throw new Error('rollback verification');
|
||||
}),
|
||||
),
|
||||
/rollback verification/,
|
||||
);
|
||||
assert.equal(await db.smsSubmitRecord.count(), before);
|
||||
assert.equal((await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } })).content, content);
|
||||
pass('failed transaction rolls back content, submit and Outbox together');
|
||||
const gateway = chain.submission.gatewaySubmit;
|
||||
const many = [];
|
||||
for (let i = 0; i < 2; i++)
|
||||
many.push(
|
||||
await db.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
batchTaskId: batch.id,
|
||||
messageId: key(),
|
||||
phoneNumber: '13800138000',
|
||||
carrier: 'mobile',
|
||||
content,
|
||||
billingUnits: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await gateway.processSendJobBatch(many.map((m) => ({ messageRecordId: m.id })));
|
||||
for (const m of many) {
|
||||
const row = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: m.id } });
|
||||
assert.equal(row.content, content + '拒收请回复R');
|
||||
assert.equal(row.originalContent, content);
|
||||
}
|
||||
pass('microbatch uses the same policy and persists original text');
|
||||
for (const state of [
|
||||
{ status: 'delivered', submitStatus: 'accepted', receiptStatus: 'delivered' },
|
||||
{ status: 'submit_failed', submitStatus: 'rejected' },
|
||||
{ status: 'failed', submitStatus: 'accepted', receiptStatus: 'undelivered' },
|
||||
{ status: 'timeout', submitStatus: 'accepted' },
|
||||
]) {
|
||||
await db.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
messageId: key(),
|
||||
phoneNumber: '13800138000',
|
||||
content,
|
||||
...state,
|
||||
},
|
||||
});
|
||||
}
|
||||
const quality = await req('/admin/operations/signature-quality?keyword=' + encodeURIComponent(tenant.name)).then(
|
||||
(r) => r.json(),
|
||||
);
|
||||
const stat = quality.items.find((i) => i.signatureId === signature.id);
|
||||
assert.equal(stat.total, stat.successCount + stat.submitFailureCount + stat.failureCount + stat.unknownCount);
|
||||
assert.equal(stat.failureCount, 1);
|
||||
assert(stat.unknownCount >= 1);
|
||||
pass('real quality query yields four exclusive categories, including timeout without receipt');
|
||||
if (process.env.OPT_OUT_TEST_BROWSER === 'true') {
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/hectorzhao/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
const ui = process.env.OPT_OUT_TEST_UI_URL || 'http://127.0.0.1:17438';
|
||||
assert.equal(new URL(ui).hostname, '127.0.0.1');
|
||||
const auth = await req('/admin/auth/session').then((r) => r.json());
|
||||
await page
|
||||
.context()
|
||||
.addCookies([{ name: cookie, value: session.token, url: ui, httpOnly: true, sameSite: 'Lax' }]);
|
||||
await page.addInitScript((data) => localStorage.setItem('cmpp-auth-session:admin', JSON.stringify(data)), auth);
|
||||
for (const [width, height] of [
|
||||
[1600, 1000],
|
||||
[1366, 768],
|
||||
[390, 844],
|
||||
]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(ui + '/#/admin/enterprise-templates');
|
||||
const row = page.locator('.admin-enterprise-template-row').filter({ hasText: template.name }).first();
|
||||
await row.getByRole('button', { name: '拒收指令', exact: true }).click();
|
||||
await page.getByLabel(channel.name + '的拒收指令').waitFor();
|
||||
assert(await page.getByLabel('避免影响消息分片数').isDisabled());
|
||||
await page.screenshot({ path: `.local-data/template-optout-20260920/template-${width}.png`, fullPage: true });
|
||||
await page.getByRole('button', { name: '保存策略', exact: true }).click();
|
||||
await page.getByRole('heading', { name: '模板拒收指令', exact: true }).waitFor({ state: 'hidden' });
|
||||
await page.goto(ui + '/#/admin/analytics');
|
||||
await page.locator('.quality-status-bar__track').first().waitFor();
|
||||
assert.match(await page.locator('.quality-status-bar__track').first().getAttribute('title'), /未收到回执/);
|
||||
await page.screenshot({ path: `.local-data/template-optout-20260920/quality-${width}.png`, fullPage: true });
|
||||
await page.reload();
|
||||
await page.locator('.quality-status-bar__track').first().waitFor();
|
||||
await page.goto(ui + '/#/admin/sms-records');
|
||||
await page
|
||||
.locator('.admin-sms-record-card')
|
||||
.filter({ hasText: '拒收请回复R' })
|
||||
.first()
|
||||
.getByRole('button', { name: '查看发送详情' })
|
||||
.click();
|
||||
await page.getByRole('heading', { name: '原始短信内容', exact: true }).waitFor();
|
||||
await page.getByRole('heading', { name: /第 1 次提交通道内容/ }).waitFor();
|
||||
await page.screenshot({ path: `.local-data/template-optout-20260920/detail-${width}.png`, fullPage: true });
|
||||
await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'unicom'] });
|
||||
await page.goto(ui + '/#/admin/channels');
|
||||
await page.getByLabel('通道名称', { exact: true }).fill(channel.name);
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
await page
|
||||
.locator('.sms-channel-table__row')
|
||||
.filter({ hasText: channel.name })
|
||||
.getByRole('button', { name: '编辑', exact: true })
|
||||
.click();
|
||||
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor();
|
||||
await page.getByRole('checkbox', { name: '联通', exact: true }).uncheck();
|
||||
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' });
|
||||
assert.deepEqual((await db.smsChannel.findUniqueOrThrow({ where: { id: channel.id } })).carriers, ['mobile']);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
pass('real API browser saves, locked checkbox, route/refresh, four-segment bar and three sizes');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
await db.gatewaySubmitOutbox.count({
|
||||
where: { messageRecordId: { in: [message.id, ...many.map((m) => m.id)] }, status: { not: 'pending' } },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
writeFileSync(
|
||||
'.local-data/template-optout-20260920/fixture.json',
|
||||
JSON.stringify({ tenantId: tenant.id, templateId: template.id, messageId: message.id }),
|
||||
);
|
||||
pass('no command published, no Gateway/SMSC started, no external SMS sent');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
Reference in New Issue
Block a user