feat: harden platform workflows and UI governance
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> : {};
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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> : {};
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user