test: add real environment smoke coverage
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
6. 输出 500 条/秒压测报告。已完成,见 `docs/phase-8-500tps-performance-report.md`。
|
6. 输出 500 条/秒压测报告。已完成,见 `docs/phase-8-500tps-performance-report.md`。
|
||||||
7. 输出 Linux 上线部署文档和回滚方案。已完成,见 `docs/linux-deployment-and-rollback.md`。
|
7. 输出 Linux 上线部署文档和回滚方案。已完成,见 `docs/linux-deployment-and-rollback.md`。
|
||||||
8. 运行 Prisma generate、API build、阶段验证脚本和 API health smoke。已完成。
|
8. 运行 Prisma generate、API build、阶段验证脚本和 API health smoke。已完成。
|
||||||
|
9. 在真实 PostgreSQL/Redis/MinIO 下补充最小 seed 和核心 HTTP smoke。已完成。
|
||||||
|
|
||||||
## 验收记录
|
## 验收记录
|
||||||
|
|
||||||
@@ -49,10 +50,15 @@
|
|||||||
- `npm run verify:phase8` 通过。
|
- `npm run verify:phase8` 通过。
|
||||||
- 队列契约校验通过。
|
- 队列契约校验通过。
|
||||||
- Go Gateway `go test ./...` 通过。
|
- Go Gateway `go test ./...` 通过。
|
||||||
- BullMQ Spike:15000 条消息、并发 500、端到端约 825.48 TPS,满足 500 条/秒指标。
|
- BullMQ Spike:15000 条消息、并发 500、端到端约 705.65 TPS,满足 500 条/秒指标。
|
||||||
- Prisma Client 生成通过。
|
- Prisma Client 生成通过。
|
||||||
- API build 通过。
|
- API build 通过。
|
||||||
- 前端 build 通过,仍存在 Vite chunk size warning。
|
- 前端 build 通过,仍存在 Vite chunk size warning。
|
||||||
|
- 真实环境 smoke 通过:
|
||||||
|
- 依赖连通性:PostgreSQL 5432、Redis 6379、MinIO S3 9000、MinIO Console 9001 均通过 TCP 探测。
|
||||||
|
- 迁移:`npm --prefix api run prisma:migrate:deploy` 通过,无待应用迁移。
|
||||||
|
- API 启动:`API_PORT=3101 node api/dist/main.js` 可连接真实 `.env` 并返回 health `ok`。
|
||||||
|
- 核心链路:`node tools/smoke/real-env-smoke.mjs` 通过,覆盖登录、企业/客户、人工充值、余额检查、发送任务、MinIO 预签名上传、运营日志、trace 和 reconciliation。
|
||||||
- 500 条/秒压测报告已输出。
|
- 500 条/秒压测报告已输出。
|
||||||
- Linux 上线部署与回滚方案已输出。
|
- Linux 上线部署与回滚方案已输出。
|
||||||
|
|
||||||
@@ -63,6 +69,7 @@
|
|||||||
- 输出 500 条/秒压测报告:已完成。
|
- 输出 500 条/秒压测报告:已完成。
|
||||||
- 输出 Linux 部署方案:已完成,覆盖 Docker Compose、systemd、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。
|
- 输出 Linux 部署方案:已完成,覆盖 Docker Compose、systemd、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。
|
||||||
- API health smoke 通过:`/api/health` 返回 `ok`。
|
- API health smoke 通过:`/api/health` 返回 `ok`。
|
||||||
|
- 真实 PostgreSQL/Redis/MinIO 核心 smoke 通过:最小 seed 与登录、充值、发送、上传、日志、追踪、对账链路均可执行。
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
@@ -71,4 +78,4 @@
|
|||||||
## 风险与说明
|
## 风险与说明
|
||||||
|
|
||||||
- 阶段 8 的统计查询以第一版可运营为目标,优先使用 Prisma 聚合与分页查询;大表分区、物化统计、Redis 热统计将在后续版本增强。
|
- 阶段 8 的统计查询以第一版可运营为目标,优先使用 Prisma 聚合与分页查询;大表分区、物化统计、Redis 热统计将在后续版本增强。
|
||||||
- 本地没有 PostgreSQL 服务时,仅执行 Prisma Client 生成和 TypeScript 构建,不执行数据库迁移。
|
- 当前真实环境 smoke 不包含真实运营商 SMSC/CMPP 互通;该项需运营商测试环境后补充联调记录。
|
||||||
|
|||||||
@@ -42,22 +42,28 @@ npm --prefix api test -- channels.service.spec.ts
|
|||||||
npm --prefix api test -- operations.service.spec.ts
|
npm --prefix api test -- operations.service.spec.ts
|
||||||
npm run test:api
|
npm run test:api
|
||||||
npm run verify:phase8
|
npm run verify:phase8
|
||||||
|
npm run build
|
||||||
npm run spike:gateway
|
npm run spike:gateway
|
||||||
npm --prefix api test
|
npm --prefix api test
|
||||||
npm run test:gateway
|
npm run test:gateway
|
||||||
|
node tools/smoke/real-env-smoke.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
### 当前结果
|
### 当前结果
|
||||||
|
|
||||||
- API Jest:5 个 test suite 通过,21 个测试通过。
|
- API Jest:5 个 test suite 通过,22 个测试通过。
|
||||||
- Gateway:`npm run spike:gateway` 通过。
|
- Gateway:`npm run spike:gateway` 通过。
|
||||||
- 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 735.83 TPS,满足 500 TPS。
|
- 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 705.65 TPS,满足 500 TPS。
|
||||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||||
- API 测试均使用 mock,不要求 PostgreSQL/Redis/MinIO 在线。
|
- API 测试均使用 mock,不要求 PostgreSQL/Redis/MinIO 在线。
|
||||||
|
- 真实 PostgreSQL/Redis/MinIO smoke 通过:
|
||||||
|
- PostgreSQL `localhost:5432`、Redis `localhost:6379`、MinIO `localhost:9000/9001` 端口均连通。
|
||||||
|
- `npm --prefix api run prisma:migrate:deploy` 通过,无待应用迁移。
|
||||||
|
- API 以真实 `.env` 启动,`GET http://127.0.0.1:3101/api/health` 返回 `ok`。
|
||||||
|
- `tools/smoke/real-env-smoke.mjs` 已补充最小幂等 seed,并验证登录、企业/客户、人工充值、余额检查、发送任务、MinIO 预签名上传、运营日志、发送链路追踪和账务对账聚合。
|
||||||
|
|
||||||
### 已知缺口
|
### 已知缺口
|
||||||
|
|
||||||
- 暂未新增前端测试框架,前端仍以 `npm run build` 作为 smoke。
|
- 暂未新增前端测试框架,前端仍以 `npm run build` 作为 smoke。
|
||||||
- 暂未跑真实 PostgreSQL/Redis/MinIO 集成测试。
|
|
||||||
- BullMQ 真实链路仍由 `npm run spike:bullmq` 覆盖,不在 Jest 内启动 Redis。
|
- BullMQ 真实链路仍由 `npm run spike:bullmq` 覆盖,不在 Jest 内启动 Redis。
|
||||||
- Gateway 未接真实运营商 SMSC;真实 CMPP 互通需要运营商测试环境后补充联调记录。
|
- Gateway 未接真实运营商 SMSC;真实 CMPP 互通需要运营商测试环境后补充联调记录。
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { PrismaPg } from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
|
||||||
|
import { PrismaClient } from '../../api/node_modules/@prisma/client/index.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPg(
|
||||||
|
process.env.DATABASE_URL ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:3101/api';
|
||||||
|
const tenantCode = 'smoke-tenant';
|
||||||
|
const username = 'smoke_client';
|
||||||
|
const password = 'SmokePass123!';
|
||||||
|
|
||||||
|
function hashPassword(value) {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSmokeData() {
|
||||||
|
const tenant = await prisma.tenant.upsert({
|
||||||
|
where: { code: tenantCode },
|
||||||
|
update: { name: 'Smoke Test Enterprise', status: 'active' },
|
||||||
|
create: { code: tenantCode, name: 'Smoke Test Enterprise', status: 'active' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { username },
|
||||||
|
update: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
displayName: 'Smoke Client Admin',
|
||||||
|
passwordHash: hashPassword(password),
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
username,
|
||||||
|
displayName: 'Smoke Client Admin',
|
||||||
|
passwordHash: hashPassword(password),
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.tenantAccount.upsert({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
update: { balanceCents: { increment: 0 }, smsUnits: { increment: 0 }, creditCents: 5000, status: 'active' },
|
||||||
|
create: { tenantId: tenant.id, balanceCents: 100000, smsUnits: 10000, creditCents: 5000, status: 'active' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = await prisma.smsChannel.upsert({
|
||||||
|
where: { code: 'SMOKE_CMPP' },
|
||||||
|
update: {
|
||||||
|
name: 'Smoke CMPP Channel',
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 7890,
|
||||||
|
account: 'smoke-account',
|
||||||
|
passwordCipher: 'smoke-password',
|
||||||
|
srcId: '10690000',
|
||||||
|
rateLimitPerSecond: 500,
|
||||||
|
unitPrice: 5,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
code: 'SMOKE_CMPP',
|
||||||
|
name: 'Smoke CMPP Channel',
|
||||||
|
carrier: 'all',
|
||||||
|
protocol: 'CMPP',
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 7890,
|
||||||
|
account: 'smoke-account',
|
||||||
|
passwordCipher: 'smoke-password',
|
||||||
|
srcId: '10690000',
|
||||||
|
rateLimitPerSecond: 500,
|
||||||
|
unitPrice: 5,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = await prisma.smsChannelGroup.upsert({
|
||||||
|
where: { code: 'SMOKE_GROUP' },
|
||||||
|
update: { name: 'Smoke Channel Group', status: 'active' },
|
||||||
|
create: { code: 'SMOKE_GROUP', name: 'Smoke Channel Group', status: 'active' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupItem = await prisma.smsChannelGroupItem.findFirst({
|
||||||
|
where: { groupId: group.id, channelId: channel.id },
|
||||||
|
});
|
||||||
|
if (!groupItem) {
|
||||||
|
await prisma.smsChannelGroupItem.create({
|
||||||
|
data: { groupId: group.id, channelId: channel.id, priority: 1, weight: 1, rateLimitPerSecond: 500 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeRule = await prisma.channelRouteRule.findFirst({
|
||||||
|
where: { tenantId: tenant.id, groupId: group.id, status: 'active' },
|
||||||
|
});
|
||||||
|
if (!routeRule) {
|
||||||
|
await prisma.channelRouteRule.create({
|
||||||
|
data: { tenantId: tenant.id, groupId: group.id, priority: 1, status: 'active' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const application =
|
||||||
|
(await prisma.smsApplication.findFirst({ where: { tenantId: tenant.id, name: 'Smoke SMS App' } })) ??
|
||||||
|
(await prisma.smsApplication.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
name: 'Smoke SMS App',
|
||||||
|
scene: 'smoke',
|
||||||
|
secretHash: hashPassword('smoke-secret'),
|
||||||
|
dailyLimit: 100000,
|
||||||
|
maxPhonesPerTask: 100000,
|
||||||
|
templateMismatchMode: 'reject',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const signature =
|
||||||
|
(await prisma.smsSignature.findFirst({ where: { tenantId: tenant.id, applicationId: application.id, name: '烟测签名' } })) ??
|
||||||
|
(await prisma.smsSignature.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
applicationId: application.id,
|
||||||
|
name: '烟测签名',
|
||||||
|
purpose: 'smoke',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
reportStatus: 'approved',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const template =
|
||||||
|
(await prisma.smsTemplate.findFirst({ where: { tenantId: tenant.id, applicationId: application.id, name: 'Smoke Verify Code' } })) ??
|
||||||
|
(await prisma.smsTemplate.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
applicationId: application.id,
|
||||||
|
signatureId: signature.id,
|
||||||
|
name: 'Smoke Verify Code',
|
||||||
|
content: '您的验证码为${code},5分钟内有效。',
|
||||||
|
category: 'verification',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
billingUnits: 1,
|
||||||
|
variables: { create: [{ name: 'code', example: '123456', required: true }] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { tenant, user, channel, application, signature, template };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${options.method ?? 'GET'} ${path} failed: ${response.status} ${text}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const data = await ensureSmokeData();
|
||||||
|
const tenantHeader = { 'x-tenant-id': data.tenant.id };
|
||||||
|
|
||||||
|
const health = await request('/health');
|
||||||
|
assert(health.status === 'ok', 'health should return ok');
|
||||||
|
|
||||||
|
const login = await request('/client/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
assert(login.accessToken && login.user?.tenantId === data.tenant.id, 'login should return smoke tenant user');
|
||||||
|
|
||||||
|
const tenants = await request('/admin/tenants');
|
||||||
|
assert(tenants.some((tenant) => tenant.code === tenantCode), 'tenant list should contain smoke tenant');
|
||||||
|
|
||||||
|
const recharge = await request('/admin/billing/manual-recharges', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenantId: data.tenant.id,
|
||||||
|
amountCents: 1234,
|
||||||
|
smsUnits: 10,
|
||||||
|
operatorId: data.user.id,
|
||||||
|
remark: 'real env smoke manual recharge',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert(recharge.id, 'manual recharge should create an order');
|
||||||
|
|
||||||
|
const transactions = await request('/client/billing/transactions', { headers: tenantHeader });
|
||||||
|
assert(transactions.some((item) => item.relatedId === recharge.id), 'transactions should include recharge delta');
|
||||||
|
|
||||||
|
const check = await request('/admin/billing/check', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tenantId: data.tenant.id, amountCents: 1, smsUnits: 1 }),
|
||||||
|
});
|
||||||
|
assert(check.canSend === true, 'billing check should allow the smoke send');
|
||||||
|
|
||||||
|
const task = await request('/client/send/batch-tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenantId: data.tenant.id,
|
||||||
|
applicationId: data.application.id,
|
||||||
|
templateId: data.template.id,
|
||||||
|
content: '您的验证码为123456,5分钟内有效。',
|
||||||
|
category: 'verification',
|
||||||
|
phones: ['13800138000', '13900139000'],
|
||||||
|
variables: { code: '123456' },
|
||||||
|
createdById: data.user.id,
|
||||||
|
sourceIp: '127.0.0.1',
|
||||||
|
userAgent: 'real-env-smoke',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert(task.id && task.messages?.length === 2, 'send batch task should create two messages');
|
||||||
|
|
||||||
|
const taskList = await request('/client/operations/batch-tasks', { headers: tenantHeader });
|
||||||
|
assert(taskList.some((item) => item.id === task.id), 'client task list should include smoke task');
|
||||||
|
|
||||||
|
const upload = await request('/admin/files/presigned-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ objectKey: `smoke/${Date.now()}-upload.txt`, expiresInSeconds: 300 }),
|
||||||
|
});
|
||||||
|
assert(upload.uploadUrl?.startsWith('http'), 'presigned upload should return an upload URL');
|
||||||
|
const putResponse = await fetch(upload.uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: 'cmpp real env smoke upload',
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
});
|
||||||
|
assert(putResponse.ok, `MinIO presigned PUT should succeed, got ${putResponse.status}`);
|
||||||
|
|
||||||
|
const log = await request('/admin/operation-logs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenantId: data.tenant.id,
|
||||||
|
userId: data.user.id,
|
||||||
|
action: 'smoke.verify',
|
||||||
|
resource: 'real-env-smoke',
|
||||||
|
resourceId: task.id,
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
|
userAgent: 'real-env-smoke',
|
||||||
|
detail: { taskNo: task.taskNo, rechargeOrderId: recharge.id, objectKey: upload.objectKey },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert(log.id, 'operation log should be created');
|
||||||
|
|
||||||
|
const logs = await request('/admin/operations/audit-logs', { headers: tenantHeader });
|
||||||
|
assert(logs.some((item) => item.id === log.id), 'operations audit logs should include smoke log');
|
||||||
|
|
||||||
|
const trace = await request(`/admin/operations/trace?tenantId=${data.tenant.id}&taskId=${task.id}`);
|
||||||
|
assert(trace.messages?.length >= 2, 'trace should include smoke messages');
|
||||||
|
|
||||||
|
const reconciliation = await request(`/admin/operations/reconciliation?tenantId=${data.tenant.id}&taskId=${task.id}`);
|
||||||
|
assert(reconciliation.messages?._count?._all >= 2, 'reconciliation should return message aggregate');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
apiBaseUrl,
|
||||||
|
tenantId: data.tenant.id,
|
||||||
|
userId: data.user.id,
|
||||||
|
rechargeOrderId: recharge.id,
|
||||||
|
batchTaskId: task.id,
|
||||||
|
messageCount: task.messages.length,
|
||||||
|
uploadedObjectKey: upload.objectKey,
|
||||||
|
operationLogId: log.id,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user