diff --git a/docs/phase-8-operations-acceptance-progress.md b/docs/phase-8-operations-acceptance-progress.md index fa5ad82..733f653 100644 --- a/docs/phase-8-operations-acceptance-progress.md +++ b/docs/phase-8-operations-acceptance-progress.md @@ -15,6 +15,7 @@ 6. 输出 500 条/秒压测报告。已完成,见 `docs/phase-8-500tps-performance-report.md`。 7. 输出 Linux 上线部署文档和回滚方案。已完成,见 `docs/linux-deployment-and-rollback.md`。 8. 运行 Prisma generate、API build、阶段验证脚本和 API health smoke。已完成。 +9. 在真实 PostgreSQL/Redis/MinIO 下补充最小 seed 和核心 HTTP smoke。已完成。 ## 验收记录 @@ -49,10 +50,15 @@ - `npm run verify:phase8` 通过。 - 队列契约校验通过。 - Go Gateway `go test ./...` 通过。 - - BullMQ Spike:15000 条消息、并发 500、端到端约 825.48 TPS,满足 500 条/秒指标。 + - BullMQ Spike:15000 条消息、并发 500、端到端约 705.65 TPS,满足 500 条/秒指标。 - Prisma Client 生成通过。 - API build 通过。 - 前端 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 条/秒压测报告已输出。 - Linux 上线部署与回滚方案已输出。 @@ -63,6 +69,7 @@ - 输出 500 条/秒压测报告:已完成。 - 输出 Linux 部署方案:已完成,覆盖 Docker Compose、systemd、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。 - API health smoke 通过:`/api/health` 返回 `ok`。 +- 真实 PostgreSQL/Redis/MinIO 核心 smoke 通过:最小 seed 与登录、充值、发送、上传、日志、追踪、对账链路均可执行。 ## 结论 @@ -71,4 +78,4 @@ ## 风险与说明 - 阶段 8 的统计查询以第一版可运营为目标,优先使用 Prisma 聚合与分页查询;大表分区、物化统计、Redis 热统计将在后续版本增强。 -- 本地没有 PostgreSQL 服务时,仅执行 Prisma Client 生成和 TypeScript 构建,不执行数据库迁移。 +- 当前真实环境 smoke 不包含真实运营商 SMSC/CMPP 互通;该项需运营商测试环境后补充联调记录。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 4208d8d..be26ffc 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -42,22 +42,28 @@ npm --prefix api test -- channels.service.spec.ts npm --prefix api test -- operations.service.spec.ts npm run test:api npm run verify:phase8 +npm run build npm run spike:gateway npm --prefix api test 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` 通过。 -- 阶段 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。 - 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。 -- 暂未跑真实 PostgreSQL/Redis/MinIO 集成测试。 - BullMQ 真实链路仍由 `npm run spike:bullmq` 覆盖,不在 Jest 内启动 Redis。 - Gateway 未接真实运营商 SMSC;真实 CMPP 互通需要运营商测试环境后补充联调记录。 diff --git a/tools/smoke/real-env-smoke.mjs b/tools/smoke/real-env-smoke.mjs new file mode 100644 index 0000000..cd35273 --- /dev/null +++ b/tools/smoke/real-env-smoke.mjs @@ -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(); + });