feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+643 -175
View File
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -19,8 +19,8 @@
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
"@nestjs/swagger": "^11.2.3",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.0.1",
"@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0",
"bullmq": "^5.79.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
@@ -35,20 +35,14 @@
"@types/jest": "^30.0.0",
"@types/node": "^25.9.3",
"jest": "^30.4.2",
"prisma": "^7.0.1",
"prisma": "^7.9.0",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^6.0.3"
},
"overrides": {
"@hono/node-server": "1.19.13"
},
"overrides": {
"exceljs": {
"uuid": "11.1.1"
},
"@prisma/dev": {
"@hono/node-server": "1.19.13"
}
}
}
@@ -0,0 +1,69 @@
-- Historical receipts created before channel-aware matching could be attached to
-- the right message but retain the wrong channel when two channels shared the
-- same supplier account. Only repair rows that have exactly one submit record
-- for the same message, upstream message id and destination phone number.
WITH unique_receipt_submit AS (
SELECT
receipt.id AS receipt_id,
MIN(submit."channelId") AS correct_channel_id
FROM "SmsReceiptRecord" receipt
JOIN "SmsMessageRecord" message
ON message.id = receipt."messageRecordId"
JOIN "SmsSubmitRecord" submit
ON submit."messageRecordId" = message.id
AND submit."gatewayMessageId" = receipt."gatewayMessageId"
WHERE receipt."receiptStatus" = 'delivered'
AND receipt."phoneNumber" = message."phoneNumber"
GROUP BY receipt.id
HAVING COUNT(submit.id) = 1
)
UPDATE "SmsReceiptRecord" receipt
SET "channelId" = matched.correct_channel_id
FROM unique_receipt_submit matched
WHERE receipt.id = matched.receipt_id
AND receipt."channelId" IS DISTINCT FROM matched.correct_channel_id;
-- Aggregate the corrected successful receipt onto the message master record.
-- DISTINCT ON chooses the first successful receipt for segmented messages while
-- preserving the original submit/gateway identity of the current attempt.
WITH delivered_match AS (
SELECT DISTINCT ON (message.id)
message.id AS message_record_id,
submit."channelId" AS channel_id,
receipt."gatewayMessageId" AS gateway_message_id,
receipt."rawStatus" AS raw_status,
receipt."errorCode" AS error_code,
receipt."errorMessage" AS error_message,
receipt."deliveredAt" AS delivered_at
FROM "SmsMessageRecord" message
JOIN "SmsReceiptRecord" receipt
ON receipt."messageRecordId" = message.id
AND receipt."receiptStatus" = 'delivered'
AND receipt."phoneNumber" = message."phoneNumber"
JOIN "SmsSubmitRecord" submit
ON submit."messageRecordId" = message.id
AND submit."gatewayMessageId" = receipt."gatewayMessageId"
AND submit."channelId" = receipt."channelId"
WHERE message."gatewayMessageId" = receipt."gatewayMessageId"
ORDER BY message.id, receipt."deliveredAt" ASC, receipt.id ASC
)
UPDATE "SmsMessageRecord" message
SET
status = 'delivered',
"receiptStatus" = 'delivered',
"receiptRawStatus" = matched.raw_status,
"channelId" = matched.channel_id,
"gatewayMessageId" = matched.gateway_message_id,
"errorCode" = matched.error_code,
"errorMessage" = matched.error_message,
"deliveredAt" = matched.delivered_at,
"timeoutAt" = NULL,
"updatedAt" = CURRENT_TIMESTAMP
FROM delivered_match matched
WHERE message.id = matched.message_record_id
AND (
message.status IS DISTINCT FROM 'delivered'
OR message."receiptStatus" IS DISTINCT FROM 'delivered'
OR message."deliveredAt" IS DISTINCT FROM matched.delivered_at
OR message."channelId" IS DISTINCT FROM matched.channel_id
);
+2
View File
@@ -9,6 +9,7 @@ import { BillingModule } from './billing/billing.module';
import { ChannelsModule } from './channels/channels.module';
import { CertificationModule } from './certification/certification.module';
import { DictionariesModule } from './dictionaries/dictionaries.module';
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
import { FilesModule } from './files/files.module';
import { HealthController } from './health.controller';
import { OperationsModule } from './operations/operations.module';
@@ -35,6 +36,7 @@ import { UsersModule } from './users/users.module';
AuditModule,
FilesModule,
DictionariesModule,
DeletionGovernanceModule,
BillingModule,
CertificationModule,
SmsConfigModule,
+46 -17
View File
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@n
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from './current-session-user.decorator';
import { AuthService, LoginDto } from './auth.service';
import { SessionService } from './session.service';
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
import type { SessionRequest } from './session-validation.middleware';
import { UsersService } from '../users/users.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -38,13 +38,31 @@ export class AuthController {
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
}
@Get('auth/session')
currentSession(@Req() request: SessionRequest) {
@Get(['admin/auth/session', 'client/auth/session'])
async currentSession(@Req() request: SessionRequest) {
this.assertSession(request);
return this.sessions.publicSession(request.authSession!);
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: request.sessionUserId! },
include: { tenant: true, roles: { include: { role: true } } },
});
return {
portal: request.authSession!.portal,
locked: Boolean(request.authSession!.lockedAt),
user: {
id: user.id,
tenantId: user.tenantId,
tenantName: user.tenant?.name,
username: user.username,
email: user.email,
phone: user.phone,
displayName: user.displayName,
roles: user.roles.map((item) => item.role.code),
},
...this.sessions.publicSession(request.authSession!),
};
}
@Post('auth/session/touch')
@Post(['admin/auth/session/touch', 'client/auth/session/touch'])
async touch(@Req() request: SessionRequest) {
this.assertSession(request);
const result = await this.sessions.touch(request.sessionToken!);
@@ -52,7 +70,7 @@ export class AuthController {
return this.sessions.publicSession(result.record);
}
@Post('auth/session/lock')
@Post(['admin/auth/session/lock', 'client/auth/session/lock'])
async lock(@Req() request: SessionRequest) {
this.assertSession(request);
const record = await this.sessions.lock(request.sessionToken!);
@@ -60,17 +78,17 @@ export class AuthController {
return { locked: Boolean(record) };
}
@Post('auth/session/unlock')
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
this.assertSession(request);
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
this.setCookie(response, result.token);
this.setCookie(response, result.record.portal, result.token);
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
return this.sessions.publicSession(result.record);
}
@Post('auth/reauthenticate')
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
this.assertSession(request);
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
@@ -79,22 +97,23 @@ export class AuthController {
return this.sessions.publicSession(result.record);
}
@Post('auth/logout')
@Post(['admin/auth/logout', 'client/auth/logout'])
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
this.clearCookie(response);
this.clearCookie(response, request.authSession?.portal);
return { success: true };
}
@Post('auth/password')
@Post(['admin/auth/password', 'client/auth/password'])
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
}
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
this.setCookie(response, result.sessionToken);
this.setCookie(response, result.portal, result.sessionToken);
this.clearLegacyCookie(response);
await this.prisma.operationLog.create({
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
});
@@ -114,8 +133,8 @@ export class AuthController {
}
}
private setCookie(response: CookieResponse, token: string) {
response.cookie(this.sessions.cookieName, token, {
private setCookie(response: CookieResponse, portal: SessionPortal, token: string) {
response.cookie(this.sessions.cookieName(portal), token, {
httpOnly: true,
secure: this.sessions.cookieSecure,
sameSite: 'lax',
@@ -123,8 +142,18 @@ export class AuthController {
});
}
private clearCookie(response: CookieResponse) {
response.clearCookie(this.sessions.cookieName, {
private clearCookie(response: CookieResponse, portal?: SessionPortal) {
if (!portal) return;
response.clearCookie(this.sessions.cookieName(portal), {
httpOnly: true,
secure: this.sessions.cookieSecure,
sameSite: 'lax',
path: '/',
});
}
private clearLegacyCookie(response: CookieResponse) {
response.clearCookie(this.sessions.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME, {
httpOnly: true,
secure: this.sessions.cookieSecure,
sameSite: 'lax',
@@ -6,7 +6,7 @@ const record = {
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
};
function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token'): SessionRequest {
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest {
return {
originalUrl: path,
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
@@ -14,9 +14,11 @@ function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token'
}
describe('SessionValidationMiddleware', () => {
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
it('accepts an active Redis session and exposes its user and record', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
const currentRequest = request();
const next = jest.fn();
@@ -31,7 +33,7 @@ describe('SessionValidationMiddleware', () => {
it('rejects a session after the user session version changes', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
@@ -40,12 +42,34 @@ describe('SessionValidationMiddleware', () => {
it('only lets a locked session reach unlock and logout endpoints', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
const next = jest.fn();
await middleware.use(request('/api/auth/session/unlock'), {}, next);
await middleware.use(request('/api/admin/auth/session/unlock'), {}, next);
expect(next).toHaveBeenCalled();
});
it('selects only the cookie belonging to the requested portal', async () => {
const clientRecord = { ...record, portal: 'client' as const };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
await middleware.use(currentRequest, {}, jest.fn());
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
expect(currentRequest.sessionToken).toBe('client-token');
});
it('does not accept an admin cookie for a client route', async () => {
const sessions = { cookieName, validate: jest.fn(), remove: jest.fn() };
const middleware = new SessionValidationMiddleware({} as never, sessions as never);
await expect(middleware.use(request('/api/client/users', 'cmpp_admin_session=admin-token'), {}, jest.fn()))
.rejects.toBeInstanceOf(UnauthorizedException);
expect(sessions.validate).not.toHaveBeenCalled();
});
});
+16 -7
View File
@@ -1,6 +1,6 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthSessionRecord, DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionService } from './session.service';
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
export type SessionRequest = {
header(name: string): string | undefined;
@@ -22,9 +22,10 @@ export class SessionValidationMiddleware implements NestMiddleware {
return;
}
const token = this.readCookie(request.header('cookie'));
const portal = this.portalForPath(path);
const token = portal ? this.readCookie(request.header('cookie'), portal) : undefined;
if (!token) {
if ((path.includes('/admin/') && !path.includes('/admin/gateway/')) || path.includes('/client/') || path.includes('/auth/')) {
if (portal && !path.includes('/admin/gateway/')) {
throw this.unauthorized('SESSION_INVALID', '请先登录');
}
next();
@@ -43,14 +44,15 @@ export class SessionValidationMiddleware implements NestMiddleware {
await this.sessions.remove(token);
throw this.unauthorized('SESSION_REVOKED', '登录会话已被撤销,请重新登录');
}
if ((path.includes('/admin/') && result.record.portal !== 'admin') || (path.includes('/client/') && result.record.portal !== 'client')) {
if (result.record.portal !== portal) {
throw this.unauthorized('SESSION_PORTAL_MISMATCH', '登录入口与当前会话不匹配');
}
request.sessionUserId = user.id;
request.sessionToken = token;
request.authSession = result.record;
if (result.status === 'locked' && !path.includes('/auth/session/unlock') && !path.includes('/auth/logout')) {
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
if (result.status === 'locked' && !isSessionRecoveryRoute) {
if (result.newlyLocked) {
await this.prisma.operationLog.create({
data: { userId: user.id, action: 'auth.session_locked', resource: 'auth_session', detail: { portal: result.record.portal, reason: 'idle_timeout' } },
@@ -61,11 +63,18 @@ export class SessionValidationMiddleware implements NestMiddleware {
next();
}
private readCookie(cookieHeader?: string) {
private portalForPath(path: string): SessionPortal | undefined {
if (/\/admin\//.test(path)) return 'admin';
if (/\/client\//.test(path)) return 'client';
return undefined;
}
private readCookie(cookieHeader: string | undefined, portal: SessionPortal) {
if (!cookieHeader) return undefined;
const cookieName = this.sessions.cookieName(portal);
for (const part of cookieHeader.split(';')) {
const [name, ...value] = part.trim().split('=');
if (name === SESSION_COOKIE_NAME || name === DEVELOPMENT_SESSION_COOKIE_NAME) return decodeURIComponent(value.join('='));
if (name === cookieName) return decodeURIComponent(value.join('='));
}
return undefined;
}
+8
View File
@@ -70,4 +70,12 @@ describe('SessionService', () => {
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
}
});
it('uses separate cookie names for admin and client sessions', () => {
process.env.SESSION_COOKIE_SECURE = 'false';
const service = new SessionService();
expect(service.cookieName('admin')).toBe('cmpp_admin_session');
expect(service.cookieName('client')).toBe('cmpp_client_session');
delete process.env.SESSION_COOKIE_SECURE;
});
});
+7 -2
View File
@@ -23,6 +23,10 @@ export type SessionValidationResult =
const SESSION_PREFIX = 'cmpp:auth:session:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
export const CLIENT_SESSION_COOKIE_NAME = '__Host-cmpp_client_session';
export const DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME = 'cmpp_admin_session';
export const DEVELOPMENT_CLIENT_SESSION_COOKIE_NAME = 'cmpp_client_session';
@Injectable()
export class SessionService implements OnModuleDestroy {
@@ -137,8 +141,9 @@ export class SessionService implements OnModuleDestroy {
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
}
get cookieName() {
return this.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME;
cookieName(portal: SessionPortal) {
if (this.cookieSecure) return portal === 'admin' ? ADMIN_SESSION_COOKIE_NAME : CLIENT_SESSION_COOKIE_NAME;
return portal === 'admin' ? DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME : DEVELOPMENT_CLIENT_SESSION_COOKIE_NAME;
}
get absoluteTimeoutMs() {
+9 -2
View File
@@ -2,10 +2,12 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import {
BillingService,
BillingActionDto,
CreateManualRechargeDto,
ManualRechargePreflightDto,
CreateBillingRuleDto,
CreateSmsBillingRecordDto,
CreateTenantAccountDto,
@@ -46,8 +48,13 @@ export class BillingController {
@Post('manual-recharges')
@RequireRecentAuthentication()
createManualRecharge(@Body() body: CreateManualRechargeDto) {
return this.billing.createManualRecharge(body);
createManualRecharge(@Body() body: CreateManualRechargeDto, @CurrentSessionUserId() operatorId?: string) {
return this.billing.createManualRecharge({ ...body, operatorId });
}
@Post('manual-recharges/preflight')
manualRechargePreflight(@Body() body: ManualRechargePreflightDto) {
return this.billing.manualRechargePreflight(body);
}
@Post('estimate')
+74 -6
View File
@@ -1,13 +1,22 @@
import { BillingService } from './billing.service';
function createPrismaMock() {
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0 };
return {
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0, updatedAt: new Date('2026-07-21T10:00:00.000Z') };
const prisma = {
accountState,
tenant: {
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
},
tenantAccount: {
findMany: jest.fn(),
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
create: jest.fn(),
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
updateMany: jest.fn().mockImplementation(({ data }) => {
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
return Promise.resolve({ count: 1 });
}),
update: jest.fn().mockImplementation(({ data }) => {
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
@@ -16,10 +25,12 @@ function createPrismaMock() {
},
accountTransaction: {
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
},
rechargeOrder: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })),
},
smsBillingRecord: {
@@ -31,9 +42,14 @@ function createPrismaMock() {
create: jest.fn(),
},
operationLog: {
create: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
},
$executeRaw: jest.fn(),
};
return Object.assign(prisma, {
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
});
}
describe('BillingService', () => {
@@ -129,6 +145,8 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: 2000,
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
idempotencyKey: 'manual-recharge-1',
operatorId: 'admin-1',
remark: '线下转账人工充值',
});
@@ -153,6 +171,7 @@ describe('BillingService', () => {
action: 'billing.manual_recharge',
resource: 'recharge_order',
resourceId: 'order-1',
detail: expect.objectContaining({ idempotencyKey: 'manual-recharge-1', previousBalanceCents: 1000, balanceAfterCents: 3000 }),
}),
});
});
@@ -189,14 +208,16 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: -300,
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
idempotencyKey: 'manual-correction-1',
operatorId: 'admin-1',
remark: '人工冲正',
});
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 700 },
expect(prisma.tenantAccount.updateMany).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', updatedAt: new Date('2026-07-21T10:00:00.000Z') },
data: { balanceCents: { increment: -300 } },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -214,6 +235,53 @@ describe('BillingService', () => {
});
});
it('preflights a manual balance correction with the persisted account version and predicted balance', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
await expect(service.manualRechargePreflight({ tenantId: 'tenant-1', amountCents: -250 })).resolves.toEqual(
expect.objectContaining({
tenant: { id: 'tenant-1', name: '示例企业', code: 'TENANT-1' },
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
balanceCents: 1000,
amountCents: -250,
balanceAfterCents: 750,
direction: 'correction',
allowedActions: ['confirm'],
}),
);
});
it('replays a manual recharge by idempotency key without creating another order', async () => {
const prisma = createPrismaMock();
prisma.operationLog.findFirst.mockResolvedValue({
id: 'operation-existing', tenantId: 'tenant-1', resourceId: 'order-existing',
detail: { idempotencyKey: 'manual-recharge-replay', amountCents: 2000 },
});
prisma.rechargeOrder.findUnique.mockResolvedValue({ id: 'order-existing', tenantId: 'tenant-1', amountCents: 2000 });
prisma.accountTransaction.findFirst.mockResolvedValue({ balanceAfter: 3000 });
const service = new BillingService(prisma as never);
await expect(service.createManualRecharge({
tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z', idempotencyKey: 'manual-recharge-replay',
})).resolves.toEqual(expect.objectContaining({ operationId: 'operation-existing', replayed: true, balanceAfterCents: 3000 }));
expect(prisma.rechargeOrder.create).not.toHaveBeenCalled();
expect(prisma.tenantAccount.updateMany).not.toHaveBeenCalled();
});
it('rejects an outdated account version before creating financial records', async () => {
const prisma = createPrismaMock();
prisma.tenantAccount.updateMany.mockResolvedValue({ count: 0 });
const service = new BillingService(prisma as never);
await expect(service.createManualRecharge({
tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T09:59:00.000Z', idempotencyKey: 'manual-recharge-stale',
})).rejects.toThrow('企业余额已变化,请重新核对后再充值');
expect(prisma.rechargeOrder.create).not.toHaveBeenCalled();
expect(prisma.accountTransaction.create).not.toHaveBeenCalled();
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
+132 -19
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
@@ -45,10 +45,17 @@ export interface CreateRechargeOrderDto {
export interface CreateManualRechargeDto {
tenantId: string;
amountCents: number;
expectedAccountUpdatedAt: string;
idempotencyKey: string;
operatorId?: string;
remark?: string;
}
export interface ManualRechargePreflightDto {
tenantId: string;
amountCents: number;
}
export interface EstimateSmsCostDto {
tenantId: string;
applicationId?: string;
@@ -187,28 +194,130 @@ export class BillingService {
}
async createManualRecharge(data: CreateManualRechargeDto) {
const order = await this.createRechargeOrder({
tenantId: data.tenantId,
amountCents: data.amountCents,
payMethod: 'manual_topup',
operatorId: data.operatorId,
remark: data.remark,
assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false });
const idempotencyKey = data.idempotencyKey?.trim();
if (!idempotencyKey || idempotencyKey.length < 8 || idempotencyKey.length > 128) {
throw new BadRequestException('人工充值幂等键长度必须为 8 至 128 个字符');
}
const expectedUpdatedAt = new Date(data.expectedAccountUpdatedAt);
if (Number.isNaN(expectedUpdatedAt.getTime())) throw new BadRequestException('账户版本无效,请重新核对充值信息');
const replay = await this.prisma.operationLog.findFirst({
where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } },
orderBy: { createdAt: 'desc' },
});
await this.prisma.operationLog.create({
data: {
tenantId: data.tenantId,
userId: data.operatorId,
action: 'billing.manual_recharge',
resource: 'recharge_order',
resourceId: order.id,
detail: {
if (replay) return this.manualRechargeReplay(replay, data);
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'manual-recharge:' + idempotencyKey}))`;
const existing = await tx.operationLog.findFirst({
where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } },
orderBy: { createdAt: 'desc' },
});
if (existing) {
const detail = asRecord(existing.detail);
if (existing.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) {
throw new ConflictException('该幂等键已用于另一笔人工充值');
}
const order = existing.resourceId ? await tx.rechargeOrder.findUnique({ where: { id: existing.resourceId } }) : null;
if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查');
const transaction = await tx.accountTransaction.findFirst({
where: { relatedType: 'recharge_order', relatedId: order.id },
orderBy: { createdAt: 'desc' },
});
return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: existing.id, replayed: true };
}
const tenant = await tx.tenant.findFirst({ where: { id: data.tenantId, status: { not: 'deleted' } }, select: { id: true } });
if (!tenant) throw new NotFoundException('充值企业不存在或已删除');
const account = await tx.tenantAccount.findUnique({ where: { tenantId: data.tenantId } });
if (!account) throw new ConflictException('企业账户尚未初始化,请重新核对充值信息');
const previousBalanceCents = moneyToNumber(account.balanceCents);
const changed = await tx.tenantAccount.updateMany({
where: { tenantId: data.tenantId, updatedAt: expectedUpdatedAt },
data: { balanceCents: { increment: data.amountCents } },
});
if (changed.count !== 1) throw new ConflictException('企业余额已变化,请重新核对后再充值');
const balanceAfterCents = previousBalanceCents + data.amountCents;
const order = await tx.rechargeOrder.create({
data: {
tenantId: data.tenantId,
orderNo: `MR${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
amountCents: data.amountCents,
orderNo: order.orderNo,
status: 'paid',
payMethod: 'manual_topup',
paidAt: new Date(),
operatorId: data.operatorId,
remark: data.remark,
} as Prisma.InputJsonValue,
},
},
});
await tx.accountTransaction.create({
data: {
tenantId: data.tenantId,
transactionType: 'recharge',
amountCents: data.amountCents,
balanceAfter: balanceAfterCents,
relatedType: 'recharge_order',
relatedId: order.id,
remark: data.remark,
},
});
const operation = await tx.operationLog.create({
data: {
tenantId: data.tenantId,
userId: data.operatorId,
action: 'billing.manual_recharge',
resource: 'recharge_order',
resourceId: order.id,
detail: {
idempotencyKey,
amountCents: data.amountCents,
previousBalanceCents,
balanceAfterCents,
orderNo: order.orderNo,
remark: data.remark,
} as Prisma.InputJsonValue,
},
});
return { ...order, balanceAfterCents, operationId: operation.id, replayed: false };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
async manualRechargePreflight(data: ManualRechargePreflightDto) {
assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false });
const tenant = await this.prisma.tenant.findFirst({
where: { id: data.tenantId, status: { not: 'deleted' } },
select: { id: true, name: true, code: true },
});
return order;
if (!tenant) throw new NotFoundException('充值企业不存在或已删除');
const account = await this.getAccountOrCreate(data.tenantId);
const balanceCents = moneyToNumber(account.balanceCents);
return {
tenant,
accountId: account.id,
expectedAccountUpdatedAt: account.updatedAt.toISOString(),
balanceCents,
creditCents: moneyToNumber(account.creditCents),
amountCents: data.amountCents,
balanceAfterCents: balanceCents + data.amountCents,
direction: data.amountCents > 0 ? 'topup' : 'correction',
allowedActions: ['confirm'],
blockedReasons: [],
};
}
private async manualRechargeReplay(log: { id: string; tenantId: string | null; resourceId: string | null; detail: Prisma.JsonValue | null }, data: CreateManualRechargeDto) {
const detail = asRecord(log.detail);
if (log.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) {
throw new ConflictException('该幂等键已用于另一笔人工充值');
}
const order = log.resourceId ? await this.prisma.rechargeOrder.findUnique({ where: { id: log.resourceId } }) : null;
if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查');
const transaction = await this.prisma.accountTransaction.findFirst({
where: { relatedType: 'recharge_order', relatedId: order.id },
orderBy: { createdAt: 'desc' },
});
return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: log.id, replayed: true };
}
estimateSmsCost(data: EstimateSmsCostDto) {
@@ -375,3 +484,7 @@ function estimateBillingUnits(content: string) {
}
return Math.ceil(length / 67);
}
function asRecord(value: Prisma.JsonValue | null | undefined): Record<string, Prisma.JsonValue> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, Prisma.JsonValue> : {};
}
+5 -3
View File
@@ -1,6 +1,8 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import {
ChannelsService,
ChangeChannelStatusDto,
@@ -25,7 +27,7 @@ import {
@ApiTags('channels')
@Controller('admin')
export class ChannelsController {
constructor(private readonly channels: ChannelsService) {}
constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {}
@Get('channels')
listChannels() {
@@ -64,8 +66,8 @@ export class ChannelsController {
@Delete('channels/:id')
@RequireRecentAuthentication()
deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
return this.channels.deleteChannel(channelId, body);
deleteChannel(@Param('id') channelId: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
return this.deletions.delete('channel', channelId, { ...body, operatorId });
}
@Get('channels/:id/metrics')
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ChannelsController } from './channels.controller';
import { ChannelsService } from './channels.service';
import { DeletionGovernanceModule } from '../deletion-governance/deletion-governance.module';
@Module({
imports: [DeletionGovernanceModule],
controllers: [ChannelsController],
providers: [ChannelsService],
exports: [ChannelsService],
@@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
@ApiTags('deletion-governance')
@Controller('admin/deletions')
export class AdminDeletionGovernanceController {
constructor(private readonly deletions: DeletionGovernanceService) {}
@Get(':type/:id/preflight')
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string) {
return this.deletions.preflight(type, id);
}
@Post(':type/:id')
@RequireRecentAuthentication()
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
return this.deletions.delete(type, id, { ...body, operatorId });
}
}
@ApiTags('client-deletion-governance')
@Controller('client/deletions')
export class ClientDeletionGovernanceController {
constructor(private readonly deletions: DeletionGovernanceService) {}
@Get(':type/:id/preflight')
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) {
return this.deletions.preflight(type, id, tenantId);
}
@Post(':type/:id')
@RequireRecentAuthentication()
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminDeletionGovernanceController, ClientDeletionGovernanceController } from './deletion-governance.controller';
import { DeletionGovernanceService } from './deletion-governance.service';
@Module({
controllers: [AdminDeletionGovernanceController, ClientDeletionGovernanceController],
providers: [DeletionGovernanceService],
exports: [DeletionGovernanceService],
})
export class DeletionGovernanceModule {}
@@ -0,0 +1,110 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { DeletionGovernanceService } from './deletion-governance.service';
describe('DeletionGovernanceService', () => {
const now = new Date('2026-07-21T10:00:00.000Z');
function setup() {
const tx = {
operationLog: { findFirst: jest.fn(), create: jest.fn() },
smsChannel: { updateMany: jest.fn() },
smsSignature: { updateMany: jest.fn() },
smsTemplate: { updateMany: jest.fn() },
};
const prisma = {
operationLog: { findFirst: jest.fn() },
smsChannel: { findUnique: jest.fn() },
smsSignature: { findFirst: jest.fn() },
smsTemplate: { findFirst: jest.fn() },
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
};
return { service: new DeletionGovernanceService(prisma as never), prisma, tx };
}
it('blocks channel deletion when a live group still references it', async () => {
const { service, prisma } = setup();
prisma.smsChannel.findUnique.mockResolvedValue({
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
groupItems: [{ priority: 10, group: { name: '移动主通道组' } }], routeRules: [], connectionStates: [], reportTasks: [],
});
const result = await service.preflight('channel', 'channel-1');
expect(result.allowedActions).toEqual([]);
expect(result.blockedReasons).toContain('引用该通道的通道组共 1 项,请先解除或完成');
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10']);
});
it('returns an allowed template preflight scoped to the client tenant', async () => {
const { service, prisma } = setup();
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
sendTasks: [], batchTasks: [],
});
const result = await service.preflight('template', 'template-1', 'tenant-1');
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
expect(result.allowedActions).toEqual(['delete']);
expect(result.identity.tenant).toBe('示例企业');
});
it('blocks signature deletion and exposes the referencing template and drainage items', async () => {
const { service, prisma } = setup();
prisma.smsSignature.findFirst.mockResolvedValue({
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
templates: [{ id: 'template-1', name: '验证码模板' }],
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
});
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
expect(result.allowedActions).toEqual([]);
expect(result.dependencies).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1'] }),
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1'] }),
]));
});
it('requires version, idempotency key and a meaningful reason', async () => {
const { service } = setup();
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
await expect(service.delete('template', 'template-1', { expectedUpdatedAt: now.toISOString(), idempotencyKey: 'key', reason: '短' })).rejects.toBeInstanceOf(BadRequestException);
});
it('soft deletes once and writes an auditable operation number', async () => {
const { service, prisma, tx } = setup();
prisma.operationLog.findFirst.mockResolvedValue(null);
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
sendTasks: [], batchTasks: [],
});
tx.operationLog.findFirst.mockResolvedValue(null);
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
const result = await service.delete('template', 'template-1', {
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', reason: '测试删除治理', operatorId: 'user-1',
}, 'tenant-1');
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
});
it('rejects a stale optimistic-lock version', async () => {
const { service, prisma } = setup();
prisma.operationLog.findFirst.mockResolvedValue(null);
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
sendTasks: [], batchTasks: [],
});
await expect(service.delete('template', 'template-1', {
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
}, 'tenant-1')).rejects.toBeInstanceOf(ConflictException);
});
});
@@ -0,0 +1,168 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeleteTargetDto = {
expectedUpdatedAt?: string;
idempotencyKey?: string;
reason?: string;
operatorId?: string;
};
type Dependency = { kind: string; label: string; count: number; items: string[] };
export type DeletionPreflight = {
type: DeletionTargetType;
id: string;
expectedUpdatedAt: string;
identity: Record<string, string>;
dependencies: Dependency[];
impacts: string[];
blockedReasons: string[];
allowedActions: Array<'delete'>;
recoverability: { mode: 'soft_delete'; description: string };
};
@Injectable()
export class DeletionGovernanceService {
constructor(private readonly prisma: PrismaService) {}
async preflight(type: DeletionTargetType, id: string, tenantId?: string): Promise<DeletionPreflight> {
this.assertType(type);
if (type === 'channel') return this.channelPreflight(id, tenantId);
if (type === 'signature') return this.signaturePreflight(id, tenantId);
return this.templatePreflight(id, tenantId);
}
async delete(type: DeletionTargetType, id: string, body: DeleteTargetDto, tenantId?: string) {
this.assertType(type);
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
const idempotencyKey = body.idempotencyKey?.trim();
const reason = body.reason?.trim();
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因');
const replay = await this.prisma.operationLog.findFirst({
where: {
action: 'governance.delete', resource: type, resourceId: id,
detail: { path: ['idempotencyKey'], equals: idempotencyKey },
},
orderBy: { createdAt: 'desc' },
});
if (replay) return { operationId: replay.id, status: 'deleted', replayed: true };
const preflight = await this.preflight(type, id, tenantId);
if (preflight.expectedUpdatedAt !== expectedUpdatedAt) throw new ConflictException('对象已被其他操作更新,请重新检查删除影响');
if (!preflight.allowedActions.includes('delete')) {
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons });
}
return this.prisma.$transaction(async (tx) => {
const existing = await tx.operationLog.findFirst({
where: {
action: 'governance.delete', resource: type, resourceId: id,
detail: { path: ['idempotencyKey'], equals: idempotencyKey },
},
});
if (existing) return { operationId: existing.id, status: 'deleted', replayed: true };
const updated = type === 'channel'
? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } })
: type === 'signature'
? await tx.smsSignature.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted', pendingReport: false } })
: await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检');
const log = await tx.operationLog.create({
data: {
tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
detail: { idempotencyKey, reason, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts },
},
});
return { operationId: log.id, status: 'deleted', replayed: false };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
private async channelPreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
if (tenantId) throw new BadRequestException('客户端无权删除运营通道');
const item = await this.prisma.smsChannel.findUnique({
where: { id },
include: {
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
routeRules: { where: { status: 'active' } },
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } } },
},
});
if (!item) throw new NotFoundException('通道不存在');
const dependencies: Dependency[] = [
dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority}`)),
dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)),
dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)),
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => row.id)),
];
return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies,
['删除后不再参与新消息路由', '历史发送、回执和审计记录继续保留']);
}
private async signaturePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
const item = await this.prisma.smsSignature.findFirst({
where: { id, ...(tenantId ? { tenantId } : {}) },
include: {
tenant: { select: { name: true } }, application: { select: { name: true } },
templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true } },
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
},
});
if (!item) throw new NotFoundException('签名不存在或无权访问');
const dependencies: Dependency[] = [
dep('templates', '仍在使用该签名的模板', item.templates.map((row) => `${row.name}${row.id}`)),
dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}${row.id}`)),
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}${row.status}`)),
];
return buildPreflight('signature', item.id, item.updatedAt, {
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定',
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '历史消息、审核与报备记录继续保留']);
}
private async templatePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
const item = await this.prisma.smsTemplate.findFirst({
where: { id, ...(tenantId ? { tenantId } : {}) },
include: {
tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } },
sendTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
batchTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
},
});
if (!item) throw new NotFoundException('模板不存在或无权访问');
const dependencies: Dependency[] = [
dep('send_tasks', '未结束发送任务', item.sendTasks.map((row) => `${row.id}${row.status}`)),
dep('batch_tasks', '未结束批量任务', item.batchTasks.map((row) => `${row.id}${row.status}`)),
];
return buildPreflight('template', item.id, item.updatedAt, {
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name,
signature: item.signature?.name ?? '未绑定',
}, item.auditStatus, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']);
}
private assertType(type: string): asserts type is DeletionTargetType {
if (!['channel', 'signature', 'template'].includes(type)) throw new BadRequestException('不支持的删除对象类型');
}
}
function dep(kind: string, label: string, items: string[]): Dependency {
return { kind, label, count: items.length, items: items.slice(0, 8) };
}
function buildPreflight(type: DeletionTargetType, id: string, updatedAt: Date, identity: Record<string, string>, status: string, dependencies: Dependency[], impacts: string[]): DeletionPreflight {
const blockedReasons = dependencies.filter((item) => item.count > 0).map((item) => `${item.label}${item.count} 项,请先解除或完成`);
if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作');
return {
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, impacts, blockedReasons,
allowedActions: blockedReasons.length ? [] : ['delete'],
recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' },
};
}
@@ -1,3 +1,4 @@
import { ConflictException } from '@nestjs/common';
import { DictionariesService } from './dictionaries.service';
function createPrismaMock() {
@@ -173,6 +174,22 @@ describe('DictionariesService', () => {
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
});
it.each([
['global', 'globalBlacklist', 'createGlobalBlacklist', { phoneNumber: '13800000000', reason: '投诉' }],
['enterprise', 'enterpriseBlacklist', 'createEnterpriseBlacklist', {
tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13900000000', reason: '退订',
}],
] as const)('maps a duplicate %s blacklist entry, including a soft-deleted row, to HTTP 409', async (_scope, model, method, input) => {
const prisma = createPrismaMock();
prisma[model].create.mockRejectedValue({ code: 'P2002', meta: { target: ['phoneNumber'] } });
const service = new DictionariesService(prisma as never);
await expect(service[method](input as never)).rejects.toBeInstanceOf(ConflictException);
await expect(service[method](input as never)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'BLACKLIST_DUPLICATE', field: 'phoneNumber' }),
});
});
it('only accepts string, image and file report field types', async () => {
const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never);
+41
View File
@@ -0,0 +1,41 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { FilesService } from './files.service';
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
@ApiTags('client-files')
@Controller('client/files')
export class ClientFilesController {
constructor(private readonly files: FilesService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
upload(
@CurrentSessionUserId() userId: string | undefined,
@UploadedFile() file: UploadedMultipartFile,
@Body('purpose') purpose: string,
@Body('prefix') prefix?: string,
) {
if (!file) throw new BadRequestException('Upload file is required');
return this.files.uploadForClient(userId, { purpose, prefix }, file);
}
@Get(':id/download')
async download(
@CurrentSessionUserId() userId: string | undefined,
@Param('id') id: string,
@Query('disposition') disposition: string | undefined,
@Res() response: DownloadResponse,
) {
const { fileObject, content } = await this.files.getClientDownload(userId, id);
const mode = disposition === 'inline' ? 'inline' : 'attachment';
response.setHeader('Content-Type', fileObject.contentType || 'application/octet-stream');
response.setHeader('Content-Length', content.length);
response.setHeader('Content-Disposition', `${mode}; filename*=UTF-8''${encodeURIComponent(fileObject.fileName)}`);
response.send(content);
}
}
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { ObjectStorageService } from './object-storage.service';
import { ClientFilesController } from './client-files.controller';
@Module({
controllers: [FilesController],
controllers: [FilesController, ClientFilesController],
providers: [FilesService, ObjectStorageService],
exports: [FilesService, ObjectStorageService],
})
+40
View File
@@ -136,4 +136,44 @@ describe('FilesService', () => {
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
});
it('derives client upload tenant from the authenticated user and ignores tenant headers', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-real' }) },
fileObject: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-client', ...data })) },
};
const objectStorage = {
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
putObject: jest.fn().mockResolvedValue({ etag: 'etag' }),
};
const service = new FilesService(prisma as never, objectStorage as never);
await expect(service.uploadForClient('user-1', {
purpose: 'enterprise_certification',
prefix: 'enterprise-certifications/license',
}, { originalname: 'license.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test') }))
.resolves.toEqual(expect.objectContaining({ tenantId: 'tenant-real', purpose: 'enterprise_certification' }));
expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'user-1' }) }));
});
it('rejects arbitrary client upload purposes and prefixes before object storage writes', async () => {
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) } };
const objectStorage = { putObject: jest.fn() };
const service = new FilesService(prisma as never, objectStorage as never);
await expect(service.uploadForClient('user-1', { purpose: 'enterprise_certification', prefix: '../admin' }, {
originalname: 'file.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test'),
})).rejects.toBeInstanceOf(BadRequestException);
expect(objectStorage.putObject).not.toHaveBeenCalled();
});
it('prevents client users from downloading another tenant file', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) },
fileObject: { findFirst: jest.fn().mockResolvedValue(null) },
};
const objectStorage = { getObject: jest.fn() };
const service = new FilesService(prisma as never, objectStorage as never);
await expect(service.getClientDownload('user-1', 'foreign-file')).rejects.toThrow('File object not found');
expect(prisma.fileObject.findFirst).toHaveBeenCalledWith({ where: { id: 'foreign-file', tenantId: 'tenant-1' } });
expect(objectStorage.getObject).not.toHaveBeenCalled();
});
});
+39
View File
@@ -26,6 +26,12 @@ export interface UploadFileDto {
prefix?: string;
}
const CLIENT_UPLOAD_RULES: Record<string, RegExp> = {
enterprise_certification: /^enterprise-certifications\/license$/,
signature_report_material: /^signature-materials$/,
drainage_report_material: /^drainage-materials\/[a-zA-Z0-9_-]+$/,
};
@Injectable()
export class FilesService {
constructor(
@@ -81,6 +87,15 @@ export class FilesService {
});
}
async uploadForClient(userId: string | undefined, data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
const tenantId = await this.resolveClientTenantId(userId);
const prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
throw new BadRequestException('不支持的客户端上传用途或目录');
}
return this.upload({ tenantId, purpose: data.purpose, prefix: data.prefix }, file);
}
async getDownload(id: string) {
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
if (!fileObject) {
@@ -92,6 +107,30 @@ export class FilesService {
content,
};
}
async getClientDownload(userId: string | undefined, id: string) {
const tenantId = await this.resolveClientTenantId(userId);
const fileObject = await this.prisma.fileObject.findFirst({ where: { id, tenantId } });
if (!fileObject) {
throw new NotFoundException('File object not found');
}
const content = await this.objectStorage.getObject(fileObject.objectKey);
return { fileObject: serializeFileObject(fileObject), content };
}
private async resolveClientTenantId(userId?: string) {
if (!userId) {
throw new NotFoundException('Client user not found');
}
const user = await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } },
select: { tenantId: true },
});
if (!user?.tenantId) {
throw new NotFoundException('Client tenant not found');
}
return user.tenantId;
}
}
const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
@@ -274,4 +274,9 @@ export class AdminSystemLogsController {
) {
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
}
@Post('exports')
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string }) {
return this.operations.exportSystemLogs(body);
}
}
@@ -1,5 +1,6 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { OperationsService } from './operations.service';
@@ -10,12 +11,12 @@ export class ClientOperationsController {
@Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
return this.operations.listBatchTasks({ tenantId, status });
return this.operations.listClientBatchTasks({ tenantId, status });
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
return this.operations.listMessages({ tenantId, taskId, phoneNumber });
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
}
@Get('messages')
@@ -27,17 +28,17 @@ export class ClientOperationsController {
@Query('phoneNumber') phoneNumber?: string,
@Query('status') status?: string,
) {
return this.operations.listMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status });
return this.operations.listClientMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status });
}
@Get('uplink-messages')
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string) {
return this.operations.listUplinkMessages({ tenantId, channelId, applicationId, phoneNumber, keyword, startTime, endTime });
return this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
}
@Get('dashboard')
dashboard(@TenantId() tenantId?: string) {
return this.operations.dashboard({ tenantId });
return this.operations.clientDashboard({ tenantId });
}
@Get('system-logs')
@@ -52,4 +53,12 @@ export class ClientOperationsController {
) {
return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
}
@Post('system-logs/exports')
exportSystemLogs(
@CurrentSessionUserId() userId: string | undefined,
@Body() body: { keyword?: string; level?: string; module?: string; range?: string },
) {
return this.operations.exportSystemLogs(body, userId);
}
}
@@ -2,6 +2,9 @@ import { OperationsService } from './operations.service';
function createPrismaMock() {
return {
user: {
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
},
smsBatchTask: {
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
count: jest.fn().mockResolvedValue(3),
@@ -250,6 +253,64 @@ describe('OperationsService', () => {
});
});
it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => {
const prisma = createPrismaMock();
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
channelId: 'channel-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
carrier: 'mobile',
province: '上海',
content: '验证码1234',
billingUnits: 1,
amountCents: 352,
status: 'delivered',
queuedAt: new Date('2026-07-21T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A', secretHash: 'secret' },
tenant: { id: 'tenant-1', name: '企业A' },
channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 },
submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }],
receiptRecords: [{
id: 'receipt-1', messageId: 'MSG-1', gatewayMessageId: 'GW-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD',
errorCode: null, errorMessage: null, deliveredAt: new Date('2026-07-21T01:00:05.000Z'), createdAt: new Date('2026-07-21T01:00:05.000Z'),
}],
}]);
const service = new OperationsService(prisma as never);
const [message] = await service.listClientMessages({ tenantId: 'tenant-1' });
expect(message).toMatchObject({
id: 'record-1', messageId: 'MSG-1', carrier: 'mobile', province: '上海', application: { id: 'app-1', name: '应用A' },
receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })],
});
expect(message).not.toHaveProperty('tenant');
expect(message).not.toHaveProperty('tenantId');
expect(message).not.toHaveProperty('channel');
expect(message).not.toHaveProperty('channelId');
expect(message).not.toHaveProperty('submitRecords');
expect(JSON.stringify(message)).not.toMatch(/passwordCipher|gatewayMessageId|unitPrice|supplier|cipher/);
});
it('returns a client dashboard without gateway state or supplier channel secrets', async () => {
const prisma = createPrismaMock();
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-1', taskNo: 'BATCH-1', tenantId: 'tenant-1', applicationId: 'app-1', phoneTotal: 1, status: 'finished',
createdAt: new Date('2026-07-21T01:00:00.000Z'), application: { id: 'app-1', name: '应用A' },
messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }],
}]);
const service = new OperationsService(prisma as never);
const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' });
expect(dashboard.gatewayConnections).toEqual([]);
expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]);
expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/);
});
it('builds dashboard and statistics aggregates', async () => {
const prisma = createPrismaMock();
prisma.cmppDownstreamDelivery.count = jest.fn()
@@ -390,6 +451,32 @@ describe('OperationsService', () => {
});
});
it('exports filtered operation logs with a traceable operation id', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(expect.objectContaining({
operationId: expect.any(String),
status: 'completed',
recordCount: 1,
truncated: false,
content: expect.stringContaining('billing.manual_recharge'),
}));
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 }));
});
it('derives client log export tenant from session user and removes internal detail and IP columns', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
const exported = await service.exportSystemLogs({ tenantId: 'spoofed-tenant' }, 'client-user');
expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) }));
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) }));
expect(exported.content.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID');
expect(exported.content).not.toContain('amountCents');
});
it('caps legacy audit-log reads with pagination', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
+208 -1
View File
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { moneyToNumber } from '../common/money';
import { randomUUID } from 'node:crypto';
export interface MessageQuery {
tenantId?: string;
@@ -89,6 +90,11 @@ export class OperationsService {
});
}
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
const items = await this.listBatchTasks(query);
return items.map(clientBatchTaskView);
}
listMessages(query: MessageQuery) {
return this.prisma.smsMessageRecord.findMany({
where: messageWhere(query),
@@ -97,6 +103,11 @@ export class OperationsService {
});
}
async listClientMessages(query: MessageQuery) {
const items = await this.listMessages(query);
return items.map(clientMessageView);
}
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
return this.prisma.smsUplinkMessage.findMany({
where: {
@@ -126,6 +137,11 @@ export class OperationsService {
});
}
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
const items = await this.listUplinkMessages(query);
return items.map(clientUplinkView);
}
async monitor(query: { tenantId?: string; channelId?: string }) {
const where = messageWhere(query);
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
@@ -293,6 +309,25 @@ export class OperationsService {
};
}
async clientDashboard(query: { tenantId?: string }) {
const dashboard = await this.dashboard(query);
return {
taskCount: dashboard.taskCount,
messageStatus: dashboard.messageStatus,
today: dashboard.today,
uplinkCount: dashboard.uplinkCount,
billing: dashboard.billing,
transactions: dashboard.transactions,
gatewayConnections: [],
pendingAuditCount: dashboard.pendingAuditCount,
pendingAudits: dashboard.pendingAudits,
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
accounts: dashboard.accounts.map(clientAccountView),
recentTasks: dashboard.recentTasks.map(clientBatchTaskView),
recentRecharges: dashboard.recentRecharges.map(clientRechargeView),
};
}
async statistics(query: { tenantId?: string; groupBy?: string }) {
const groupBy = normalizeGroupBy(query.groupBy);
if (groupBy === 'tenantId') {
@@ -378,6 +413,59 @@ export class OperationsService {
};
}
async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) {
const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined;
const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId };
const where: Prisma.OperationLogWhereInput = {
tenantId: effectiveQuery.tenantId,
userId: effectiveQuery.userId,
createdAt: createdAtRange(effectiveQuery.range),
resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined,
AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined,
OR: effectiveQuery.keyword ? [
{ action: { contains: effectiveQuery.keyword } },
{ resource: { contains: effectiveQuery.keyword } },
{ resourceId: { contains: effectiveQuery.keyword } },
{ tenant: { name: { contains: effectiveQuery.keyword } } },
{ user: { displayName: { contains: effectiveQuery.keyword } } },
{ user: { username: { contains: effectiveQuery.keyword } } },
] : undefined,
};
const rows = await this.prisma.operationLog.findMany({
where,
include: { tenant: true, user: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 10_001,
});
const truncated = rows.length > 10_000;
const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog);
const clientExport = Boolean(clientUserId);
const headers = clientExport
? ['时间', '级别', '模块', '操作人', '动作', '资源ID']
: ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP'];
const values = exportedRows.map((item) => clientExport
? [item.time, item.level, item.module, item.operator, item.action, item.resourceId]
: [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]);
return {
operationId: randomUUID(),
status: 'completed' as const,
fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`,
recordCount: exportedRows.length,
truncated,
content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'),
filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range },
};
}
private async resolveClientTenantId(userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } },
select: { tenantId: true },
});
if (!user?.tenantId) throw new NotFoundException('Client tenant not found');
return user.tenantId;
}
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
@@ -989,7 +1077,10 @@ function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
}
function escapeCsvCell(value: string) {
const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
if (/^[=+\-@]/.test(normalized)) {
normalized = `'${normalized}`;
}
if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) {
return `"${normalized.replace(/"/g, '""')}"`;
}
@@ -1015,6 +1106,122 @@ function formatExportTimestamp(date: Date) {
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
}
function clientApplicationView(application?: Record<string, any> | null) {
if (!application) return null;
return { id: application.id, name: application.name };
}
function clientReceiptView(receipt: Record<string, any>) {
return {
id: receipt.id,
messageId: receipt.messageId,
receiptStatus: receipt.receiptStatus,
rawStatus: receipt.rawStatus,
errorCode: receipt.errorCode ?? null,
errorMessage: receipt.errorMessage ?? null,
deliveredAt: receipt.deliveredAt,
createdAt: receipt.createdAt,
};
}
function clientMessageView(message: Record<string, any>) {
return {
id: message.id,
batchTaskId: message.batchTaskId ?? null,
applicationId: message.applicationId ?? null,
messageId: message.messageId,
phoneNumber: message.phoneNumber,
carrier: message.carrier ?? null,
province: message.province ?? null,
content: message.content,
billingUnits: message.billingUnits,
amountCents: moneyToNumber(message.amountCents),
status: message.status,
submitStatus: message.submitStatus ?? null,
receiptStatus: message.receiptStatus ?? null,
errorCode: message.errorCode ?? null,
errorMessage: message.errorMessage ?? null,
queuedAt: message.queuedAt,
submittedAt: message.submittedAt ?? null,
deliveredAt: message.deliveredAt ?? null,
application: clientApplicationView(message.application),
receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [],
};
}
function clientBatchTaskView(task: Record<string, any>) {
return {
id: task.id,
taskNo: task.taskNo,
applicationId: task.applicationId ?? null,
templateId: task.templateId ?? null,
content: task.content,
category: task.category ?? null,
phoneTotal: task.phoneTotal,
status: task.status,
auditStatus: task.auditStatus ?? null,
reviewReason: task.reviewReason ?? null,
rejectReason: task.rejectReason ?? null,
progressTotal: task.progressTotal,
progressSent: task.progressSent ?? 0,
progressDelivered: task.progressDelivered ?? 0,
progressFailed: task.progressFailed ?? 0,
submittedTotal: task.submittedTotal ?? 0,
successTotal: task.successTotal ?? 0,
failedTotal: task.failedTotal ?? 0,
unknownTotal: task.unknownTotal ?? 0,
timeoutTotal: task.timeoutTotal ?? 0,
scheduledAt: task.scheduledAt ?? null,
canceledAt: task.canceledAt ?? null,
createdAt: task.createdAt,
application: clientApplicationView(task.application),
messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [],
};
}
function clientUplinkView(message: Record<string, any>) {
return {
id: message.id,
applicationId: message.applicationId ?? null,
messageRecordId: message.messageRecordId ?? null,
messageId: message.messageId ?? null,
phoneNumber: message.phoneNumber,
destId: message.destId,
content: message.content,
matchStatus: message.matchStatus,
matchReason: message.matchReason ?? null,
receivedAt: message.receivedAt,
createdAt: message.createdAt,
application: clientApplicationView(message.application),
messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null,
};
}
function clientAccountView(account: Record<string, any>) {
return {
id: account.id,
tenantId: account.tenantId,
balanceCents: moneyToNumber(account.balanceCents),
creditCents: moneyToNumber(account.creditCents),
status: account.status,
updatedAt: account.updatedAt,
tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null,
};
}
function clientRechargeView(order: Record<string, any>) {
return {
id: order.id,
orderNo: order.orderNo,
amountCents: moneyToNumber(order.amountCents),
status: order.status,
payMethod: order.payMethod,
remark: order.remark ?? null,
createdAt: order.createdAt,
completedAt: order.completedAt ?? null,
};
}
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
return groups.reduce(
(summary, group) => {
+13
View File
@@ -0,0 +1,13 @@
import { PrismaService } from './prisma.service';
describe('PrismaService', () => {
it('keeps the operation log delegate configurable for transaction client proxies', async () => {
const prisma = new PrismaService();
expect(Object.getOwnPropertyDescriptor(prisma, 'operationLog')).toEqual(
expect.objectContaining({ configurable: true }),
);
await prisma.$disconnect();
});
});
+1
View File
@@ -29,6 +29,7 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
return typeof value === 'function' ? value.bind(target) : value;
},
}),
configurable: true,
});
}
@@ -67,6 +67,11 @@ export class ReportMaterialsController {
return this.service.listBatches();
}
@Post('batches/preflight')
preflightBatch(@Body() body: CreateReportBatchDto) {
return this.service.preflightBatch(body);
}
@Post('batches')
@RequireRecentAuthentication()
createBatch(@Body() body: CreateReportBatchDto, @CurrentSessionUserId() operatorId?: string) {
@@ -59,17 +59,20 @@ describe('ReportMaterialsService', () => {
let exportSequence = 0;
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-1' }), update: jest.fn().mockResolvedValue({}) },
reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
},
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用' } }),
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', pendingReport: true, materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用', status: 'active' } }),
update: jest.fn().mockResolvedValue({}),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: channels.map((channel, index) => ({ priority: index, channel })) } }]) },
reportMaterialBatchItem: { create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })) } }]) },
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
channelReportField: { findMany: jest.fn().mockResolvedValue([
{ code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
@@ -89,7 +92,7 @@ describe('ReportMaterialsService', () => {
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-1' }] });
const result = await service.createBatch({ idempotencyKey: 'report-batch:test-1', items: [{ reportType: 'signature', signatureId: 'signature-1', materialVersion: 3 }] });
expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2);
@@ -107,11 +110,14 @@ describe('ReportMaterialsService', () => {
it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => {
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-2' }), update: jest.fn().mockResolvedValue({}) },
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) },
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用' } }), update: jest.fn() },
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', pendingReport: true, materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用', status: 'active' } }), update: jest.fn() },
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
reportMaterialBatchItem: { create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) },
@@ -122,10 +128,38 @@ describe('ReportMaterialsService', () => {
const files = { upload: jest.fn().mockResolvedValue({ id: 'file-2', fileName: 'empty.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-2' }] });
await expect(service.createBatch({ idempotencyKey: 'report-batch:test-2', items: [{ reportType: 'signature', signatureId: 'signature-2', materialVersion: 1 }] }))
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) });
expect(result).toMatchObject({ status: 'partial_failed' });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'waiting_material', reason: '通道未配置当前资料类型的报备字段' }) });
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
});
it('reuses a completed operation for the same idempotency key without generating a second batch', async () => {
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue({ id: 'operation-existing', detail: { status: 'completed', fingerprint: expect.anything(), result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }) },
reportMaterialBatch: { create: jest.fn() },
};
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }];
const fingerprint = createFingerprint(items);
prisma.operationLog.findFirst.mockResolvedValueOnce({ id: 'operation-existing', detail: { status: 'completed', fingerprint, result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } });
await expect(service.createBatch({ idempotencyKey: 'report-batch:replay', items })).resolves.toMatchObject({ id: 'batch-existing', replayed: true, operationId: 'operation-existing' });
expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled();
});
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);
await expect(service.preflightBatch({ items: [{ reportType: 'signature', signatureId: '' }] })).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_ITEM_INVALID' }) });
expect(prisma.smsSignature.findUnique).not.toHaveBeenCalled();
});
});
function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) {
const { createHash } = require('node:crypto') as typeof import('node:crypto');
return createHash('sha256').update(JSON.stringify(items.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: null, materialVersion: item.materialVersion })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
}
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -40,9 +40,26 @@ export interface ImportCommitDto {
export interface CreateReportBatchDto {
createdById?: string;
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }>;
idempotencyKey?: string;
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>;
}
type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string };
type ReportBatchInspection = {
id: string;
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string;
materialVersion: number;
name: string;
tenantName: string;
applicationId?: string;
applicationName: string;
eligible: boolean;
blockedReasons: string[];
targets: ReportBatchTarget[];
};
type AnalyzeImportOptions = {
tenantId: string;
applicationId?: string;
@@ -296,13 +313,33 @@ export class ReportMaterialsService {
async createBatch(data: CreateReportBatchDto) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
const claimed = await this.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
if (claimed.replayed) return claimed.result;
let preflight: Awaited<ReturnType<ReportMaterialsService['preflightBatch']>>;
try {
preflight = await this.preflightBatch({ items: uniqueItems });
} catch (error) {
await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败');
throw error;
}
if (preflight.eligibleTargetCount === 0) {
await this.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight });
}
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
const batch = await this.prisma.reportMaterialBatch.create({
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: uniqueItems.length },
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length },
});
try {
const prepared = [];
for (const selected of uniqueItems) prepared.push(await this.prepareBatchItem(batch.id, selected));
for (const inspection of eligibleInspections) {
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!;
prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
}
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
for (const item of prepared) {
for (const channel of item.channels) {
@@ -313,9 +350,11 @@ export class ReportMaterialsService {
}
const exportedFiles = [];
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
let failedTargetCount = 0;
for (const [channelId, items] of channelMap) {
const result = await this.exportChannelBatch(batch.id, channelId, items);
exportedFiles.push(result.file);
failedTargetCount += result.incompleteBatchItemIds.length;
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
}
for (const item of prepared) {
@@ -323,13 +362,46 @@ export class ReportMaterialsService {
if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } });
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } });
}
return this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
const result = {
...completed,
operationId: claimed.operationId,
replayed: false,
result: {
successCount: preflight.eligibleTargetCount - failedTargetCount,
skippedCount: preflight.skippedTargetCount,
failedCount: failedTargetCount,
items: preflight.items,
},
};
await this.completeBatchOperation(claimed.operationId, batch.id, result);
return result;
} catch (error) {
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id);
throw error;
}
}
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
for (const item of data.items) {
if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' });
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' });
}
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
return {
checkedAt: new Date().toISOString(),
eligible: items.some((item) => item.eligible),
eligibleItemCount: items.filter((item) => item.eligible).length,
blockedItemCount: items.filter((item) => !item.eligible).length,
eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0),
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0),
items,
};
}
private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
const name = mappedCoreValue(mappings, values, 'signatureName');
if (!name) throw new Error('缺少短信签名');
@@ -354,7 +426,7 @@ export class ReportMaterialsService {
return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
}
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number]) {
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
@@ -365,15 +437,121 @@ export class ReportMaterialsService {
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
}) : [];
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active').map((channel) => [channel.id, channel])).values()];
const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id));
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()];
const snapshot = selected.reportType === 'signature'
? { reportType: 'signature', signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { reportType: 'drainage', signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion;
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } });
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels };
}
private async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature) throw new NotFoundException('签名不存在');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0;
const blockedReasons: string[] = [];
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion}`);
if (selected.reportType === 'drainage') {
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名');
else {
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
}
}
const snapshot = selected.reportType === 'signature'
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) };
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
}) : [];
const channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>();
for (const route of routes) {
if (route.group.status !== 'active') continue;
for (const entry of route.group.items) {
if (entry.channel.status !== 'active') continue;
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() };
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all');
channelCarriers.set(entry.channel.id, current);
}
}
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道');
const previous = await this.prisma.reportMaterialBatchItem.findMany({
where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } },
orderBy: { createdAt: 'desc' },
});
const priorKeys = new Map<string, string>();
for (const item of previous) {
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value)));
for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) {
if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId);
}
}
const targets: ReportBatchTarget[] = [];
for (const { channel, carriers } of channelCarriers.values()) {
const carrier = [...carriers].sort().join(',');
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
else {
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue));
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const duplicateBatchId = priorKeys.get(businessKey);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId });
}
return {
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
reportType: selected.reportType,
signatureId: signature.id,
drainageItemId: drainageInfo?.id,
materialVersion,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
tenantName: signature.tenant.name,
applicationId: signature.applicationId ?? undefined,
applicationName: signature.application?.name ?? '未指定应用',
eligible: targets.some((target) => target.eligible),
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons,
targets,
};
}
private async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`;
const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } });
if (existing) {
const detail = jsonRecord(existing.detail);
if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' });
if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } };
throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' });
}
const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } });
return { operationId: operation.id, replayed: false as const, result: null };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
private async completeBatchOperation(operationId: string, batchId: string, result: Record<string, unknown>) {
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } });
}
private async failBatchOperation(operationId: string, message: string, batchId?: string) {
const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } });
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } });
}
private async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportMaterialsService['prepareBatchItem']>>>) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('通道不存在');
@@ -602,3 +780,17 @@ function styleHeader(row: ExcelJS.Row) {
function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
function normalizeBatchIdempotencyKey(value?: string) {
const key = value?.trim();
if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' });
return key;
}
function jsonStringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
}
function jsonSafe(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
+305 -12
View File
@@ -84,8 +84,10 @@ function createPrismaMock() {
id: 'tpl-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
content: 'hello',
auditStatus: 'approved',
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
}),
findFirst: jest.fn().mockResolvedValue({
id: 'tpl-1',
@@ -112,6 +114,7 @@ function createPrismaMock() {
findFirst: jest.fn().mockResolvedValue(task),
findMany: jest.fn(),
update: jest.fn().mockResolvedValue(task),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsApiRequest: {
create: jest.fn().mockResolvedValue({ id: 'request-1' }),
@@ -214,6 +217,9 @@ function createPrismaMock() {
messageId: 'MSG-1',
deliveryType: 'receipt',
retryCount: 0,
manualRetryCount: 0,
status: 'failed',
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
lastError: null,
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
application: { cmppAccount: '100001' },
@@ -257,6 +263,7 @@ function createPrismaMock() {
}),
update: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findMany: jest.fn().mockResolvedValue([]),
},
gatewayDownstreamRecoveryStatus: {
findUnique: jest.fn().mockResolvedValue(null),
@@ -364,6 +371,10 @@ describe('SendChainService', () => {
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' },
});
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
await service.createHttpBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'],
@@ -384,16 +395,17 @@ describe('SendChainService', () => {
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved',
signature: { id: 'sig-1', auditStatus: 'approved', reportStatus: 'reporting' },
content: '【签名】详情请访问 https://a.example/landing',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
});
prisma.smsDrainageInfo.findMany.mockResolvedValue([
{ id: 'drain-short', url: 'https://a.example', updatedAt: new Date('2026-07-01') },
{ id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') },
{ id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') },
{ id: 'drain-long', url: 'https://a.example/landing', auditStatus: 'approved', updatedAt: new Date('2026-07-02') },
]);
await service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '详情请访问 https://a.example/landing', phones: ['13800000001'],
content: '【签名】详情请访问 https://a.example/landing', phones: ['13800000001'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
@@ -401,6 +413,81 @@ describe('SendChainService', () => {
});
});
it('rejects a task when the submitted content no longer matches the selected approved template', async () => {
const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
content: '【签名】验证码${code}', auditStatus: 'approved',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '【签名】被篡改的正文', phones: ['13800000001'],
})).rejects.toThrow('短信内容与选定的审核模板不匹配');
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
});
it('rejects free content without an approved leading signature', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true,
customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send',
});
prisma.smsSignature.findFirst.mockResolvedValue(null);
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', content: '没有签名的自由内容', phones: ['13800000001'],
})).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头');
});
it('allows signed free content only when the application explicitly uses direct send', async () => {
const { service, prisma } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true,
customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send',
});
prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【签名】', auditStatus: 'approved' });
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】允许直接发送的自由内容', phones: ['13800000001'],
})).resolves.toBeDefined();
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ signatureId: 'sig-1' })],
});
});
it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => {
const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
content: '【签名】详情 https://blocked.example', auditStatus: 'approved',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
prisma.smsDrainageInfo.findMany.mockResolvedValue([
{ id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') },
]);
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '【签名】详情 https://blocked.example', phones: ['13800000001'],
})).resolves.toBeDefined();
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
})],
});
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
});
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
@@ -437,6 +524,102 @@ describe('SendChainService', () => {
where: { batchTaskId: 'task-1', status: 'scheduled' },
data: { status: 'queued' },
});
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({ id: 'task-1', status: 'scheduled' }),
data: { status: 'scheduled_dispatching' },
});
});
it('atomically claims a due scheduled task so concurrent scanners only freeze and enqueue once', async () => {
const { service, prisma, billing } = createService();
const dueTask = {
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
status: 'scheduled', scheduledAt: new Date(Date.now() - 1_000), updatedAt: new Date(Date.now() - 1_000),
};
prisma.smsBatchTask.findMany.mockResolvedValue([dueTask]);
prisma.smsBatchTask.updateMany
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 0 });
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
const results = await Promise.all([
service.dispatchDueScheduledTasks(new Date()),
service.dispatchDueScheduledTasks(new Date()),
]);
expect(results.map((item) => item.dispatched).sort()).toEqual([0, 1]);
expect(billing.freeze).toHaveBeenCalledTimes(1);
expect(service.enqueueBatchTask).toHaveBeenCalledTimes(1);
});
it('recovers a stale claimed task without freezing its balance twice', async () => {
const { service, prisma, billing } = createService();
const now = new Date();
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
status: 'scheduled_dispatching', scheduledAt: new Date(now.getTime() - 300_000),
updatedAt: new Date(now.getTime() - 300_000),
}]);
prisma.accountTransaction.findFirst.mockResolvedValue({ id: 'frozen-transaction-1' });
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
await expect(service.dispatchDueScheduledTasks(now)).resolves.toEqual({
dispatched: 1,
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
});
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({ id: 'task-1', status: 'scheduled_dispatching', updatedAt: { lt: expect.any(Date) } }),
data: { status: 'scheduled_recovering' },
});
expect(billing.freeze).not.toHaveBeenCalled();
});
it('keeps a zero-fee task recoverable when queue enqueue fails after preparation', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-free', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
}]);
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-free', amountCents: 0, billingUnits: 1 }]);
service.enqueueBatchTask = jest.fn().mockRejectedValue(new Error('Redis unavailable'));
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
dispatched: 0,
results: [{ taskId: 'task-free', status: 'retrying', reason: 'Redis unavailable' }],
});
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
where: { id: 'task-free' },
data: { status: 'scheduled_dispatching', rejectReason: '调度将在超时后恢复:Redis unavailable' },
});
});
it('automatically scans and dispatches due scheduled tasks after application startup', async () => {
jest.useFakeTimers();
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
const { service } = createService();
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(1_000);
expect(dispatch).toHaveBeenCalledTimes(1);
await service.onModuleDestroy();
} finally {
if (previousReceiptEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousReceiptEnabled;
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
jest.useRealTimers();
}
});
it('cancels scheduled tasks before dispatch', async () => {
@@ -1155,7 +1338,7 @@ describe('SendChainService', () => {
}));
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: {
status: { in: ['pending', 'requeued'] },
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }],
},
data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }),
@@ -1734,13 +1917,16 @@ describe('SendChainService', () => {
operatorId: 'user-1',
});
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' }));
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
expect.objectContaining({ submitId: 'SUB-1' }),
'gateway:submit:requeue:dead-1:1',
);
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'pending' },
data: { status: 'requeueing' },
});
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
where: { id: 'dead-1' },
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'requeueing' },
data: expect.objectContaining({
status: 'requeued',
manualRetryCount: { increment: 1 },
@@ -1778,6 +1964,103 @@ describe('SendChainService', () => {
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
const { service, prisma } = createService();
await service.recordGatewaySubmitDeadLetter({
streamMessageId: '1710000000000-0',
failureCode: 'SUBMIT_PROCESSING_FAILED',
failureMessage: 'repeated report',
attempts: 3,
maxAttempts: 3,
});
expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith(expect.objectContaining({
update: expect.not.objectContaining({ status: expect.anything(), resolvedAt: expect.anything(), resolvedStatus: expect.anything() }),
}));
});
it('recovers a stale submit requeue with the same Redis idempotency key', async () => {
const { service, prisma } = createService();
const stale = {
...await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } }),
status: 'requeueing',
updatedAt: new Date('2026-07-21T07:00:00.000Z'),
};
prisma.gatewaySubmitDeadLetter.findMany.mockResolvedValue([stale]);
const publish = jest.spyOn(service as any, 'publishGatewaySubmitCommand').mockResolvedValue('1710000001000-0');
await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1, failed: 0 });
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(1, {
where: { id: 'dead-1', status: 'requeueing', updatedAt: stale.updatedAt },
data: { status: 'requeue_recovering' },
});
expect(publish).toHaveBeenCalledWith(
stale.commandPayload,
'gateway:submit:requeue:dead-1:1',
);
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(2, {
where: { id: 'dead-1', status: 'requeue_recovering' },
data: expect.objectContaining({
status: 'requeued',
manualRetryCount: { increment: 1 },
lastRetryStreamId: '1710000001000-0',
}),
});
});
it('atomically claims a downstream manual requeue so concurrent requests only call Gateway once', async () => {
const { service, prisma } = createService();
service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: true, sequenceId: '11', messageId: '22' });
prisma.cmppDownstreamDelivery.updateMany
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 0 });
const results = await Promise.allSettled([
service.requeueDownstreamDelivery('delivery-1'),
service.requeueDownstreamDelivery('delivery-1'),
]);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
expect(service['postGatewayControl']).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
where: {
id: 'delivery-1',
status: 'failed',
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
},
data: expect.objectContaining({
status: 'manual_requeueing',
manualRetryCount: { increment: 1 },
}),
});
});
it('recovers a stale downstream manual-requeue claim into the Gateway pending path', async () => {
const { service, prisma } = createService();
const updatedAt = new Date('2026-07-21T07:00:00.000Z');
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1', updatedAt }]);
await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1 });
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({
where: { status: 'manual_requeueing', updatedAt: { lt: expect.any(Date) } },
select: { id: true, updatedAt: true },
orderBy: { updatedAt: 'asc' },
take: 500,
});
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
where: { id: 'delivery-1', status: 'manual_requeueing', updatedAt },
data: {
status: 'pending',
nextRetryAt: null,
lastError: '人工重投进程中断,已恢复为待投递',
},
});
});
it('records gateway downstream recovery statuses', async () => {
const { service, prisma } = createService();
@@ -2053,6 +2336,7 @@ describe('SendChainService', () => {
status: 'failed',
retryCount: 3,
manualRetryCount: 1,
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
lastError: 'downstream client is not connected',
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
application: { cmppAccount: '100001' },
@@ -2086,9 +2370,14 @@ describe('SendChainService', () => {
resourceId: 'delivery-1',
}),
});
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
id: 'delivery-1',
status: 'failed',
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
}),
data: expect.objectContaining({
status: 'pending',
status: 'manual_requeueing',
retryCount: 0,
manualRetryCount: { increment: 1 },
lastRetriedAt: expect.any(Date),
@@ -2122,7 +2411,7 @@ describe('SendChainService', () => {
});
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled();
expect(service['postGatewayControl']).not.toHaveBeenCalled();
});
@@ -2231,11 +2520,13 @@ describe('SendChainService', () => {
it('starts the automatic receipt-timeout scan after application startup', async () => {
jest.useFakeTimers();
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const { service } = createService();
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(60_000);
expect(scan).toHaveBeenCalledWith({});
@@ -2244,6 +2535,8 @@ describe('SendChainService', () => {
} finally {
if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
jest.useRealTimers();
}
});
+346 -60
View File
@@ -255,6 +255,12 @@ const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72;
const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000;
const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000;
const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000;
const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000;
const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
priority: 1,
normal: 100,
@@ -270,6 +276,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private receiptTimeoutInitialTimer?: ReturnType<typeof setTimeout>;
private receiptTimeoutIntervalTimer?: ReturnType<typeof setInterval>;
private receiptTimeoutScanRunning = false;
private scheduledDispatchInitialTimer?: ReturnType<typeof setTimeout>;
private scheduledDispatchIntervalTimer?: ReturnType<typeof setInterval>;
private scheduledDispatchScanRunning = false;
constructor(
private readonly prisma: PrismaService,
@@ -291,11 +300,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
);
this.receiptTimeoutIntervalTimer.unref?.();
}
if (process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED !== 'false') {
this.scheduledDispatchInitialTimer = setTimeout(
() => void this.runScheduledDispatchScan(),
SCHEDULED_DISPATCH_INITIAL_DELAY_MS,
);
this.scheduledDispatchInitialTimer.unref?.();
this.scheduledDispatchIntervalTimer = setInterval(
() => void this.runScheduledDispatchScan(),
positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS),
);
this.scheduledDispatchIntervalTimer.unref?.();
}
}
async onModuleDestroy() {
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer);
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
@@ -307,21 +330,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const schedule = parseSchedule(data);
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
this.resolveTemplateMessageClassification(data.templateId, data.content),
this.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
this.resolveUnitPrice(data.tenantId, data.applicationId),
this.resolveQueuePriority(data.tenantId, data.applicationId),
this.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
]);
const risk = await this.riskReview.evaluateTask({
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
content: data.content,
category: data.category,
phones,
variables: data.variables,
createdById: data.createdById,
});
const risk = messageClassification.rejectionReason
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
: await this.riskReview.evaluateTask({
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
content: data.content,
category: data.category,
phones,
variables: messageClassification.variables ?? data.variables,
createdById: data.createdById,
});
const billing = this.billing.estimateSmsCost({
tenantId: data.tenantId,
applicationId: data.applicationId,
@@ -703,33 +728,64 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
async dispatchDueScheduledTasks(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.SMS_SCHEDULED_DISPATCH_STALE_MS,
DEFAULT_SCHEDULED_DISPATCH_STALE_MS,
));
const tasks = await this.prisma.smsBatchTask.findMany({
where: { status: 'scheduled', scheduledAt: { lte: now } },
where: {
OR: [
{ status: 'scheduled', scheduledAt: { lte: now } },
{ status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } },
],
},
orderBy: { scheduledAt: 'asc' },
});
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
for (const task of tasks) {
const candidateStatus = task.status || 'scheduled';
const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching';
const claimed = await this.prisma.smsBatchTask.updateMany({
where: {
id: task.id,
status: candidateStatus,
...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }),
},
data: { status: claimedStatus },
});
if (claimed.count !== 1) continue;
let reservationEstablished = false;
let dispatchPrepared = false;
try {
await this.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
const messages = await this.prisma.smsMessageRecord.findMany({
where: { batchTaskId: task.id, status: 'scheduled' },
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
select: { id: true, amountCents: true, billingUnits: true },
take: 100000,
});
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
if (!accountCheck.canSend) {
throw new BadRequestException('定时任务到点时企业账户余额不足');
}
if (amountCents > 0) {
await this.billing.freeze({
tenantId: task.tenantId,
amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '定时任务到点冻结',
});
const existingReservation = await this.prisma.accountTransaction.findFirst({
where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id },
select: { id: true },
});
reservationEstablished = Boolean(existingReservation);
if (!reservationEstablished) {
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
if (!accountCheck.canSend) {
throw new BadRequestException('定时任务到点时企业账户余额不足');
}
if (amountCents > 0) {
await this.billing.freeze({
tenantId: task.tenantId,
amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '定时任务到点冻结',
});
reservationEstablished = true;
}
}
dispatchPrepared = true;
await this.prisma.smsMessageRecord.updateMany({
where: { batchTaskId: task.id, status: 'scheduled' },
data: { status: 'queued' },
@@ -738,6 +794,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
} catch (error) {
const reason = error instanceof Error ? error.message : '定时任务到点执行失败';
if (reservationEstablished || dispatchPrepared) {
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` },
});
results.push({ taskId: task.id, status: 'retrying', reason });
continue;
}
await this.prisma.smsMessageRecord.updateMany({
where: { batchTaskId: task.id, status: 'scheduled' },
data: { status: 'rejected', errorMessage: reason },
@@ -752,6 +816,18 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return { dispatched: results.filter((result) => result.status === 'queued').length, results };
}
private async runScheduledDispatchScan() {
if (this.scheduledDispatchScanRunning) return;
this.scheduledDispatchScanRunning = true;
try {
await this.dispatchDueScheduledTasks();
} catch (error) {
this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
this.scheduledDispatchScanRunning = false;
}
}
startWorker() {
if (this.worker) {
return { status: 'already_started' };
@@ -852,7 +928,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: {
status: { in: ['pending', 'requeued'] },
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
OR: [
data.submitId ? { submitId: data.submitId } : undefined,
data.messageId ? { messageId: data.messageId } : undefined,
@@ -1189,15 +1265,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
traceId: data.traceId,
messageId: data.messageId,
submitId: data.submitId,
status: 'pending',
failureCode: data.failureCode,
failureMessage: data.failureMessage,
attempts: data.attempts,
maxAttempts: data.maxAttempts,
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
rawPayload: data.rawPayload,
resolvedAt: null,
resolvedStatus: null,
},
create: {
streamMessageId: data.streamMessageId,
@@ -1369,19 +1442,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (claimed.count !== 1) {
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
}
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
let retryStreamMessageId: string;
try {
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
if (!publishedStreamMessageId) {
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
}
retryStreamMessageId = publishedStreamMessageId;
} catch (error) {
await this.prisma.gatewaySubmitDeadLetter.update({ where: { id }, data: { status: 'pending' } });
await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id, status: 'requeueing' },
data: { status: 'pending' },
});
throw error;
}
const updated = await this.prisma.gatewaySubmitDeadLetter.update({
where: { id },
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id, status: 'requeueing' },
data: {
status: 'requeued',
manualRetryCount: { increment: 1 },
@@ -1389,6 +1466,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
lastRetriedAt: new Date(),
},
});
const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
if (!updated) {
throw new NotFoundException('Gateway提交异常记录不存在');
}
if (finalized.count !== 1 && updated.status !== 'resolved') {
throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果');
}
await this.prisma.operationLog.create({
data: {
tenantId: updated.tenantId ?? undefined,
@@ -1409,6 +1493,69 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return updated;
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS,
));
const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({
where: { status: 'requeueing', updatedAt: { lt: staleCutoff } },
orderBy: { updatedAt: 'asc' },
take: 100,
});
let recovered = 0;
let failed = 0;
for (const deadLetter of stale) {
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
data: { status: 'pending' },
});
failed += 1;
continue;
}
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
data: { status: 'requeue_recovering' },
});
if (claimed.count !== 1) continue;
try {
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
const retryStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号');
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id: deadLetter.id, status: 'requeue_recovering' },
data: {
status: 'requeued',
manualRetryCount: { increment: 1 },
lastRetryStreamId: retryStreamMessageId,
lastRetriedAt: new Date(),
},
});
if (finalized.count === 1) {
recovered += 1;
await this.prisma.operationLog.create({
data: {
tenantId: deadLetter.tenantId ?? undefined,
action: 'gateway.submit_dead_letter_requeue_recovered',
resource: 'gateway_submit_dead_letter',
resourceId: deadLetter.id,
detail: { retryStreamMessageId, requeueKey },
},
});
}
} catch (error) {
failed += 1;
await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id: deadLetter.id, status: 'requeue_recovering' },
data: { status: 'requeueing' },
});
this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return { recovered, failed };
}
async requeueDownstreamDelivery(id: string) {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { id },
@@ -1440,10 +1587,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
...payload,
};
const retriedAt = new Date();
const requeued = await this.prisma.cmppDownstreamDelivery.update({
where: { id: delivery.id },
const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({
where: {
id: delivery.id,
status: delivery.status,
updatedAt: delivery.updatedAt,
},
data: {
status: 'pending',
status: 'manual_requeueing',
retryCount: 0,
manualRetryCount: { increment: 1 },
lastRetriedAt: retriedAt,
@@ -1459,6 +1610,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
lastError: null,
},
});
if (claimed.count !== 1) {
throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试');
}
await this.prisma.operationLog.create({
data: {
tenantId: delivery.tenantId,
@@ -1471,7 +1625,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
messageId: delivery.messageId,
previousStatus: delivery.status,
previousRetryCount: delivery.retryCount,
manualRetryCount: requeued.manualRetryCount,
manualRetryCount: (delivery.manualRetryCount ?? 0) + 1,
lastRetriedAt: retriedAt,
},
},
@@ -1494,6 +1648,32 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
}
async recoverStaleDownstreamManualRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
));
const stale = await this.prisma.cmppDownstreamDelivery.findMany({
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
select: { id: true, updatedAt: true },
orderBy: { updatedAt: 'asc' },
take: 500,
});
let recovered = 0;
for (const delivery of stale) {
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt },
data: {
status: 'pending',
nextRetryAt: null,
lastError: '人工重投进程中断,已恢复为待投递',
},
});
recovered += updated.count;
}
return { recovered };
}
async batchRequeueDownstreamDeliveries(ids: string[]) {
const uniqueIds = [...new Set(ids.filter(Boolean))];
if (uniqueIds.length === 0) {
@@ -1949,7 +2129,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.recordCmppFailureReceipt(message, code, reason);
};
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
const drainageInfoId = await this.resolveDrainageInfoId(options.signatureId, data.content);
const drainage = await this.resolveDrainageInfoMatch(options.signatureId, data.content);
const drainageInfoId = drainage?.id;
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId, signatureId: options.signatureId },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return;
}
const risk = await this.riskReview.evaluateTask({
tenantId: application.tenantId,
applicationId: application.id,
@@ -2011,6 +2201,24 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!signature) {
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
} else {
const drainage = await this.resolveDrainageInfoMatch(signature.id, data.content);
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId: drainage?.id, signatureId: signature.id },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
messageId,
messageRecordId: message.id,
taskId: task.id,
status: 'rejected',
};
}
const risk = await this.riskReview.evaluateTask({
tenantId: application.tenantId,
applicationId: application.id,
@@ -2037,7 +2245,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
const reviewTask = risk.status === 'pending_review' && risk.task
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, await this.resolveDrainageInfoId(signature.id, data.content))
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id)
: await this.riskReview.aggregateTemplateMismatch({
tenantId: application.tenantId,
applicationId: application.id,
@@ -2143,12 +2351,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (this.receiptTimeoutScanRunning) return;
this.receiptTimeoutScanRunning = true;
try {
const [receiptResult, downstreamResult] = await Promise.all([
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
this.markUnknownTimeout({}),
this.markExpiredDownstreamDeliveries(),
this.recoverStaleGatewaySubmitRequeues(),
this.recoverStaleDownstreamManualRequeues(),
]);
if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
} catch (error) {
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
} finally {
@@ -2510,21 +2722,67 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
private async resolveTemplateMessageClassification(templateId: string | undefined, content: string) {
if (!templateId) return { signatureId: undefined, drainageInfoId: undefined };
const template = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
select: { signatureId: true },
});
const signatureId = template?.signatureId ?? undefined;
return { signatureId, drainageInfoId: await this.resolveDrainageInfoId(signatureId, content) };
private async resolveTemplateMessageClassification(
tenantId: string,
applicationId: string | undefined,
templateId: string | undefined,
content: string,
) {
if (templateId) {
const template = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
include: { signature: true },
});
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId
|| template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
const variables = matchTemplateContent(template.content, content);
if (variables === null) {
throw new BadRequestException('短信内容与选定的审核模板不匹配');
}
const drainage = await this.resolveDrainageInfoMatch(template.signatureId, content);
return {
signatureId: template.signatureId,
drainageInfoId: drainage?.id,
variables,
rejectionReason: drainageRejectionReason(drainage),
};
}
if (!applicationId) {
throw new BadRequestException('自由内容短信必须关联企业应用');
}
const [application, signature] = await Promise.all([
this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true, templateMismatchMode: true },
}),
this.resolveInboundSignatureCandidate(applicationId, content),
]);
if (!application || application.tenantId !== tenantId) {
throw new BadRequestException('短信应用不存在或不属于当前企业');
}
if (!signature) {
throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头');
}
if (application.templateMismatchMode !== 'direct_send') {
throw new BadRequestException('当前应用未允许无模板自由内容直接发送');
}
const drainage = await this.resolveDrainageInfoMatch(signature.id, content);
return {
signatureId: signature.id,
drainageInfoId: drainage?.id,
variables: undefined,
rejectionReason: drainageRejectionReason(drainage),
};
}
private async resolveDrainageInfoId(signatureId: string | undefined, content: string) {
private async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
if (!signatureId) return undefined;
const candidates = await this.prisma.smsDrainageInfo.findMany({
where: { signatureId, auditStatus: 'approved' },
select: { id: true, url: true, updatedAt: true },
where: { signatureId, auditStatus: { not: 'deleted' } },
select: { id: true, url: true, auditStatus: true, updatedAt: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
});
const matches = candidates
@@ -2534,7 +2792,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (matches.length === 0) return undefined;
const longestLength = matches[0].normalizedUrl.length;
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
return longestMatches.length === 1 ? longestMatches[0].id : undefined;
if (longestMatches.length !== 1) {
throw new BadRequestException({
code: 'DRAINAGE_MATCH_AMBIGUOUS',
message: '短信内容同时匹配多条等长引流地址,无法确定报备资料',
drainageInfoIds: longestMatches.map((item) => item.id),
});
}
const matched = longestMatches[0];
return { id: matched.id, auditStatus: matched.auditStatus };
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
@@ -3143,19 +3409,39 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return response.json().catch(() => ({}));
}
private async publishGatewaySubmitCommand(command: unknown) {
return this.getRedis().xadd(
process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM,
'*',
'messageType',
'SubmitCommand',
'data',
JSON.stringify(command),
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
const redis = this.getRedis();
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
const payload = JSON.stringify(command);
if (!idempotencyKey) {
return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload);
}
const result = await redis.eval(
`local existing = redis.call('GET', KEYS[2])
if existing then return existing end
local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1])
redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2])
return streamId`,
2,
stream,
idempotencyKey,
payload,
String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS),
);
return typeof result === 'string' ? result : String(result ?? '');
}
}
function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
return `gateway:submit:requeue:${deadLetterId}:${attempt}`;
}
function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) {
if (!drainage || drainage.auditStatus === 'approved') return undefined;
return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`;
}
function statusFromRisk(status: string, scheduled: boolean) {
if (status === 'rejected') {
return 'rejected';
@@ -1,12 +1,15 @@
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ReviewDecisionDto, ReviewGovernanceService } from './review-governance.service';
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
@ApiTags('admin-sms-config')
@Controller('admin')
export class AdminSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService) {}
constructor(private readonly smsConfig: SmsConfigService, private readonly reviewGovernance: ReviewGovernanceService, private readonly deletions: DeletionGovernanceService) {}
@Get('enterprise-applications')
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) {
@@ -126,8 +129,8 @@ export class AdminSmsConfigController {
@Post('signatures/:id/approve')
@RequireRecentAuthentication()
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
return this.smsConfig.approveSignature(signatureId, body);
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) {
return this.reviewGovernance.decide('signature', signatureId, { ...body, decision: 'approve', reviewerId });
}
@Post('signatures/:id/reject')
@@ -138,8 +141,8 @@ export class AdminSmsConfigController {
@Post('templates/:id/approve')
@RequireRecentAuthentication()
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
return this.smsConfig.approveTemplate(templateId, body);
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) {
return this.reviewGovernance.decide('template', templateId, { ...body, decision: 'approve', reviewerId });
}
@Post('templates/:id/reject')
@@ -156,13 +159,15 @@ export class AdminSmsConfigController {
@Post('enterprise-signatures/:id/status')
@RequireRecentAuthentication()
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) {
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId });
return this.smsConfig.changeSignatureStatus(signatureId, body);
}
@Post('enterprise-templates/:id/status')
@RequireRecentAuthentication()
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) {
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId });
return this.smsConfig.changeTemplateStatus(templateId, body);
}
}
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import {
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
@@ -18,7 +20,7 @@ import {
@ApiTags('client-sms-config')
@Controller('client')
export class ClientSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService) {}
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
@Get('applications')
listApplications(@TenantId() tenantId?: string) {
@@ -115,7 +117,8 @@ export class ClientSmsConfigController {
}
@Post('signatures/:id/status')
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@@ -132,7 +135,7 @@ export class ClientSmsConfigController {
@Put('templates/:id')
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) {
return this.smsConfig.updateTemplate(templateId, body, tenantId);
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
}
@Post('templates/:id/submit')
@@ -141,7 +144,8 @@ export class ClientSmsConfigController {
}
@Post('templates/:id/status')
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
return this.smsConfig.changeTemplateStatus(templateId, body, tenantId);
}
}
@@ -0,0 +1,27 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { ReviewDecisionDto, ReviewGovernanceService, ReviewTargetType } from './review-governance.service';
@ApiTags('review-governance')
@Controller('admin/reviews')
export class ReviewGovernanceController {
constructor(private readonly reviews: ReviewGovernanceService) {}
@Get(':type/:id/preflight')
preflight(@Param('type') type: ReviewTargetType, @Param('id') id: string) {
return this.reviews.preflight(type, id);
}
@Post(':type/:id/decision')
@RequireRecentAuthentication()
decide(
@Param('type') type: ReviewTargetType,
@Param('id') id: string,
@Body() body: ReviewDecisionDto,
@CurrentSessionUserId() reviewerId?: string,
) {
return this.reviews.decide(type, id, { ...body, reviewerId });
}
}
@@ -0,0 +1,85 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { ReviewGovernanceService } from './review-governance.service';
function signature(overrides: Record<string, unknown> = {}) {
return {
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: null,
drainageInfo: { signatureProfile: { companyName: '企业A', creditCode: '9133', legalPersonName: '法人', responsibleName: '责任人', responsiblePhone: '13800000000', credentialFile: { fileObjectId: 'file-1' } } },
auditStatus: 'pending', reportStatus: 'waiting_material', rejectReason: null, materialVersion: 1,
pendingReport: true, reportChangedAt: new Date(), createdAt: new Date(), updatedAt: new Date('2026-07-21T08:00:00.000Z'),
tenant: { id: 'tenant-1', name: '企业A' }, application: { id: 'app-1', name: '应用A' }, materials: [],
...overrides,
};
}
function prismaMock() {
const current = signature();
const prisma: Record<string, any> = {
smsSignature: {
findUnique: jest.fn().mockResolvedValue(current),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsTemplate: { findUnique: jest.fn(), updateMany: jest.fn() },
auditRecord: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'audit-1', statusAfter: 'approved' }),
},
};
prisma.$transaction = jest.fn(async (callback: (tx: typeof prisma) => unknown) => callback(prisma));
return prisma;
}
describe('ReviewGovernanceService', () => {
it('blocks approval when required signature qualification is incomplete', async () => {
const prisma = prismaMock();
prisma.smsSignature.findUnique.mockResolvedValue(signature({ applicationId: null, application: null, drainageInfo: {}, materials: [] }));
const service = new ReviewGovernanceService(prisma as never);
const result = await service.preflight('signature', 'sig-1');
expect(result.allowedActions).toEqual(['reject']);
expect(result.blockedReasons).toEqual(expect.arrayContaining(['未绑定短信应用', '缺少公司名称', '缺少资质文件']));
});
it('atomically approves the expected version and returns an audit operation id', async () => {
const prisma = prismaMock();
const service = new ReviewGovernanceService(prisma as never);
await expect(service.decide('signature', 'sig-1', {
decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:key-1', reviewerId: 'admin-1',
})).resolves.toEqual(expect.objectContaining({ operationId: 'audit-1', replayed: false, status: 'approved' }));
expect(prisma.smsSignature.updateMany).toHaveBeenCalledWith({
where: { id: 'sig-1', auditStatus: 'pending', updatedAt: new Date('2026-07-21T08:00:00.000Z') },
data: { auditStatus: 'approved', rejectReason: null },
});
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reviewerId: 'admin-1', action: 'approve', statusBefore: 'pending', statusAfter: 'approved' }) });
});
it('rejects a concurrent stale decision without overwriting the winner', async () => {
const prisma = prismaMock();
prisma.smsSignature.updateMany.mockResolvedValue({ count: 0 });
const service = new ReviewGovernanceService(prisma as never);
await expect(service.decide('signature', 'sig-1', {
decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:key-2', reviewerId: 'admin-1',
})).rejects.toBeInstanceOf(ConflictException);
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
});
it('replays a completed idempotency key without a second status update', async () => {
const prisma = prismaMock();
prisma.auditRecord.findFirst.mockResolvedValue({ id: 'audit-existing', action: 'approve', statusAfter: 'approved' });
const service = new ReviewGovernanceService(prisma as never);
await expect(service.decide('signature', 'sig-1', {
decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:same', reviewerId: 'admin-1',
})).resolves.toEqual(expect.objectContaining({ operationId: 'audit-existing', replayed: true }));
expect(prisma.smsSignature.updateMany).not.toHaveBeenCalled();
});
it('requires a valid server session reviewer and rejects malformed idempotency keys', async () => {
const service = new ReviewGovernanceService(prismaMock() as never);
await expect(service.decide('signature', 'sig-1', { decision: 'approve', expectedUpdatedAt: new Date().toISOString(), idempotencyKey: '../bad' }))
.rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,158 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, type SmsSignature, type SmsTemplate } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type ReviewTargetType = 'signature' | 'template';
export type ReviewDecision = 'approve' | 'reject';
export interface ReviewDecisionDto {
decision: ReviewDecision;
expectedUpdatedAt: string;
idempotencyKey: string;
reason?: string;
reviewerId?: string;
}
@Injectable()
export class ReviewGovernanceService {
constructor(private readonly prisma: PrismaService) {}
async preflight(type: ReviewTargetType, id: string) {
if (type === 'signature') return this.signaturePreflight(id);
if (type === 'template') return this.templatePreflight(id);
throw new BadRequestException('Unsupported review target');
}
async decide(type: ReviewTargetType, id: string, data: ReviewDecisionDto) {
const key = normalizeIdempotencyKey(data.idempotencyKey);
const reason = data.reason?.trim();
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
if (data.decision === 'reject' && !reason) throw new BadRequestException('驳回时必须填写原因');
const marker = `[idempotency:${key}]`;
const targetType = type === 'signature' ? 'sms_signature' : 'sms_template';
const replay = await this.prisma.auditRecord.findFirst({
where: { targetType, targetId: id, reason: { startsWith: marker } },
orderBy: { createdAt: 'desc' },
});
if (replay) {
if (replay.action !== data.decision) {
throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同审核决定' });
}
return {
operationId: replay.id,
replayed: true,
decision: replay.action as ReviewDecision,
status: replay.statusAfter,
item: await this.readTarget(type, id),
};
}
const preflight = await this.preflight(type, id);
if (!preflight.allowedActions.includes(data.decision)) {
throw new BadRequestException({ code: 'REVIEW_NOT_ELIGIBLE', message: preflight.blockedReasons.join('') || '当前对象不可执行该审核动作', preflight });
}
const expectedUpdatedAt = new Date(data.expectedUpdatedAt);
if (Number.isNaN(expectedUpdatedAt.getTime())) throw new BadRequestException('Invalid expectedUpdatedAt');
const statusAfter = data.decision === 'approve' ? 'approved' : 'rejected';
const auditReason = `${marker}${reason ? ` ${reason}` : ' 审核资料及影响摘要已确认'}`;
return this.prisma.$transaction(async (tx) => {
const model = type === 'signature' ? tx.smsSignature : tx.smsTemplate;
const changed = await (model.updateMany as unknown as (args: unknown) => Promise<{ count: number }>)({
where: { id, auditStatus: 'pending', updatedAt: expectedUpdatedAt },
data: { auditStatus: statusAfter, rejectReason: data.decision === 'reject' ? reason : null },
});
if (changed.count !== 1) {
throw new ConflictException({ code: 'REVIEW_VERSION_CONFLICT', message: '审核对象已被其他操作更新,请刷新后重试' });
}
const audit = await tx.auditRecord.create({
data: {
tenantId: preflight.tenantId,
targetType,
targetId: id,
action: data.decision,
statusBefore: preflight.status,
statusAfter,
reason: auditReason,
reviewerId: data.reviewerId,
},
});
const item = type === 'signature'
? await tx.smsSignature.findUnique({ where: { id }, include: { tenant: true, application: true, materials: true } })
: await tx.smsTemplate.findUnique({ where: { id }, include: { tenant: true, application: true, signature: true } });
return { operationId: audit.id, replayed: false, decision: data.decision, status: statusAfter, item };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
private async signaturePreflight(id: string) {
const item = await this.prisma.smsSignature.findUnique({
where: { id }, include: { tenant: true, application: true, materials: true },
});
if (!item) throw new NotFoundException('Signature not found');
const payload = asRecord(item.drainageInfo);
const profile = asRecord(payload.signatureProfile);
const missing: string[] = [];
if (!item.applicationId) missing.push('未绑定短信应用');
if (!String(profile.companyName ?? '').trim()) missing.push('缺少公司名称');
if (!String(profile.creditCode ?? '').trim()) missing.push('缺少统一社会信用代码');
if (!String(profile.legalPersonName ?? '').trim()) missing.push('缺少法人姓名');
if (!String(profile.responsibleName ?? '').trim()) missing.push('缺少责任人姓名');
if (!String(profile.responsiblePhone ?? '').trim()) missing.push('缺少责任人手机号');
const profileHasFile = Object.values(profile).some((value) => Boolean(asRecord(value).fileObjectId));
if (!profileHasFile && item.materials.length === 0) missing.push('缺少资质文件');
return reviewPreflight('signature', item, {
identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定应用' },
blockedReasons: missing,
impacts: ['通过后签名将进入报备资格链路', '已绑定模板和后续发送资格可能受此决定影响'],
materialSummary: { qualificationFiles: item.materials.length + (profileHasFile ? 1 : 0), missingCount: missing.length },
});
}
private async templatePreflight(id: string) {
const item = await this.prisma.smsTemplate.findUnique({
where: { id }, include: { tenant: true, application: true, signature: true },
});
if (!item) throw new NotFoundException('Template not found');
const missing: string[] = [];
if (!item.content.trim()) missing.push('模板内容为空');
if (!item.signatureId) missing.push('未绑定短信签名');
else if (item.signature?.auditStatus !== 'approved') missing.push('绑定签名尚未审核通过');
return reviewPreflight('template', item, {
identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name },
blockedReasons: missing,
impacts: ['通过后模板将进入客户端可发送资源候选', '实际发送仍需通过应用、签名、路由和余额校验'],
materialSummary: { contentLength: item.content.length, signature: item.signature?.name ?? '未绑定' },
});
}
private readTarget(type: ReviewTargetType, id: string): Promise<SmsSignature | SmsTemplate | null> {
return type === 'signature' ? this.prisma.smsSignature.findUnique({ where: { id } }) : this.prisma.smsTemplate.findUnique({ where: { id } });
}
}
function reviewPreflight(type: ReviewTargetType, item: SmsSignature | SmsTemplate, detail: { identity: Record<string, string>; blockedReasons: string[]; impacts: string[]; materialSummary: Record<string, string | number> }) {
const statusBlocked = item.auditStatus !== 'pending' ? [`当前状态为${item.auditStatus},仅待审核对象可决策`] : [];
const blockedReasons = [...statusBlocked, ...detail.blockedReasons];
return {
type,
id: item.id,
tenantId: item.tenantId,
status: item.auditStatus,
expectedUpdatedAt: item.updatedAt.toISOString(),
identity: detail.identity,
impacts: detail.impacts,
materialSummary: detail.materialSummary,
blockedReasons,
allowedActions: item.auditStatus === 'pending' ? (detail.blockedReasons.length ? ['reject'] : ['approve', 'reject']) : [],
};
}
function normalizeIdempotencyKey(value: string) {
const key = value?.trim();
if (!key || key.length > 100 || !/^[a-zA-Z0-9:_-]+$/.test(key)) throw new BadRequestException('Invalid idempotencyKey');
return key;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
+6 -2
View File
@@ -2,10 +2,14 @@ import { Module } from '@nestjs/common';
import { AdminSmsConfigController } from './admin-sms-config.controller';
import { ClientSmsConfigController } from './client-sms-config.controller';
import { SmsConfigService } from './sms-config.service';
import { ReviewGovernanceController } from './review-governance.controller';
import { ReviewGovernanceService } from './review-governance.service';
import { DeletionGovernanceModule } from '../deletion-governance/deletion-governance.module';
@Module({
controllers: [ClientSmsConfigController, AdminSmsConfigController],
providers: [SmsConfigService],
imports: [DeletionGovernanceModule],
controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController],
providers: [SmsConfigService, ReviewGovernanceService],
exports: [SmsConfigService],
})
export class SmsConfigModule {}
+66 -1
View File
@@ -543,7 +543,16 @@ describe('SmsConfigService', () => {
}),
});
expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({
where: { status: 'connected', lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } },
where: {
status: 'connected',
OR: [
{ lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } },
{
lastHeartbeatAt: null,
connectedAt: { lt: new Date('2026-07-11T10:58:30.000Z') },
},
],
},
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -554,6 +563,27 @@ describe('SmsConfigService', () => {
});
});
it('prunes expired downstream connections whose heartbeat was never recorded', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
const now = new Date('2026-07-21T04:00:00.000Z');
await service.markTimedOutDownstreamConnections(now);
expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({
where: {
status: 'connected',
OR: [
{ lastHeartbeatAt: { lt: new Date('2026-07-21T03:58:30.000Z') } },
{
lastHeartbeatAt: null,
connectedAt: { lt: new Date('2026-07-21T03:58:30.000Z') },
},
],
},
});
});
it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
@@ -866,6 +896,41 @@ describe('SmsConfigService', () => {
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('resets an approved signature to pending when key content is changed', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】',
purpose: '通知', drainageInfo: {}, auditStatus: 'approved',
});
const service = new SmsConfigService(prisma as never);
await service.updateSignature('sig-1', { name: '【新签名】' });
expect(prisma.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ auditStatus: 'pending', rejectReason: null }),
}));
});
it('resets an approved template to pending when key content is changed', async () => {
const prisma = createPrismaMock();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
name: '模板A', content: '【签名A】验证码${code}', category: '验证码', auditStatus: 'approved',
});
const tx = {
templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
smsTemplate: { update: jest.fn().mockResolvedValue({ id: 'tpl-1', auditStatus: 'pending' }) },
};
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await service.updateTemplate('tpl-1', { content: '【签名A】您的验证码为${code}' });
expect(tx.smsTemplate.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ auditStatus: 'pending' }),
}));
});
it('creates real drainage materials and channel tasks after operations approval', async () => {
const prisma = createPrismaMock();
const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' };
+40 -4
View File
@@ -729,7 +729,13 @@ export class SmsConfigService {
const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
const cutoff = new Date(now.getTime() - timeoutMs);
return this.prisma.cmppDownstreamConnection.deleteMany({
where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } },
where: {
status: 'connected',
OR: [
{ lastHeartbeatAt: { lt: cutoff } },
{ lastHeartbeatAt: null, connectedAt: { lt: cutoff } },
],
},
});
}
@@ -1005,14 +1011,19 @@ export class SmsConfigService {
const drainageInfo = data.drainageInfo
? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo)
: undefined;
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId)
|| (data.name !== undefined && normalizeSmsSignature(data.name) !== normalizeSmsSignature(signature.name))
|| (data.purpose !== undefined && data.purpose !== signature.purpose)
|| (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null));
const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus;
const updated = await this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
applicationId: data.applicationId,
name: data.name,
purpose: data.purpose,
auditStatus: data.auditStatus,
rejectReason: data.auditStatus === 'pending' ? null : undefined,
auditStatus,
rejectReason: auditStatus === 'pending' ? null : undefined,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: { increment: 1 },
pendingReport: true,
@@ -1376,6 +1387,12 @@ export class SmsConfigService {
const variables = data.content !== undefined || data.variables !== undefined
? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables)
: undefined;
const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId)
|| (data.signatureId !== undefined && data.signatureId !== template.signatureId)
|| (data.content !== undefined && data.content !== template.content)
|| (data.category !== undefined && data.category !== template.category)
|| data.variables !== undefined;
const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus;
return this.prisma.$transaction(async (tx) => {
if (variables) {
await tx.templateVariable.deleteMany({ where: { templateId } });
@@ -1388,7 +1405,8 @@ export class SmsConfigService {
name: data.name,
content: data.content,
category: data.category,
auditStatus: data.auditStatus,
auditStatus,
rejectReason: auditStatus === 'pending' ? null : undefined,
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
variables: variables ? {
create: variables.map((variable) => ({
@@ -1403,6 +1421,24 @@ export class SmsConfigService {
});
}
async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found');
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
throw new BadRequestException('当前审核状态不允许修改模板');
}
const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId);
await this.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_template',
targetId: templateId,
action: 'client_update_submit',
statusBefore: current.auditStatus,
statusAfter: 'pending',
});
return updated;
}
async submitTemplate(templateId: string, tenantId?: string) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template || (tenantId && template.tenantId !== tenantId)) {