feat: harden platform workflows and UI governance
This commit is contained in:
Generated
+643
-175
File diff suppressed because it is too large
Load Diff
+3
-9
@@ -19,8 +19,8 @@
|
|||||||
"@nestjs/core": "^11.1.28",
|
"@nestjs/core": "^11.1.28",
|
||||||
"@nestjs/platform-express": "^11.1.28",
|
"@nestjs/platform-express": "^11.1.28",
|
||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "^11.2.3",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.9.0",
|
||||||
"@prisma/client": "^7.0.1",
|
"@prisma/client": "^7.9.0",
|
||||||
"bullmq": "^5.79.2",
|
"bullmq": "^5.79.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
@@ -35,20 +35,14 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
"prisma": "^7.0.1",
|
"prisma": "^7.9.0",
|
||||||
"ts-jest": "^29.4.11",
|
"ts-jest": "^29.4.11",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
|
||||||
"@hono/node-server": "1.19.13"
|
|
||||||
},
|
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"exceljs": {
|
"exceljs": {
|
||||||
"uuid": "11.1.1"
|
"uuid": "11.1.1"
|
||||||
},
|
|
||||||
"@prisma/dev": {
|
|
||||||
"@hono/node-server": "1.19.13"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
-- Historical receipts created before channel-aware matching could be attached to
|
||||||
|
-- the right message but retain the wrong channel when two channels shared the
|
||||||
|
-- same supplier account. Only repair rows that have exactly one submit record
|
||||||
|
-- for the same message, upstream message id and destination phone number.
|
||||||
|
WITH unique_receipt_submit AS (
|
||||||
|
SELECT
|
||||||
|
receipt.id AS receipt_id,
|
||||||
|
MIN(submit."channelId") AS correct_channel_id
|
||||||
|
FROM "SmsReceiptRecord" receipt
|
||||||
|
JOIN "SmsMessageRecord" message
|
||||||
|
ON message.id = receipt."messageRecordId"
|
||||||
|
JOIN "SmsSubmitRecord" submit
|
||||||
|
ON submit."messageRecordId" = message.id
|
||||||
|
AND submit."gatewayMessageId" = receipt."gatewayMessageId"
|
||||||
|
WHERE receipt."receiptStatus" = 'delivered'
|
||||||
|
AND receipt."phoneNumber" = message."phoneNumber"
|
||||||
|
GROUP BY receipt.id
|
||||||
|
HAVING COUNT(submit.id) = 1
|
||||||
|
)
|
||||||
|
UPDATE "SmsReceiptRecord" receipt
|
||||||
|
SET "channelId" = matched.correct_channel_id
|
||||||
|
FROM unique_receipt_submit matched
|
||||||
|
WHERE receipt.id = matched.receipt_id
|
||||||
|
AND receipt."channelId" IS DISTINCT FROM matched.correct_channel_id;
|
||||||
|
|
||||||
|
-- Aggregate the corrected successful receipt onto the message master record.
|
||||||
|
-- DISTINCT ON chooses the first successful receipt for segmented messages while
|
||||||
|
-- preserving the original submit/gateway identity of the current attempt.
|
||||||
|
WITH delivered_match AS (
|
||||||
|
SELECT DISTINCT ON (message.id)
|
||||||
|
message.id AS message_record_id,
|
||||||
|
submit."channelId" AS channel_id,
|
||||||
|
receipt."gatewayMessageId" AS gateway_message_id,
|
||||||
|
receipt."rawStatus" AS raw_status,
|
||||||
|
receipt."errorCode" AS error_code,
|
||||||
|
receipt."errorMessage" AS error_message,
|
||||||
|
receipt."deliveredAt" AS delivered_at
|
||||||
|
FROM "SmsMessageRecord" message
|
||||||
|
JOIN "SmsReceiptRecord" receipt
|
||||||
|
ON receipt."messageRecordId" = message.id
|
||||||
|
AND receipt."receiptStatus" = 'delivered'
|
||||||
|
AND receipt."phoneNumber" = message."phoneNumber"
|
||||||
|
JOIN "SmsSubmitRecord" submit
|
||||||
|
ON submit."messageRecordId" = message.id
|
||||||
|
AND submit."gatewayMessageId" = receipt."gatewayMessageId"
|
||||||
|
AND submit."channelId" = receipt."channelId"
|
||||||
|
WHERE message."gatewayMessageId" = receipt."gatewayMessageId"
|
||||||
|
ORDER BY message.id, receipt."deliveredAt" ASC, receipt.id ASC
|
||||||
|
)
|
||||||
|
UPDATE "SmsMessageRecord" message
|
||||||
|
SET
|
||||||
|
status = 'delivered',
|
||||||
|
"receiptStatus" = 'delivered',
|
||||||
|
"receiptRawStatus" = matched.raw_status,
|
||||||
|
"channelId" = matched.channel_id,
|
||||||
|
"gatewayMessageId" = matched.gateway_message_id,
|
||||||
|
"errorCode" = matched.error_code,
|
||||||
|
"errorMessage" = matched.error_message,
|
||||||
|
"deliveredAt" = matched.delivered_at,
|
||||||
|
"timeoutAt" = NULL,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP
|
||||||
|
FROM delivered_match matched
|
||||||
|
WHERE message.id = matched.message_record_id
|
||||||
|
AND (
|
||||||
|
message.status IS DISTINCT FROM 'delivered'
|
||||||
|
OR message."receiptStatus" IS DISTINCT FROM 'delivered'
|
||||||
|
OR message."deliveredAt" IS DISTINCT FROM matched.delivered_at
|
||||||
|
OR message."channelId" IS DISTINCT FROM matched.channel_id
|
||||||
|
);
|
||||||
@@ -9,6 +9,7 @@ import { BillingModule } from './billing/billing.module';
|
|||||||
import { ChannelsModule } from './channels/channels.module';
|
import { ChannelsModule } from './channels/channels.module';
|
||||||
import { CertificationModule } from './certification/certification.module';
|
import { CertificationModule } from './certification/certification.module';
|
||||||
import { DictionariesModule } from './dictionaries/dictionaries.module';
|
import { DictionariesModule } from './dictionaries/dictionaries.module';
|
||||||
|
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { OperationsModule } from './operations/operations.module';
|
import { OperationsModule } from './operations/operations.module';
|
||||||
@@ -35,6 +36,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
AuditModule,
|
AuditModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
DictionariesModule,
|
DictionariesModule,
|
||||||
|
DeletionGovernanceModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
CertificationModule,
|
CertificationModule,
|
||||||
SmsConfigModule,
|
SmsConfigModule,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@n
|
|||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from './current-session-user.decorator';
|
import { CurrentSessionUserId } from './current-session-user.decorator';
|
||||||
import { AuthService, LoginDto } from './auth.service';
|
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 type { SessionRequest } from './session-validation.middleware';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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);
|
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('auth/session')
|
@Get(['admin/auth/session', 'client/auth/session'])
|
||||||
currentSession(@Req() request: SessionRequest) {
|
async currentSession(@Req() request: SessionRequest) {
|
||||||
this.assertSession(request);
|
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) {
|
async touch(@Req() request: SessionRequest) {
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const result = await this.sessions.touch(request.sessionToken!);
|
const result = await this.sessions.touch(request.sessionToken!);
|
||||||
@@ -52,7 +70,7 @@ export class AuthController {
|
|||||||
return this.sessions.publicSession(result.record);
|
return this.sessions.publicSession(result.record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/session/lock')
|
@Post(['admin/auth/session/lock', 'client/auth/session/lock'])
|
||||||
async lock(@Req() request: SessionRequest) {
|
async lock(@Req() request: SessionRequest) {
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const record = await this.sessions.lock(request.sessionToken!);
|
const record = await this.sessions.lock(request.sessionToken!);
|
||||||
@@ -60,17 +78,17 @@ export class AuthController {
|
|||||||
return { locked: Boolean(record) };
|
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) {
|
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
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: '锁定时间过长,请重新登录' });
|
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 });
|
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
|
||||||
return this.sessions.publicSession(result.record);
|
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) {
|
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
||||||
@@ -79,22 +97,23 @@ export class AuthController {
|
|||||||
return this.sessions.publicSession(result.record);
|
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) {
|
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||||
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
|
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
|
||||||
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/password')
|
@Post(['admin/auth/password', 'client/auth/password'])
|
||||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
||||||
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||||
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
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({
|
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 } },
|
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) {
|
private setCookie(response: CookieResponse, portal: SessionPortal, token: string) {
|
||||||
response.cookie(this.sessions.cookieName, token, {
|
response.cookie(this.sessions.cookieName(portal), token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: this.sessions.cookieSecure,
|
secure: this.sessions.cookieSecure,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
@@ -123,8 +142,18 @@ export class AuthController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearCookie(response: CookieResponse) {
|
private clearCookie(response: CookieResponse, portal?: SessionPortal) {
|
||||||
response.clearCookie(this.sessions.cookieName, {
|
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,
|
httpOnly: true,
|
||||||
secure: this.sessions.cookieSecure,
|
secure: this.sessions.cookieSecure,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const record = {
|
|||||||
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
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 {
|
return {
|
||||||
originalUrl: path,
|
originalUrl: path,
|
||||||
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
|
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', () => {
|
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 () => {
|
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 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 middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
const currentRequest = request();
|
const currentRequest = request();
|
||||||
const next = jest.fn();
|
const next = jest.fn();
|
||||||
@@ -31,7 +33,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
|
|
||||||
it('rejects a session after the user session version changes', async () => {
|
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 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);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
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 () => {
|
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 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);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
const next = jest.fn();
|
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();
|
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 { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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 = {
|
export type SessionRequest = {
|
||||||
header(name: string): string | undefined;
|
header(name: string): string | undefined;
|
||||||
@@ -22,9 +22,10 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
return;
|
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 (!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', '请先登录');
|
throw this.unauthorized('SESSION_INVALID', '请先登录');
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
@@ -43,14 +44,15 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
await this.sessions.remove(token);
|
await this.sessions.remove(token);
|
||||||
throw this.unauthorized('SESSION_REVOKED', '登录会话已被撤销,请重新登录');
|
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', '登录入口与当前会话不匹配');
|
throw this.unauthorized('SESSION_PORTAL_MISMATCH', '登录入口与当前会话不匹配');
|
||||||
}
|
}
|
||||||
|
|
||||||
request.sessionUserId = user.id;
|
request.sessionUserId = user.id;
|
||||||
request.sessionToken = token;
|
request.sessionToken = token;
|
||||||
request.authSession = result.record;
|
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) {
|
if (result.newlyLocked) {
|
||||||
await this.prisma.operationLog.create({
|
await this.prisma.operationLog.create({
|
||||||
data: { userId: user.id, action: 'auth.session_locked', resource: 'auth_session', detail: { portal: result.record.portal, reason: 'idle_timeout' } },
|
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();
|
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;
|
if (!cookieHeader) return undefined;
|
||||||
|
const cookieName = this.sessions.cookieName(portal);
|
||||||
for (const part of cookieHeader.split(';')) {
|
for (const part of cookieHeader.split(';')) {
|
||||||
const [name, ...value] = part.trim().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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,4 +70,12 @@ describe('SessionService', () => {
|
|||||||
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
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:';
|
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||||
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = '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()
|
@Injectable()
|
||||||
export class SessionService implements OnModuleDestroy {
|
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');
|
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
get cookieName() {
|
cookieName(portal: SessionPortal) {
|
||||||
return this.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME;
|
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() {
|
get absoluteTimeoutMs() {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
|||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
BillingActionDto,
|
BillingActionDto,
|
||||||
CreateManualRechargeDto,
|
CreateManualRechargeDto,
|
||||||
|
ManualRechargePreflightDto,
|
||||||
CreateBillingRuleDto,
|
CreateBillingRuleDto,
|
||||||
CreateSmsBillingRecordDto,
|
CreateSmsBillingRecordDto,
|
||||||
CreateTenantAccountDto,
|
CreateTenantAccountDto,
|
||||||
@@ -46,8 +48,13 @@ export class BillingController {
|
|||||||
|
|
||||||
@Post('manual-recharges')
|
@Post('manual-recharges')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
createManualRecharge(@Body() body: CreateManualRechargeDto) {
|
createManualRecharge(@Body() body: CreateManualRechargeDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
return this.billing.createManualRecharge(body);
|
return this.billing.createManualRecharge({ ...body, operatorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('manual-recharges/preflight')
|
||||||
|
manualRechargePreflight(@Body() body: ManualRechargePreflightDto) {
|
||||||
|
return this.billing.manualRechargePreflight(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('estimate')
|
@Post('estimate')
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0 };
|
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0, updatedAt: new Date('2026-07-21T10:00:00.000Z') };
|
||||||
return {
|
const prisma = {
|
||||||
accountState,
|
accountState,
|
||||||
|
tenant: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
||||||
|
},
|
||||||
tenantAccount: {
|
tenantAccount: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
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 }) => {
|
update: jest.fn().mockImplementation(({ data }) => {
|
||||||
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
||||||
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
|
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
|
||||||
@@ -16,10 +25,12 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
accountTransaction: {
|
accountTransaction: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||||
},
|
},
|
||||||
rechargeOrder: {
|
rechargeOrder: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })),
|
||||||
},
|
},
|
||||||
smsBillingRecord: {
|
smsBillingRecord: {
|
||||||
@@ -31,9 +42,14 @@ function createPrismaMock() {
|
|||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
},
|
},
|
||||||
operationLog: {
|
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', () => {
|
describe('BillingService', () => {
|
||||||
@@ -129,6 +145,8 @@ describe('BillingService', () => {
|
|||||||
const order = await service.createManualRecharge({
|
const order = await service.createManualRecharge({
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
amountCents: 2000,
|
amountCents: 2000,
|
||||||
|
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
||||||
|
idempotencyKey: 'manual-recharge-1',
|
||||||
operatorId: 'admin-1',
|
operatorId: 'admin-1',
|
||||||
remark: '线下转账人工充值',
|
remark: '线下转账人工充值',
|
||||||
});
|
});
|
||||||
@@ -153,6 +171,7 @@ describe('BillingService', () => {
|
|||||||
action: 'billing.manual_recharge',
|
action: 'billing.manual_recharge',
|
||||||
resource: 'recharge_order',
|
resource: 'recharge_order',
|
||||||
resourceId: 'order-1',
|
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({
|
const order = await service.createManualRecharge({
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
amountCents: -300,
|
amountCents: -300,
|
||||||
|
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
||||||
|
idempotencyKey: 'manual-correction-1',
|
||||||
operatorId: 'admin-1',
|
operatorId: 'admin-1',
|
||||||
remark: '人工冲正',
|
remark: '人工冲正',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
|
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
|
||||||
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
|
expect(prisma.tenantAccount.updateMany).toHaveBeenCalledWith({
|
||||||
where: { tenantId: 'tenant-1' },
|
where: { tenantId: 'tenant-1', updatedAt: new Date('2026-07-21T10:00:00.000Z') },
|
||||||
data: { balanceCents: 700 },
|
data: { balanceCents: { increment: -300 } },
|
||||||
});
|
});
|
||||||
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({
|
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 () => {
|
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new BillingService(prisma as never);
|
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 { Prisma } from '@prisma/client';
|
||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -45,10 +45,17 @@ export interface CreateRechargeOrderDto {
|
|||||||
export interface CreateManualRechargeDto {
|
export interface CreateManualRechargeDto {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
|
expectedAccountUpdatedAt: string;
|
||||||
|
idempotencyKey: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManualRechargePreflightDto {
|
||||||
|
tenantId: string;
|
||||||
|
amountCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EstimateSmsCostDto {
|
export interface EstimateSmsCostDto {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
applicationId?: string;
|
applicationId?: string;
|
||||||
@@ -187,28 +194,130 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createManualRecharge(data: CreateManualRechargeDto) {
|
async createManualRecharge(data: CreateManualRechargeDto) {
|
||||||
const order = await this.createRechargeOrder({
|
assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false });
|
||||||
tenantId: data.tenantId,
|
const idempotencyKey = data.idempotencyKey?.trim();
|
||||||
amountCents: data.amountCents,
|
if (!idempotencyKey || idempotencyKey.length < 8 || idempotencyKey.length > 128) {
|
||||||
payMethod: 'manual_topup',
|
throw new BadRequestException('人工充值幂等键长度必须为 8 至 128 个字符');
|
||||||
operatorId: data.operatorId,
|
}
|
||||||
remark: data.remark,
|
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({
|
if (replay) return this.manualRechargeReplay(replay, data);
|
||||||
data: {
|
|
||||||
tenantId: data.tenantId,
|
return this.prisma.$transaction(async (tx) => {
|
||||||
userId: data.operatorId,
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'manual-recharge:' + idempotencyKey}))`;
|
||||||
action: 'billing.manual_recharge',
|
const existing = await tx.operationLog.findFirst({
|
||||||
resource: 'recharge_order',
|
where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } },
|
||||||
resourceId: order.id,
|
orderBy: { createdAt: 'desc' },
|
||||||
detail: {
|
});
|
||||||
|
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,
|
amountCents: data.amountCents,
|
||||||
orderNo: order.orderNo,
|
status: 'paid',
|
||||||
|
payMethod: 'manual_topup',
|
||||||
|
paidAt: new Date(),
|
||||||
|
operatorId: data.operatorId,
|
||||||
remark: data.remark,
|
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) {
|
estimateSmsCost(data: EstimateSmsCostDto) {
|
||||||
@@ -375,3 +484,7 @@ function estimateBillingUnits(content: string) {
|
|||||||
}
|
}
|
||||||
return Math.ceil(length / 67);
|
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 { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.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 {
|
import {
|
||||||
ChannelsService,
|
ChannelsService,
|
||||||
ChangeChannelStatusDto,
|
ChangeChannelStatusDto,
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
@ApiTags('channels')
|
@ApiTags('channels')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class ChannelsController {
|
export class ChannelsController {
|
||||||
constructor(private readonly channels: ChannelsService) {}
|
constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {}
|
||||||
|
|
||||||
@Get('channels')
|
@Get('channels')
|
||||||
listChannels() {
|
listChannels() {
|
||||||
@@ -64,8 +66,8 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Delete('channels/:id')
|
@Delete('channels/:id')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
|
deleteChannel(@Param('id') channelId: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
return this.channels.deleteChannel(channelId, body);
|
return this.deletions.delete('channel', channelId, { ...body, operatorId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('channels/:id/metrics')
|
@Get('channels/:id/metrics')
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ChannelsController } from './channels.controller';
|
import { ChannelsController } from './channels.controller';
|
||||||
import { ChannelsService } from './channels.service';
|
import { ChannelsService } from './channels.service';
|
||||||
|
import { DeletionGovernanceModule } from '../deletion-governance/deletion-governance.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [DeletionGovernanceModule],
|
||||||
controllers: [ChannelsController],
|
controllers: [ChannelsController],
|
||||||
providers: [ChannelsService],
|
providers: [ChannelsService],
|
||||||
exports: [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';
|
import { DictionariesService } from './dictionaries.service';
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
@@ -173,6 +174,22 @@ describe('DictionariesService', () => {
|
|||||||
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
|
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 () => {
|
it('only accepts string, image and file report field types', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new DictionariesService(prisma as never);
|
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 { FilesController } from './files.controller';
|
||||||
import { FilesService } from './files.service';
|
import { FilesService } from './files.service';
|
||||||
import { ObjectStorageService } from './object-storage.service';
|
import { ObjectStorageService } from './object-storage.service';
|
||||||
|
import { ClientFilesController } from './client-files.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [FilesController],
|
controllers: [FilesController, ClientFilesController],
|
||||||
providers: [FilesService, ObjectStorageService],
|
providers: [FilesService, ObjectStorageService],
|
||||||
exports: [FilesService, ObjectStorageService],
|
exports: [FilesService, ObjectStorageService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -136,4 +136,44 @@ describe('FilesService', () => {
|
|||||||
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
|
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
|
||||||
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
|
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;
|
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()
|
@Injectable()
|
||||||
export class FilesService {
|
export class FilesService {
|
||||||
constructor(
|
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) {
|
async getDownload(id: string) {
|
||||||
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
|
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
|
||||||
if (!fileObject) {
|
if (!fileObject) {
|
||||||
@@ -92,6 +107,30 @@ export class FilesService {
|
|||||||
content,
|
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;
|
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) });
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
import { OperationsService } from './operations.service';
|
import { OperationsService } from './operations.service';
|
||||||
|
|
||||||
@@ -10,12 +11,12 @@ export class ClientOperationsController {
|
|||||||
|
|
||||||
@Get('batch-tasks')
|
@Get('batch-tasks')
|
||||||
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
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')
|
@Get('batch-tasks/:id/messages')
|
||||||
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
|
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')
|
@Get('messages')
|
||||||
@@ -27,17 +28,17 @@ export class ClientOperationsController {
|
|||||||
@Query('phoneNumber') phoneNumber?: string,
|
@Query('phoneNumber') phoneNumber?: string,
|
||||||
@Query('status') status?: 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')
|
@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) {
|
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')
|
@Get('dashboard')
|
||||||
dashboard(@TenantId() tenantId?: string) {
|
dashboard(@TenantId() tenantId?: string) {
|
||||||
return this.operations.dashboard({ tenantId });
|
return this.operations.clientDashboard({ tenantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('system-logs')
|
@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) });
|
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() {
|
function createPrismaMock() {
|
||||||
return {
|
return {
|
||||||
|
user: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
|
||||||
|
},
|
||||||
smsBatchTask: {
|
smsBatchTask: {
|
||||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
||||||
count: jest.fn().mockResolvedValue(3),
|
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 () => {
|
it('builds dashboard and statistics aggregates', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
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 () => {
|
it('caps legacy audit-log reads with pagination', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { moneyToNumber } from '../common/money';
|
import { moneyToNumber } from '../common/money';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
export interface MessageQuery {
|
export interface MessageQuery {
|
||||||
tenantId?: string;
|
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) {
|
listMessages(query: MessageQuery) {
|
||||||
return this.prisma.smsMessageRecord.findMany({
|
return this.prisma.smsMessageRecord.findMany({
|
||||||
where: messageWhere(query),
|
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 }) {
|
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
|
||||||
return this.prisma.smsUplinkMessage.findMany({
|
return this.prisma.smsUplinkMessage.findMany({
|
||||||
where: {
|
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 }) {
|
async monitor(query: { tenantId?: string; channelId?: string }) {
|
||||||
const where = messageWhere(query);
|
const where = messageWhere(query);
|
||||||
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
|
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 }) {
|
async statistics(query: { tenantId?: string; groupBy?: string }) {
|
||||||
const groupBy = normalizeGroupBy(query.groupBy);
|
const groupBy = normalizeGroupBy(query.groupBy);
|
||||||
if (groupBy === 'tenantId') {
|
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) {
|
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
|
||||||
const page = Math.max(1, Number(query.page ?? 1));
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||||
@@ -989,7 +1077,10 @@ function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeCsvCell(value: string) {
|
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')) {
|
if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) {
|
||||||
return `"${normalized.replace(/"/g, '""')}"`;
|
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]}`;
|
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 } }>) {
|
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
|
||||||
return groups.reduce(
|
return groups.reduce(
|
||||||
(summary, group) => {
|
(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;
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
configurable: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ export class ReportMaterialsController {
|
|||||||
return this.service.listBatches();
|
return this.service.listBatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('batches/preflight')
|
||||||
|
preflightBatch(@Body() body: CreateReportBatchDto) {
|
||||||
|
return this.service.preflightBatch(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('batches')
|
@Post('batches')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
createBatch(@Body() body: CreateReportBatchDto, @CurrentSessionUserId() operatorId?: string) {
|
createBatch(@Body() body: CreateReportBatchDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
|||||||
@@ -59,17 +59,20 @@ describe('ReportMaterialsService', () => {
|
|||||||
let exportSequence = 0;
|
let exportSequence = 0;
|
||||||
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
|
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
|
||||||
const prisma = {
|
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: {
|
reportMaterialBatch: {
|
||||||
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
|
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 })),
|
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
|
||||||
},
|
},
|
||||||
smsSignature: {
|
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({}),
|
update: jest.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: channels.map((channel, index) => ({ priority: index, channel })) } }]) },
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })) } }]) },
|
||||||
reportMaterialBatchItem: { create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
|
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))) },
|
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
|
||||||
channelReportField: { findMany: jest.fn().mockResolvedValue([
|
channelReportField: { findMany: jest.fn().mockResolvedValue([
|
||||||
{ code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
|
{ 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 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(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
|
||||||
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(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 () => {
|
it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => {
|
||||||
const prisma = {
|
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)) },
|
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() },
|
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
|
||||||
reportMaterialBatchItem: { create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
|
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
|
||||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
|
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
|
||||||
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
|
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 })) },
|
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 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 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).not.toHaveBeenCalled();
|
||||||
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'waiting_material', reason: '通道未配置当前资料类型的报备字段' }) });
|
|
||||||
expect(prisma.smsSignature.update).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 { Prisma } from '@prisma/client';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { extname } from 'node:path';
|
import { extname } from 'node:path';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -40,9 +40,26 @@ export interface ImportCommitDto {
|
|||||||
|
|
||||||
export interface CreateReportBatchDto {
|
export interface CreateReportBatchDto {
|
||||||
createdById?: string;
|
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 = {
|
type AnalyzeImportOptions = {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
applicationId?: string;
|
applicationId?: string;
|
||||||
@@ -296,13 +313,33 @@ export class ReportMaterialsService {
|
|||||||
|
|
||||||
async createBatch(data: CreateReportBatchDto) {
|
async createBatch(data: CreateReportBatchDto) {
|
||||||
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
|
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 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({
|
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 {
|
try {
|
||||||
const prepared = [];
|
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]>>();
|
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
|
||||||
for (const item of prepared) {
|
for (const item of prepared) {
|
||||||
for (const channel of item.channels) {
|
for (const channel of item.channels) {
|
||||||
@@ -313,9 +350,11 @@ export class ReportMaterialsService {
|
|||||||
}
|
}
|
||||||
const exportedFiles = [];
|
const exportedFiles = [];
|
||||||
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
|
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) {
|
for (const [channelId, items] of channelMap) {
|
||||||
const result = await this.exportChannelBatch(batch.id, channelId, items);
|
const result = await this.exportChannelBatch(batch.id, channelId, items);
|
||||||
exportedFiles.push(result.file);
|
exportedFiles.push(result.file);
|
||||||
|
failedTargetCount += result.incompleteBatchItemIds.length;
|
||||||
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
|
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
|
||||||
}
|
}
|
||||||
for (const item of prepared) {
|
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 } });
|
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 } });
|
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) {
|
} 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.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;
|
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>) {
|
private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||||
const name = mappedCoreValue(mappings, values, 'signatureName');
|
const name = mappedCoreValue(mappings, values, 'signatureName');
|
||||||
if (!name) throw new Error('缺少短信签名');
|
if (!name) throw new Error('缺少短信签名');
|
||||||
@@ -354,7 +426,7 @@ export class ReportMaterialsService {
|
|||||||
return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
|
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 } });
|
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
|
||||||
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
|
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
|
||||||
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
|
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
|
||||||
@@ -365,15 +437,121 @@ export class ReportMaterialsService {
|
|||||||
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
||||||
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'
|
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: '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', 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: '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 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 } });
|
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 };
|
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']>>>) {
|
private async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportMaterialsService['prepareBatchItem']>>>) {
|
||||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||||
if (!channel) throw new NotFoundException('通道不存在');
|
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 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 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 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',
|
id: 'tpl-1',
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
applicationId: 'app-1',
|
applicationId: 'app-1',
|
||||||
|
signatureId: 'sig-1',
|
||||||
|
content: 'hello',
|
||||||
auditStatus: 'approved',
|
auditStatus: 'approved',
|
||||||
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
|
||||||
}),
|
}),
|
||||||
findFirst: jest.fn().mockResolvedValue({
|
findFirst: jest.fn().mockResolvedValue({
|
||||||
id: 'tpl-1',
|
id: 'tpl-1',
|
||||||
@@ -112,6 +114,7 @@ function createPrismaMock() {
|
|||||||
findFirst: jest.fn().mockResolvedValue(task),
|
findFirst: jest.fn().mockResolvedValue(task),
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
update: jest.fn().mockResolvedValue(task),
|
update: jest.fn().mockResolvedValue(task),
|
||||||
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
},
|
},
|
||||||
smsApiRequest: {
|
smsApiRequest: {
|
||||||
create: jest.fn().mockResolvedValue({ id: 'request-1' }),
|
create: jest.fn().mockResolvedValue({ id: 'request-1' }),
|
||||||
@@ -214,6 +217,9 @@ function createPrismaMock() {
|
|||||||
messageId: 'MSG-1',
|
messageId: 'MSG-1',
|
||||||
deliveryType: 'receipt',
|
deliveryType: 'receipt',
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
|
manualRetryCount: 0,
|
||||||
|
status: 'failed',
|
||||||
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
||||||
application: { cmppAccount: '100001' },
|
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' }),
|
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 }),
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
gatewayDownstreamRecoveryStatus: {
|
gatewayDownstreamRecoveryStatus: {
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
@@ -364,6 +371,10 @@ describe('SendChainService', () => {
|
|||||||
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
|
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
|
||||||
auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' },
|
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({
|
await service.createHttpBatchTask({
|
||||||
tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'],
|
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 });
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||||
prisma.smsTemplate.findUnique.mockResolvedValue({
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||||
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved',
|
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([
|
prisma.smsDrainageInfo.findMany.mockResolvedValue([
|
||||||
{ id: 'drain-short', url: 'https://a.example', updatedAt: new Date('2026-07-01') },
|
{ id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') },
|
||||||
{ id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') },
|
{ id: 'drain-long', url: 'https://a.example/landing', auditStatus: 'approved', updatedAt: new Date('2026-07-02') },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await service.createBatchTask({
|
await service.createBatchTask({
|
||||||
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
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({
|
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 () => {
|
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
|
||||||
const { service, prisma, billing } = createService();
|
const { service, prisma, billing } = createService();
|
||||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||||
@@ -437,6 +524,102 @@ describe('SendChainService', () => {
|
|||||||
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
||||||
data: { status: 'queued' },
|
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 () => {
|
it('cancels scheduled tasks before dispatch', async () => {
|
||||||
@@ -1155,7 +1338,7 @@ describe('SendChainService', () => {
|
|||||||
}));
|
}));
|
||||||
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['pending', 'requeued'] },
|
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||||
OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }],
|
OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }],
|
||||||
},
|
},
|
||||||
data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }),
|
data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }),
|
||||||
@@ -1734,13 +1917,16 @@ describe('SendChainService', () => {
|
|||||||
operatorId: 'user-1',
|
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({
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||||
where: { id: 'dead-1', status: 'pending' },
|
where: { id: 'dead-1', status: 'pending' },
|
||||||
data: { status: 'requeueing' },
|
data: { status: 'requeueing' },
|
||||||
});
|
});
|
||||||
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||||
where: { id: 'dead-1' },
|
where: { id: 'dead-1', status: 'requeueing' },
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: 'requeued',
|
status: 'requeued',
|
||||||
manualRetryCount: { increment: 1 },
|
manualRetryCount: { increment: 1 },
|
||||||
@@ -1778,6 +1964,103 @@ describe('SendChainService', () => {
|
|||||||
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
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 () => {
|
it('records gateway downstream recovery statuses', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
|
|
||||||
@@ -2053,6 +2336,7 @@ describe('SendChainService', () => {
|
|||||||
status: 'failed',
|
status: 'failed',
|
||||||
retryCount: 3,
|
retryCount: 3,
|
||||||
manualRetryCount: 1,
|
manualRetryCount: 1,
|
||||||
|
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
||||||
lastError: 'downstream client is not connected',
|
lastError: 'downstream client is not connected',
|
||||||
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
||||||
application: { cmppAccount: '100001' },
|
application: { cmppAccount: '100001' },
|
||||||
@@ -2086,9 +2370,14 @@ describe('SendChainService', () => {
|
|||||||
resourceId: 'delivery-1',
|
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({
|
data: expect.objectContaining({
|
||||||
status: 'pending',
|
status: 'manual_requeueing',
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
manualRetryCount: { increment: 1 },
|
manualRetryCount: { increment: 1 },
|
||||||
lastRetriedAt: expect.any(Date),
|
lastRetriedAt: expect.any(Date),
|
||||||
@@ -2122,7 +2411,7 @@ describe('SendChainService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
|
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();
|
expect(service['postGatewayControl']).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2231,11 +2520,13 @@ describe('SendChainService', () => {
|
|||||||
it('starts the automatic receipt-timeout scan after application startup', async () => {
|
it('starts the automatic receipt-timeout scan after application startup', async () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
||||||
|
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
|
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
|
||||||
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
|
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
|
||||||
try {
|
try {
|
||||||
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
|
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
|
||||||
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
|
||||||
service.onModuleInit();
|
service.onModuleInit();
|
||||||
await jest.advanceTimersByTimeAsync(60_000);
|
await jest.advanceTimersByTimeAsync(60_000);
|
||||||
expect(scan).toHaveBeenCalledWith({});
|
expect(scan).toHaveBeenCalledWith({});
|
||||||
@@ -2244,6 +2535,8 @@ describe('SendChainService', () => {
|
|||||||
} finally {
|
} finally {
|
||||||
if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
||||||
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
|
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();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72;
|
|||||||
const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
|
const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
|
||||||
const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
||||||
const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 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> = {
|
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||||
priority: 1,
|
priority: 1,
|
||||||
normal: 100,
|
normal: 100,
|
||||||
@@ -270,6 +276,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private receiptTimeoutInitialTimer?: ReturnType<typeof setTimeout>;
|
private receiptTimeoutInitialTimer?: ReturnType<typeof setTimeout>;
|
||||||
private receiptTimeoutIntervalTimer?: ReturnType<typeof setInterval>;
|
private receiptTimeoutIntervalTimer?: ReturnType<typeof setInterval>;
|
||||||
private receiptTimeoutScanRunning = false;
|
private receiptTimeoutScanRunning = false;
|
||||||
|
private scheduledDispatchInitialTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private scheduledDispatchIntervalTimer?: ReturnType<typeof setInterval>;
|
||||||
|
private scheduledDispatchScanRunning = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -291,11 +300,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
this.receiptTimeoutIntervalTimer.unref?.();
|
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() {
|
async onModuleDestroy() {
|
||||||
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
|
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
|
||||||
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
|
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
|
||||||
|
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
|
||||||
|
if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer);
|
||||||
await this.worker?.close();
|
await this.worker?.close();
|
||||||
await this.sendQueue?.close();
|
await this.sendQueue?.close();
|
||||||
await this.gatewayQueue?.close();
|
await this.gatewayQueue?.close();
|
||||||
@@ -307,21 +330,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const schedule = parseSchedule(data);
|
const schedule = parseSchedule(data);
|
||||||
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||||
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
|
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.resolveUnitPrice(data.tenantId, data.applicationId),
|
||||||
this.resolveQueuePriority(data.tenantId, data.applicationId),
|
this.resolveQueuePriority(data.tenantId, data.applicationId),
|
||||||
this.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
|
this.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
|
||||||
]);
|
]);
|
||||||
const risk = await this.riskReview.evaluateTask({
|
const risk = messageClassification.rejectionReason
|
||||||
tenantId: data.tenantId,
|
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
|
||||||
applicationId: data.applicationId,
|
: await this.riskReview.evaluateTask({
|
||||||
templateId: data.templateId,
|
tenantId: data.tenantId,
|
||||||
content: data.content,
|
applicationId: data.applicationId,
|
||||||
category: data.category,
|
templateId: data.templateId,
|
||||||
phones,
|
content: data.content,
|
||||||
variables: data.variables,
|
category: data.category,
|
||||||
createdById: data.createdById,
|
phones,
|
||||||
});
|
variables: messageClassification.variables ?? data.variables,
|
||||||
|
createdById: data.createdById,
|
||||||
|
});
|
||||||
const billing = this.billing.estimateSmsCost({
|
const billing = this.billing.estimateSmsCost({
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
@@ -703,33 +728,64 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dispatchDueScheduledTasks(now = new Date()) {
|
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({
|
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' },
|
orderBy: { scheduledAt: 'asc' },
|
||||||
});
|
});
|
||||||
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
|
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
|
||||||
for (const task of tasks) {
|
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 {
|
try {
|
||||||
await this.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
await this.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
||||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
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 },
|
select: { id: true, amountCents: true, billingUnits: true },
|
||||||
take: 100000,
|
take: 100000,
|
||||||
});
|
});
|
||||||
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
|
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
|
||||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
const existingReservation = await this.prisma.accountTransaction.findFirst({
|
||||||
if (!accountCheck.canSend) {
|
where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id },
|
||||||
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
select: { id: true },
|
||||||
}
|
});
|
||||||
if (amountCents > 0) {
|
reservationEstablished = Boolean(existingReservation);
|
||||||
await this.billing.freeze({
|
if (!reservationEstablished) {
|
||||||
tenantId: task.tenantId,
|
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
||||||
amountCents,
|
if (!accountCheck.canSend) {
|
||||||
relatedType: 'sms_batch_task',
|
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
||||||
relatedId: task.id,
|
}
|
||||||
remark: '定时任务到点冻结',
|
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({
|
await this.prisma.smsMessageRecord.updateMany({
|
||||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||||
data: { status: 'queued' },
|
data: { status: 'queued' },
|
||||||
@@ -738,6 +794,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
|
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : '定时任务到点执行失败';
|
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({
|
await this.prisma.smsMessageRecord.updateMany({
|
||||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||||
data: { status: 'rejected', errorMessage: reason },
|
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 };
|
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() {
|
startWorker() {
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
return { status: 'already_started' };
|
return { status: 'already_started' };
|
||||||
@@ -852,7 +928,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['pending', 'requeued'] },
|
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||||
OR: [
|
OR: [
|
||||||
data.submitId ? { submitId: data.submitId } : undefined,
|
data.submitId ? { submitId: data.submitId } : undefined,
|
||||||
data.messageId ? { messageId: data.messageId } : undefined,
|
data.messageId ? { messageId: data.messageId } : undefined,
|
||||||
@@ -1189,15 +1265,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
traceId: data.traceId,
|
traceId: data.traceId,
|
||||||
messageId: data.messageId,
|
messageId: data.messageId,
|
||||||
submitId: data.submitId,
|
submitId: data.submitId,
|
||||||
status: 'pending',
|
|
||||||
failureCode: data.failureCode,
|
failureCode: data.failureCode,
|
||||||
failureMessage: data.failureMessage,
|
failureMessage: data.failureMessage,
|
||||||
attempts: data.attempts,
|
attempts: data.attempts,
|
||||||
maxAttempts: data.maxAttempts,
|
maxAttempts: data.maxAttempts,
|
||||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||||
rawPayload: data.rawPayload,
|
rawPayload: data.rawPayload,
|
||||||
resolvedAt: null,
|
|
||||||
resolvedStatus: null,
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
streamMessageId: data.streamMessageId,
|
streamMessageId: data.streamMessageId,
|
||||||
@@ -1369,19 +1442,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (claimed.count !== 1) {
|
if (claimed.count !== 1) {
|
||||||
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
||||||
}
|
}
|
||||||
|
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||||
let retryStreamMessageId: string;
|
let retryStreamMessageId: string;
|
||||||
try {
|
try {
|
||||||
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
|
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||||
if (!publishedStreamMessageId) {
|
if (!publishedStreamMessageId) {
|
||||||
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
|
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
|
||||||
}
|
}
|
||||||
retryStreamMessageId = publishedStreamMessageId;
|
retryStreamMessageId = publishedStreamMessageId;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
const updated = await this.prisma.gatewaySubmitDeadLetter.update({
|
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||||
where: { id },
|
where: { id, status: 'requeueing' },
|
||||||
data: {
|
data: {
|
||||||
status: 'requeued',
|
status: 'requeued',
|
||||||
manualRetryCount: { increment: 1 },
|
manualRetryCount: { increment: 1 },
|
||||||
@@ -1389,6 +1466,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
lastRetriedAt: new Date(),
|
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({
|
await this.prisma.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: updated.tenantId ?? undefined,
|
tenantId: updated.tenantId ?? undefined,
|
||||||
@@ -1409,6 +1493,69 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return updated;
|
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) {
|
async requeueDownstreamDelivery(id: string) {
|
||||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -1440,10 +1587,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
...payload,
|
...payload,
|
||||||
};
|
};
|
||||||
const retriedAt = new Date();
|
const retriedAt = new Date();
|
||||||
const requeued = await this.prisma.cmppDownstreamDelivery.update({
|
const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||||
where: { id: delivery.id },
|
where: {
|
||||||
|
id: delivery.id,
|
||||||
|
status: delivery.status,
|
||||||
|
updatedAt: delivery.updatedAt,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
status: 'pending',
|
status: 'manual_requeueing',
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
manualRetryCount: { increment: 1 },
|
manualRetryCount: { increment: 1 },
|
||||||
lastRetriedAt: retriedAt,
|
lastRetriedAt: retriedAt,
|
||||||
@@ -1459,6 +1610,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (claimed.count !== 1) {
|
||||||
|
throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试');
|
||||||
|
}
|
||||||
await this.prisma.operationLog.create({
|
await this.prisma.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: delivery.tenantId,
|
tenantId: delivery.tenantId,
|
||||||
@@ -1471,7 +1625,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
messageId: delivery.messageId,
|
messageId: delivery.messageId,
|
||||||
previousStatus: delivery.status,
|
previousStatus: delivery.status,
|
||||||
previousRetryCount: delivery.retryCount,
|
previousRetryCount: delivery.retryCount,
|
||||||
manualRetryCount: requeued.manualRetryCount,
|
manualRetryCount: (delivery.manualRetryCount ?? 0) + 1,
|
||||||
lastRetriedAt: retriedAt,
|
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[]) {
|
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||||
const uniqueIds = [...new Set(ids.filter(Boolean))];
|
const uniqueIds = [...new Set(ids.filter(Boolean))];
|
||||||
if (uniqueIds.length === 0) {
|
if (uniqueIds.length === 0) {
|
||||||
@@ -1949,7 +2129,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.recordCmppFailureReceipt(message, code, reason);
|
await this.recordCmppFailureReceipt(message, code, reason);
|
||||||
};
|
};
|
||||||
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
|
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({
|
const risk = await this.riskReview.evaluateTask({
|
||||||
tenantId: application.tenantId,
|
tenantId: application.tenantId,
|
||||||
applicationId: application.id,
|
applicationId: application.id,
|
||||||
@@ -2011,6 +2201,24 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!signature) {
|
if (!signature) {
|
||||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||||
} else {
|
} 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({
|
const risk = await this.riskReview.evaluateTask({
|
||||||
tenantId: application.tenantId,
|
tenantId: application.tenantId,
|
||||||
applicationId: application.id,
|
applicationId: application.id,
|
||||||
@@ -2037,7 +2245,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const reviewTask = risk.status === 'pending_review' && risk.task
|
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({
|
: await this.riskReview.aggregateTemplateMismatch({
|
||||||
tenantId: application.tenantId,
|
tenantId: application.tenantId,
|
||||||
applicationId: application.id,
|
applicationId: application.id,
|
||||||
@@ -2143,12 +2351,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (this.receiptTimeoutScanRunning) return;
|
if (this.receiptTimeoutScanRunning) return;
|
||||||
this.receiptTimeoutScanRunning = true;
|
this.receiptTimeoutScanRunning = true;
|
||||||
try {
|
try {
|
||||||
const [receiptResult, downstreamResult] = await Promise.all([
|
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
|
||||||
this.markUnknownTimeout({}),
|
this.markUnknownTimeout({}),
|
||||||
this.markExpiredDownstreamDeliveries(),
|
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 (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 (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) {
|
} catch (error) {
|
||||||
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
|
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2510,21 +2722,67 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveTemplateMessageClassification(templateId: string | undefined, content: string) {
|
private async resolveTemplateMessageClassification(
|
||||||
if (!templateId) return { signatureId: undefined, drainageInfoId: undefined };
|
tenantId: string,
|
||||||
const template = await this.prisma.smsTemplate.findUnique({
|
applicationId: string | undefined,
|
||||||
where: { id: templateId },
|
templateId: string | undefined,
|
||||||
select: { signatureId: true },
|
content: string,
|
||||||
});
|
) {
|
||||||
const signatureId = template?.signatureId ?? undefined;
|
if (templateId) {
|
||||||
return { signatureId, drainageInfoId: await this.resolveDrainageInfoId(signatureId, content) };
|
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;
|
if (!signatureId) return undefined;
|
||||||
const candidates = await this.prisma.smsDrainageInfo.findMany({
|
const candidates = await this.prisma.smsDrainageInfo.findMany({
|
||||||
where: { signatureId, auditStatus: 'approved' },
|
where: { signatureId, auditStatus: { not: 'deleted' } },
|
||||||
select: { id: true, url: true, updatedAt: true },
|
select: { id: true, url: true, auditStatus: true, updatedAt: true },
|
||||||
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||||
});
|
});
|
||||||
const matches = candidates
|
const matches = candidates
|
||||||
@@ -2534,7 +2792,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (matches.length === 0) return undefined;
|
if (matches.length === 0) return undefined;
|
||||||
const longestLength = matches[0].normalizedUrl.length;
|
const longestLength = matches[0].normalizedUrl.length;
|
||||||
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
|
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) {
|
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(() => ({}));
|
return response.json().catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishGatewaySubmitCommand(command: unknown) {
|
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||||
return this.getRedis().xadd(
|
const redis = this.getRedis();
|
||||||
process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM,
|
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
|
||||||
'*',
|
const payload = JSON.stringify(command);
|
||||||
'messageType',
|
if (!idempotencyKey) {
|
||||||
'SubmitCommand',
|
return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload);
|
||||||
'data',
|
}
|
||||||
JSON.stringify(command),
|
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) {
|
function statusFromRisk(status: string, scheduled: boolean) {
|
||||||
if (status === 'rejected') {
|
if (status === 'rejected') {
|
||||||
return 'rejected';
|
return 'rejected';
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
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';
|
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||||
|
|
||||||
@ApiTags('admin-sms-config')
|
@ApiTags('admin-sms-config')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminSmsConfigController {
|
export class AdminSmsConfigController {
|
||||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
constructor(private readonly smsConfig: SmsConfigService, private readonly reviewGovernance: ReviewGovernanceService, private readonly deletions: DeletionGovernanceService) {}
|
||||||
|
|
||||||
@Get('enterprise-applications')
|
@Get('enterprise-applications')
|
||||||
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) {
|
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')
|
@Post('signatures/:id/approve')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
|
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||||
return this.smsConfig.approveSignature(signatureId, body);
|
return this.reviewGovernance.decide('signature', signatureId, { ...body, decision: 'approve', reviewerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures/:id/reject')
|
@Post('signatures/:id/reject')
|
||||||
@@ -138,8 +141,8 @@ export class AdminSmsConfigController {
|
|||||||
|
|
||||||
@Post('templates/:id/approve')
|
@Post('templates/:id/approve')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||||
return this.smsConfig.approveTemplate(templateId, body);
|
return this.reviewGovernance.decide('template', templateId, { ...body, decision: 'approve', reviewerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('templates/:id/reject')
|
@Post('templates/:id/reject')
|
||||||
@@ -156,13 +159,15 @@ export class AdminSmsConfigController {
|
|||||||
|
|
||||||
@Post('enterprise-signatures/:id/status')
|
@Post('enterprise-signatures/:id/status')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
return this.smsConfig.changeSignatureStatus(signatureId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('enterprise-templates/:id/status')
|
@Post('enterprise-templates/:id/status')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.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 {
|
import {
|
||||||
CreateSignatureMaterialDto,
|
CreateSignatureMaterialDto,
|
||||||
CreateSmsApplicationDto,
|
CreateSmsApplicationDto,
|
||||||
@@ -18,7 +20,7 @@ import {
|
|||||||
@ApiTags('client-sms-config')
|
@ApiTags('client-sms-config')
|
||||||
@Controller('client')
|
@Controller('client')
|
||||||
export class ClientSmsConfigController {
|
export class ClientSmsConfigController {
|
||||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
|
||||||
|
|
||||||
@Get('applications')
|
@Get('applications')
|
||||||
listApplications(@TenantId() tenantId?: string) {
|
listApplications(@TenantId() tenantId?: string) {
|
||||||
@@ -115,7 +117,8 @@ export class ClientSmsConfigController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures/:id/status')
|
@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);
|
await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId);
|
||||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||||
}
|
}
|
||||||
@@ -132,7 +135,7 @@ export class ClientSmsConfigController {
|
|||||||
|
|
||||||
@Put('templates/:id')
|
@Put('templates/:id')
|
||||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) {
|
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')
|
@Post('templates/:id/submit')
|
||||||
@@ -141,7 +144,8 @@ export class ClientSmsConfigController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('templates/:id/status')
|
@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);
|
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 { AdminSmsConfigController } from './admin-sms-config.controller';
|
||||||
import { ClientSmsConfigController } from './client-sms-config.controller';
|
import { ClientSmsConfigController } from './client-sms-config.controller';
|
||||||
import { SmsConfigService } from './sms-config.service';
|
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({
|
@Module({
|
||||||
controllers: [ClientSmsConfigController, AdminSmsConfigController],
|
imports: [DeletionGovernanceModule],
|
||||||
providers: [SmsConfigService],
|
controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController],
|
||||||
|
providers: [SmsConfigService, ReviewGovernanceService],
|
||||||
exports: [SmsConfigService],
|
exports: [SmsConfigService],
|
||||||
})
|
})
|
||||||
export class SmsConfigModule {}
|
export class SmsConfigModule {}
|
||||||
|
|||||||
@@ -543,7 +543,16 @@ describe('SmsConfigService', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({
|
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({
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({
|
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 () => {
|
it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||||
@@ -866,6 +896,41 @@ describe('SmsConfigService', () => {
|
|||||||
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
|
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 () => {
|
it('creates real drainage materials and channel tasks after operations approval', async () => {
|
||||||
const prisma = createPrismaMock();
|
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' };
|
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 timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
|
||||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||||
return this.prisma.cmppDownstreamConnection.deleteMany({
|
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
|
const drainageInfo = data.drainageInfo
|
||||||
? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||||
: undefined;
|
: 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({
|
const updated = await this.prisma.smsSignature.update({
|
||||||
where: { id: signatureId },
|
where: { id: signatureId },
|
||||||
data: {
|
data: {
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
purpose: data.purpose,
|
purpose: data.purpose,
|
||||||
auditStatus: data.auditStatus,
|
auditStatus,
|
||||||
rejectReason: data.auditStatus === 'pending' ? null : undefined,
|
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||||
materialVersion: { increment: 1 },
|
materialVersion: { increment: 1 },
|
||||||
pendingReport: true,
|
pendingReport: true,
|
||||||
@@ -1376,6 +1387,12 @@ export class SmsConfigService {
|
|||||||
const variables = data.content !== undefined || data.variables !== undefined
|
const variables = data.content !== undefined || data.variables !== undefined
|
||||||
? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables)
|
? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables)
|
||||||
: undefined;
|
: 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) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
if (variables) {
|
if (variables) {
|
||||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||||
@@ -1388,7 +1405,8 @@ export class SmsConfigService {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
content: data.content,
|
content: data.content,
|
||||||
category: data.category,
|
category: data.category,
|
||||||
auditStatus: data.auditStatus,
|
auditStatus,
|
||||||
|
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||||
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
||||||
variables: variables ? {
|
variables: variables ? {
|
||||||
create: variables.map((variable) => ({
|
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) {
|
async submitTemplate(templateId: string, tenantId?: string) {
|
||||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
2. 依次应用 `20260720110000_add_receipt_identity`、`20260720113000_add_report_business_metrics` 与 `20260720114500_add_receipt_phone_number`。三者为历史回执生成不冲突的 `receiptKey`,增加回执文本、目的号码和匹配索引,并增加报表失败数及利润退款金额列。历史目的号码先从原关联主记录回填;对已知错绑记录仍须依据生产提交记录、通道和原始 Gateway 日志专项复核,不能仅靠该回填自动改绑。
|
2. 依次应用 `20260720110000_add_receipt_identity`、`20260720113000_add_report_business_metrics` 与 `20260720114500_add_receipt_phone_number`。三者为历史回执生成不冲突的 `receiptKey`,增加回执文本、目的号码和匹配索引,并增加报表失败数及利润退款金额列。历史目的号码先从原关联主记录回填;对已知错绑记录仍须依据生产提交记录、通道和原始 Gateway 日志专项复核,不能仅靠该回填自动改绑。
|
||||||
3. 先重启 Gateway,再重启 API;检查 Redis Stream、活动通道、TPS key、API/Gateway health 和近期错误日志。
|
3. 先重启 Gateway,再重启 API;检查 Redis Stream、活动通道、TPS key、API/Gateway health 和近期错误日志。
|
||||||
4. 对 T-4 至 T-1 及需修复的历史日期重复执行报表重算,核对查询与 CSV 的发送、成功、失败、收入、退款、成本、利润和到达时长。
|
4. 对 T-4 至 T-1 及需修复的历史日期重复执行报表重算,核对查询与 CSV 的发送、成功、失败、收入、退款、成本、利润和到达时长。
|
||||||
|
5. 追加应用 `20260721150000_backfill_misattributed_delivery_receipts`。该迁移只处理“回执已关联主记录,且主记录 + 上游 Msg_Id + 目的号码只能命中一条提交记录”的成功回执:先把历史错误通道改为真实提交通道,再将成功状态聚合到主记录。零匹配或多匹配记录保持不变。迁移可重复执行且不会创建回执、计费或客户下游投递。
|
||||||
|
6. 新迁移完成并启动 API 后,确认启动时 T-4 至 T-1 重算覆盖受影响日期;若发布日期已使目标历史日超出滚动窗口,必须在受控维护命令中显式重算对应日期,不能只修改报表表格或手工填写成功数。
|
||||||
|
|
||||||
## 回滚
|
## 回滚
|
||||||
|
|
||||||
应用代码可回滚到上一版本,但新增列和索引默认保留,避免丢失已接收的回执身份、错误文本和重算结果。若确认不存在新版本写入且必须做结构回滚,应先备份,再依次删除两个报表新增列、回执新增列及索引;删除 `receiptKey` 唯一约束前必须确认旧代码不会再次以模糊条件消费回执。生产禁止未经审批直接执行破坏性回滚。
|
应用代码可回滚到上一版本,但新增列和索引默认保留,避免丢失已接收的回执身份、错误文本和重算结果。若确认不存在新版本写入且必须做结构回滚,应先备份,再依次删除两个报表新增列、回执新增列及索引;删除 `receiptKey` 唯一约束前必须确认旧代码不会再次以模糊条件消费回执。生产禁止未经审批直接执行破坏性回滚。
|
||||||
|
|
||||||
|
`20260721150000_backfill_misattributed_delivery_receipts` 是数据纠正迁移,没有安全的自动逆向迁移。回滚应用代码时保留已纠正的回执和主记录;若业务要求恢复迁移前值,只能从发布前 PostgreSQL 备份按明确记录 ID 定向恢复,禁止用账号或日期范围批量反向覆盖。
|
||||||
|
|||||||
@@ -280,10 +280,12 @@
|
|||||||
- 已实现上游连接断开时的 pending submit 状态补偿第一版:如果某条上游 CMPP 连接在收到 submit resp 前断开,Gateway 会立即唤醒该连接上等待中的 pending submit,请求返回 `timeout/CONNECTION_LOST`,由 NestJS 进入既有补发或释放冻结逻辑,不再只依赖固定超时。
|
- 已实现上游连接断开时的 pending submit 状态补偿第一版:如果某条上游 CMPP 连接在收到 submit resp 前断开,Gateway 会立即唤醒该连接上等待中的 pending submit,请求返回 `timeout/CONNECTION_LOST`,由 NestJS 进入既有补发或释放冻结逻辑,不再只依赖固定超时。
|
||||||
- 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。
|
- 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。
|
||||||
- 已实现 Gateway 提交异常治理:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表(数据库表名和内部接口保留技术兼容名,页面统一称“Gateway提交异常”)。运营端 `/admin/gateway-submit-exceptions` 提供真实分页、筛选、汇总、脱敏详情和单条重新入队;原始 payload、密码、密钥不得返回浏览器。重新入队必须要求近期认证、填写原因、勾选“已确认上游未受理”,并校验短信尚未 accepted/submitted/delivered/unknown、通道 active 且 connected、人工次数小于 3;服务端以 pending 到 requeueing 的原子状态抢占防止重复点击,成功写回 Redis Stream 后记录操作人、原因、Stream ID 和时间。收到后续 SubmitResult 时必须将对应异常记录闭环为 resolved。
|
- 已实现 Gateway 提交异常治理:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表(数据库表名和内部接口保留技术兼容名,页面统一称“Gateway提交异常”)。运营端 `/admin/gateway-submit-exceptions` 提供真实分页、筛选、汇总、脱敏详情和单条重新入队;原始 payload、密码、密钥不得返回浏览器。重新入队必须要求近期认证、填写原因、勾选“已确认上游未受理”,并校验短信尚未 accepted/submitted/delivered/unknown、通道 active 且 connected、人工次数小于 3;服务端以 pending 到 requeueing 的原子状态抢占防止重复点击,成功写回 Redis Stream 后记录操作人、原因、Stream ID 和时间。收到后续 SubmitResult 时必须将对应异常记录闭环为 resolved。
|
||||||
|
- Gateway 提交异常重新入队必须使用“异常记录 ID + 下一次人工次数”的稳定幂等键,通过 Redis Lua 原子完成“检查幂等键、XADD、保存 Stream ID”;API 在 XADD 成功后宕机或数据库落账失败时,超时恢复扫描必须复用同一幂等键完成落账,不能再次产生 Stream 消息。Gateway 重复上报同一个原始 Stream 异常不得把 `requeued/resolved` 回退成 `pending`。
|
||||||
- 已实现 Gateway 通道级 Redis 限速:NestJS 入队前保留业务层通道限速,Go Gateway 在真正调用上游 Submit 前再次按通道 ID 预约发送时隙;连接命令把权威 TPS 写入 Redis,提交按权威值与消息值的较小者执行。普通 Stream 消息在等待期间不 ACK、不转失败,多实例共同使用同一限速状态;worker 对同批消息并发调度,低 TPS 通道等待不阻塞其他通道。
|
- 已实现 Gateway 通道级 Redis 限速:NestJS 入队前保留业务层通道限速,Go Gateway 在真正调用上游 Submit 前再次按通道 ID 预约发送时隙;连接命令把权威 TPS 写入 Redis,提交按权威值与消息值的较小者执行。普通 Stream 消息在等待期间不 ACK、不转失败,多实例共同使用同一限速状态;worker 对同批消息并发调度,低 TPS 通道等待不阻塞其他通道。
|
||||||
- 已实现 Gateway 重启后的 active 上游通道恢复:部署先重启 Gateway 再重启 API;API 启动后从 PostgreSQL 读取 active 通道,重新下发连接命令,恢复 Gateway 内存连接池、真实连接状态和 Redis 权威 TPS key,不得继续沿用重启前的 connected 状态。
|
- 已实现 Gateway 重启后的 active 上游通道恢复:部署先重启 Gateway 再重启 API;API 启动后从 PostgreSQL 读取 active 通道,重新下发连接命令,恢复 Gateway 内存连接池、真实连接状态和 Redis 权威 TPS key,不得继续沿用重启前的 connected 状态。
|
||||||
- 已实现客户侧下游投递重试第二版:客户系统负责断线后重连;平台在客户离线或投递失败时把 Deliver Receipt/上行 Deliver 保留在 `CmppDownstreamDelivery`,客户 bind 成功后立即拉取 pending,且 Gateway 会对当前在线账号周期补投;超过重试上限后转 `failed` 并写失败审计。
|
- 已实现客户侧下游投递重试第二版:客户系统负责断线后重连;平台在客户离线或投递失败时把 Deliver Receipt/上行 Deliver 保留在 `CmppDownstreamDelivery`,客户 bind 成功后立即拉取 pending,且 Gateway 会对当前在线账号周期补投;超过重试上限后转 `failed` 并写失败审计。
|
||||||
- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。
|
- 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。
|
||||||
|
- 同一下游投递的人工重投必须使用 `id + status + updatedAt` 条件更新原子认领;认领后先进入 `manual_requeueing`,避免运营并发请求或 Gateway pending 恢复扫描同时双发。调用完成后进入 `awaiting_ack` 或失败状态;进程中断超过默认 2 分钟后自动转回 `pending`,由 Gateway 单一路径恢复投递。阈值可通过 `CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS` 调整。
|
||||||
- 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。
|
- 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。
|
||||||
- 下游投递的 `pending` 展示必须结合真实尝试字段:自动与人工次数均为 0 时显示“待首次投递”,`retryCount > 0` 时显示“等待自动重试”,`manualRetryCount > 0` 时显示“人工重投排队中”。人工重投可重置新一轮自动重试预算,但不得把记录伪装成从未投递。
|
- 下游投递的 `pending` 展示必须结合真实尝试字段:自动与人工次数均为 0 时显示“待首次投递”,`retryCount > 0` 时显示“等待自动重试”,`manualRetryCount > 0` 时显示“人工重投排队中”。人工重投可重置新一轮自动重试预算,但不得把记录伪装成从未投递。
|
||||||
- 下游投递不得无限停留在 `pending`:Gateway 对未写出的结果必须返回明确的 `retryable/reasonCode/errorMessage`。状态回执在原消息映射已经丢失且缺少 `submitSequenceId` 时属于不可恢复错误,立即转为 `failed` 并保留失败原因和操作日志;客户端暂时离线属于可恢复错误,按既有重试次数与指数退避处理。所有 pending 从创建时间或最近人工重投时间起最多保留 72 小时,可通过 `CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS` 调整,超时后自动终结为 `failed`。
|
- 下游投递不得无限停留在 `pending`:Gateway 对未写出的结果必须返回明确的 `retryable/reasonCode/errorMessage`。状态回执在原消息映射已经丢失且缺少 `submitSequenceId` 时属于不可恢复错误,立即转为 `failed` 并保留失败原因和操作日志;客户端暂时离线属于可恢复错误,按既有重试次数与指数退避处理。所有 pending 从创建时间或最近人工重投时间起最多保留 72 小时,可通过 `CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS` 调整,超时后自动终结为 `failed`。
|
||||||
@@ -1096,6 +1098,8 @@
|
|||||||
### 14.2 发送能力
|
### 14.2 发送能力
|
||||||
|
|
||||||
- 需要支持定时发送。
|
- 需要支持定时发送。
|
||||||
|
- API 实例启动后必须自动扫描并派发到期任务,管理端手工触发仅作为运维补偿入口,不能作为正常发送的前置操作。
|
||||||
|
- 多 API 实例只能有一个实例成功认领同一定时任务;认领后进程退出或 Redis 短暂不可用时,任务超时后必须可恢复,且不得重复冻结余额或重复创建队列作业。0 元短信同样适用恢复和幂等要求。
|
||||||
- 导入号码文件格式支持 CSV、TXT。
|
- 导入号码文件格式支持 CSV、TXT。
|
||||||
- 导入文件最大 20 MB。
|
- 导入文件最大 20 MB。
|
||||||
- 单任务最大号码数默认 100 万条。
|
- 单任务最大号码数默认 100 万条。
|
||||||
@@ -1563,6 +1567,7 @@
|
|||||||
5. HTTP 单发公开契约使用 `mobile`、`content` 和可选 `clientMessageId`,不要求内部签名或模板 ID;服务端按 CMPP 同一规则识别已审核签名、模板及变量,复用风控、余额、计费、路由和队列。成功返回可查询 messageId,业务拒绝返回对应 4xx,不得在已创建记录后返回“批次不存在”。
|
5. HTTP 单发公开契约使用 `mobile`、`content` 和可选 `clientMessageId`,不要求内部签名或模板 ID;服务端按 CMPP 同一规则识别已审核签名、模板及变量,复用风控、余额、计费、路由和队列。成功返回可查询 messageId,业务拒绝返回对应 4xx,不得在已创建记录后返回“批次不存在”。
|
||||||
6. 客户发送候选只返回 approved 签名和模板,管理视图可查看历史状态。模板变量必须拒绝空变量、未闭合、中文或非法名称、重复名称及超长名称;客户端签名视图仅返回必要报备汇总状态。
|
6. 客户发送候选只返回 approved 签名和模板,管理视图可查看历史状态。模板变量必须拒绝空变量、未闭合、中文或非法名称、重复名称及超长名称;客户端签名视图仅返回必要报备汇总状态。
|
||||||
7. 报备资料提供官方 XLSX 模板和按当前筛选导出;导入拒绝空文件、错误扩展名、超限文件以及公式/脚本单元格。分析、提交和导出日志记录操作人、文件名、筛选条件、成功数、失败数和 IP,不保存密钥或完整请求体。
|
7. 报备资料提供官方 XLSX 模板和按当前筛选导出;导入拒绝空文件、错误扩展名、超限文件以及公式/脚本单元格。分析、提交和导出日志记录操作人、文件名、筛选条件、成功数、失败数和 IP,不保存密钥或完整请求体。
|
||||||
|
8. 下游客户连接超时清理必须同时覆盖“最后心跳早于阈值”和“最后心跳为空但连接建立时间早于阈值”;刚建立且尚未超过阈值的空心跳连接不得误删。历史共享供应商账号导致的错归回执,只能在同一主记录、同一通道消息号、同一目的号码且唯一提交记录可证明时改绑并聚合;存在歧义时必须保留原数据供人工核查。
|
||||||
|
|
||||||
## 2026-07-20 客户端用户管理移动操作可达性要求
|
## 2026-07-20 客户端用户管理移动操作可达性要求
|
||||||
|
|
||||||
@@ -1570,3 +1575,58 @@
|
|||||||
2. 390×844和375×667下四项操作必须全部可见、可聚焦、可命中,触控热区高度至少44px;操作组应具有包含目标用户名称的可访问名称。
|
2. 390×844和375×667下四项操作必须全部可见、可聚焦、可命中,触控热区高度至少44px;操作组应具有包含目标用户名称的可访问名称。
|
||||||
3. 删除仍必须走真实客户端用户API与确认流程,不得通过前端隐藏或静态数据冒充;页面验收只打开并取消确认时,不得产生DELETE请求或数据库状态变化。
|
3. 删除仍必须走真实客户端用户API与确认流程,不得通过前端隐藏或静态数据冒充;页面验收只打开并取消确认时,不得产生DELETE请求或数据库状态变化。
|
||||||
4. 1440×900、1366×768和768×1024必须同步回归。1366桌面宽表若仍需内部横向滚动,滚动条必须可发现且操作可到达;固定操作列和邮箱列宽另按全站Table整改治理。
|
4. 1440×900、1366×768和768×1024必须同步回归。1366桌面宽表若仍需内部横向滚动,滚动条必须可发现且操作可到达;固定操作列和邮箱列宽另按全站Table整改治理。
|
||||||
|
|
||||||
|
## 2026-07-21 双门户会话隔离与深链恢复要求
|
||||||
|
|
||||||
|
1. 运营端和客户端必须分别使用独立的浏览器存储键、跨标签广播频道和 HttpOnly Cookie;后端只允许目标门户的 Cookie 认证对应路由,不能依赖前端隐藏或跳转实现隔离。
|
||||||
|
2. 受保护路由必须先向真实会话接口完成初始化,再决定渲染或跳转;刷新和直接打开深链时不得先跳登录页,失效后重新登录必须回到同站点、同门户白名单内的原目标。
|
||||||
|
3. 同一浏览器可以同时保持运营端和客户端登录。锁定、解锁、会话过期和主动退出事件仅作用于当前门户;退出一端不得删除、广播或撤销另一端会话。
|
||||||
|
4. 会话锁定必须保留目标页面,显示锁定原因、恢复说明和剩余策略;锁定状态下暂停首次业务路由请求,解锁后在原 URL 重新挂载并从真实 API 加载数据。当前标签页内已加载的非敏感草稿不得因跨门户事件被清除。
|
||||||
|
5. 旧共享 Cookie 和旧共享 localStorage 只允许做一次同门户迁移或清理,不得继续作为双门户认证来源。生产发布会使旧共享 Cookie 失效时,必须在发布说明中明确需要重新登录。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A2安全上传与日志导出补充
|
||||||
|
|
||||||
|
- 客户端文件上传和下载必须使用`/api/client/files`专用接口;后端从当前会话用户反查企业,不接受请求头指定文件归属。客户端仅允许企业认证、签名报备、引流报备三类用途及对应安全目录,跨企业文件统一不可见。
|
||||||
|
- 运营端和客户端系统日志导出必须读取PostgreSQL真实筛选结果,具备提交中防重复、结构化成功结果、操作单号、文件下载、失败原地重试和会话恢复后的筛选保留。客户端导出不得包含详情JSON、IP、User-Agent等内部字段。
|
||||||
|
- CSV导出最多10000条并明确截断状态;对`= + - @`开头单元格做公式注入防护。瞬时恢复状态可使用按门户隔离的`sessionStorage`,不得作为业务数据源。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A3审核风险治理补充
|
||||||
|
|
||||||
|
- 运营端签名、模板“通过”必须先调用后端资格预检,确认层展示对象名称、唯一ID、企业、应用、资料完整度、阻断原因和后续影响;阻断项存在时后端和前端均不得批准。
|
||||||
|
- 审核决定必须使用当前登录会话审核人、客户端生成并重试复用的幂等键,以及对象`updatedAt`状态版本。后端仅允许`pending`对象在Serializable事务中原子变更,版本不一致返回409且不得覆盖其他审核员结果。
|
||||||
|
- 审核成功必须返回持久化`AuditRecord.id`作为操作单号;同对象同幂等键重试返回原结果且不得重复更新或重复审计。旧的签名/模板批准接口也必须进入同一治理服务,不得保留绕过入口。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A4报备生成风险治理补充
|
||||||
|
|
||||||
|
- 待报备资料必须由后端预检应用启用状态、资料审核与待报备状态、资料版本、启用路由/通道、通道字段配置和必填资料;不合格资料在列表中不可选择,并返回可执行的阻断原因。
|
||||||
|
- 生成接口提交时必须重新预检,不能信任列表时的前端状态。零个可生成通道组合必须返回业务4xx,不得先创建空批次或显示成功。
|
||||||
|
- 业务去重键必须覆盖资料类型、资料ID、资料版本、应用、通道及适用运营商;历史已生成组合返回跳过及既有批次,不得无提示覆盖旧任务或重复导出。
|
||||||
|
- 每次生成要求8至128位客户端幂等键。后端通过PostgreSQL事务锁认领操作,相同键同一请求返回原操作单及`replayed=true`,相同键用于不同范围返回409;生成结果按成功、跳过、失败分项返回并提供操作单号。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A5删除治理补充
|
||||||
|
|
||||||
|
- 通道、签名和模板删除前必须由后端返回对象身份、活动依赖数量与对象摘要、影响范围、`allowedActions`、`blockedReasons`、状态版本和可恢复说明;前端不得自行推断或只显示通用风险文案。
|
||||||
|
- 删除提交必须包含预检版本、8位以上幂等键和至少4字符原因。后端在Serializable事务中重新以`updatedAt`和未删除状态做条件更新,并写入包含原因、依赖、影响及幂等键的`OperationLog`,返回操作单号和重放标识。
|
||||||
|
- 客户端只能预检和删除当前会话企业的签名/模板,不得删除运营通道;通道被活动通道组/路由/连接/未结束报备引用,签名被模板/引流/未结束报备引用,模板被未结束发送/批量任务引用时,后端必须阻断。
|
||||||
|
- 删除采用逻辑删除,历史发送、回执、计费、审核和审计数据继续保留;恢复需有审计依据。所有旧删除入口必须委托同一治理服务,禁止保留绕过路径。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A6人工充值治理补充
|
||||||
|
|
||||||
|
- 充值记录页和企业管理页必须共用同一人工充值组件。取消、右上角关闭或完成后必须销毁未提交金额、备注、预检结果和幂等键;重新打开必须是新草稿,不得自动恢复资金操作输入。
|
||||||
|
- 最终入账前必须调用真实后端预检并重复展示企业名称、编码、唯一ID、操作方向、当前现金余额、本次变动、预计现金余额和备注;余额以PostgreSQL账户读取结果为准,不能用前端静态计算冒充资格检查。
|
||||||
|
- 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。
|
||||||
|
- RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。
|
||||||
|
|
||||||
|
## 2026-07-22 UI/UX A7公共Dialog契约
|
||||||
|
|
||||||
|
- 所有公共Dialog打开后必须把焦点送入弹窗,并将Tab/Shift+Tab约束在当前顶层弹窗;背景内容必须同时不可聚焦、不可被辅助技术读取,页面滚动必须锁定。
|
||||||
|
- Dialog必须通过`aria-labelledby`关联可见标题;遮罩不得伪装成可聚焦关闭按钮。右上角、取消、Escape和遮罩关闭必须使用同一关闭协议,关闭后焦点返回触发控件。
|
||||||
|
- 可编辑弹窗必须声明dirty状态。存在未保存内容时,任何关闭入口都必须先显示具名确认层;继续编辑保留草稿并恢复原焦点,只有明确放弃后才能销毁草稿。
|
||||||
|
- 确认层作为顶层`alertdialog`管理焦点并隔离父弹窗;手机端按钮应安全堆叠,320—1440px内不得产生页面级横向溢出。
|
||||||
|
|
||||||
|
## 2026-07-22 UI/UX A2/A3收口补充
|
||||||
|
|
||||||
|
- 客户端上传必须由可见页面操作触发客户端专用接口;租户只能从当前会话用户解析,上传用途、对象目录和租户内下载均由后端校验,真实对象必须写入配置的MinIO并可回读。
|
||||||
|
- 企业认证上传区在手机端必须完整显示长文件名;步骤条允许安全横向浏览且默认展示第一步,省市选择不得因固定宽度被裁切。
|
||||||
|
- 客户端日志导出必须显示提交中、完成数量、操作单号、下载和失败重试;客户端CSV仅允许时间、级别、模块、操作人、动作、资源ID六列,不得包含详情、IP或嵌套内部字段。
|
||||||
|
- 签名和模板审核通过/驳回必须共用资格预检、状态版本、幂等键、事务审计和结构化结果协议。页面必须在最终决定前显示对象唯一标识、资格和影响范围;取消确认不得改变状态或写审计。
|
||||||
|
|||||||
@@ -1568,6 +1568,32 @@
|
|||||||
- 被选候选状态变为 `claimed`,其他 pending 候选变为 `rejected`,认领动作写入操作日志。
|
- 被选候选状态变为 `claimed`,其他 pending 候选变为 `rejected`,认领动作写入操作日志。
|
||||||
- 认领后创建真实 `CmppDownstreamDelivery(deliveryType=uplink)`,并按现有下游投递链路在线推送或离线保留重试。
|
- 认领后创建真实 `CmppDownstreamDelivery(deliveryType=uplink)`,并按现有下游投递链路在线推送或离线保留重试。
|
||||||
|
|
||||||
|
### TC-GW-028 下游人工重投并发认领与中断恢复
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:真实 PostgreSQL、NestJS API 和 Gateway 控制面可用;准备一条 failed/pending 下游投递记录。
|
||||||
|
- 步骤:
|
||||||
|
1. 两个运营请求同时重投同一记录,并让 Gateway pending 恢复扫描同时运行。
|
||||||
|
2. 检查数据库状态、人工次数和 Gateway 控制面调用次数。
|
||||||
|
3. 另构造一条停留在 `manual_requeueing` 且超过恢复阈值的记录,运行后台扫描。
|
||||||
|
- 预期结果:
|
||||||
|
- `id + status + updatedAt` 条件更新只允许一个运营请求认领;另一个返回明确冲突。
|
||||||
|
- 认领期间状态为 `manual_requeueing`,不进入 Gateway 的 pending 拉取结果,同一轮只调用一次控制面。
|
||||||
|
- 人工次数只增加一次;进程中断的陈旧认领自动恢复为 pending,随后由 Gateway 单一路径补投。
|
||||||
|
|
||||||
|
### TC-GW-029 Gateway提交异常幂等重入队与宕机恢复
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:真实 PostgreSQL、Redis Stream 和 NestJS API 可用;存在 pending Gateway 提交异常记录。
|
||||||
|
- 步骤:
|
||||||
|
1. 人工重新入队,在 Redis XADD 成功后、数据库写回 requeued 前模拟进程退出。
|
||||||
|
2. 等待陈旧 requeueing 恢复扫描,再重复调用相同幂等发布。
|
||||||
|
3. Gateway 再次上报原始 streamMessageId,并在恢复期间回传 SubmitResult。
|
||||||
|
- 预期结果:
|
||||||
|
- 恢复和重复调用返回同一个 Redis Stream ID,Stream 只有一个 SubmitCommand。
|
||||||
|
- 数据库最终为 requeued 或被更早 SubmitResult 闭环为 resolved,人工次数只增加一次。
|
||||||
|
- 重复异常报告不得把 requeued/resolved 回退为 pending,迟到恢复不得覆盖 resolved。
|
||||||
|
|
||||||
### TC-SEND-021 优先队列插队发送
|
### TC-SEND-021 优先队列插队发送
|
||||||
|
|
||||||
- 优先级:P0
|
- 优先级:P0
|
||||||
@@ -2090,6 +2116,32 @@
|
|||||||
- 定时任务详情展示计划发送时间、创建时间、创建人、号码总数、预估费用、当前状态。
|
- 定时任务详情展示计划发送时间、创建时间、创建人、号码总数、预估费用、当前状态。
|
||||||
- 到点执行后的状态变化在客户端和运营端一致。
|
- 到点执行后的状态变化在客户端和运营端一致。
|
||||||
|
|
||||||
|
### TC-SCHEDULE-007 多实例自动调度和原子认领
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:启动两个连接同一 PostgreSQL/Redis 的 API 实例,存在一条已到期 scheduled 任务。
|
||||||
|
- 步骤:
|
||||||
|
1. 不调用管理端手工派发接口,等待自动扫描周期。
|
||||||
|
2. 两个实例同时扫描同一任务。
|
||||||
|
3. 查询任务、冻结流水、短信记录和 BullMQ 作业。
|
||||||
|
- 预期结果:
|
||||||
|
- 到期任务自动进入 queued/sending。
|
||||||
|
- 仅一个实例原子认领成功。
|
||||||
|
- 每条短信只存在一个以消息记录 ID 为 jobId 的队列作业,余额只冻结一次。
|
||||||
|
|
||||||
|
### TC-SCHEDULE-008 调度中断、0 元任务和超时恢复
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:存在普通计费任务和客户单价为 0 的免费任务,调度认领超时阈值可缩短用于测试。
|
||||||
|
- 步骤:
|
||||||
|
1. 分别在冻结后、短信转 queued 后和部分作业入队后模拟进程退出或 Redis 不可用。
|
||||||
|
2. 恢复 API/Redis 并等待认领超时后再次扫描。
|
||||||
|
3. 重复执行恢复扫描。
|
||||||
|
- 预期结果:
|
||||||
|
- 陈旧的 scheduled_dispatching/scheduled_recovering 任务能够被唯一重新认领并完成入队。
|
||||||
|
- 已存在冻结流水时不重复冻结;0 元任务入队失败时保留可恢复状态而非错误终结。
|
||||||
|
- BullMQ jobId 幂等阻止重复作业,最终任务和消息状态一致。
|
||||||
|
|
||||||
### TC-LOG-001 登录和登出日志
|
### TC-LOG-001 登录和登出日志
|
||||||
|
|
||||||
- 优先级:P1
|
- 优先级:P1
|
||||||
@@ -3553,3 +3605,89 @@ npm run verify:phase8
|
|||||||
| TC-UIUX-P0-003 | 在375视口点击删除,读取确认内容后点击取消。 | 确认层显示目标用户名;取消后用户仍在列表,PostgreSQL记录保持active且没有执行删除。 |
|
| TC-UIUX-P0-003 | 在375视口点击删除,读取确认内容后点击取消。 | 确认层显示目标用户名;取消后用户仍在列表,PostgreSQL记录保持active且没有执行删除。 |
|
||||||
| TC-UIUX-P0-004 | 依次设置768×1024、1366×768、1440×900并复核同一用户。 | 平板四项操作全部可见且热区≥44px;1440首屏完整;1366即使存在内部横向滚动,滚动后删除必须完整可达,且页面级无横向溢出。 |
|
| TC-UIUX-P0-004 | 依次设置768×1024、1366×768、1440×900并复核同一用户。 | 平板四项操作全部可见且热区≥44px;1440首屏完整;1366即使存在内部横向滚动,滚动后删除必须完整可达,且页面级无横向溢出。 |
|
||||||
| TC-UIUX-P0-005 | 完成五视口操作后检查浏览器控制台并执行前端/API构建与用户服务回归。 | 无新增console error/warn;前端与API build通过,用户服务测试通过,`git diff --check`通过。 |
|
| TC-UIUX-P0-005 | 完成五视口操作后检查浏览器控制台并执行前端/API构建与用户服务回归。 | 无新增console error/warn;前端与API build通过,用户服务测试通过,`git diff --check`通过。 |
|
||||||
|
|
||||||
|
### 17.17 2026-07-21 下游连接恢复与历史回执回填
|
||||||
|
|
||||||
|
| 用例编号 | 操作 | 预期结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-CMPP-DOWNSTREAM-NULL-001 | 创建一条 `status=connected`、`lastHeartbeatAt=NULL` 且 `connectedAt` 早于心跳阈值的客户接入连接,再触发新连接登记。 | 陈旧记录在连接数校验前删除,新连接不被错误的 `cmppMaxConnections` 拒绝;Gateway 能继续读取 Submit。 |
|
||||||
|
| TC-CMPP-DOWNSTREAM-NULL-002 | 创建一条刚建立、`lastHeartbeatAt=NULL` 但 `connectedAt` 尚未超过阈值的连接并执行清理。 | 新连接保留,不因首次心跳尚未写入而误删。 |
|
||||||
|
| TC-RECEIPT-BACKFILL-001 | 构造两个供应商通道共用账号,历史 DELIVRD 被写到错误通道,但同一主记录、Msg_Id、号码只有一条匹配提交记录,执行迁移两次。 | 回执改绑到唯一提交通道;主记录同步为 delivered 并写入回执状态、原始码、通道消息号和到达时间;重复执行结果不变。 |
|
||||||
|
| TC-RECEIPT-BACKFILL-002 | 构造同一历史回执能匹配零条或多条提交记录的歧义样本。 | 迁移不修改回执和主记录,保留人工核查,不以账号或模糊 Msg_Id 强行归属。 |
|
||||||
|
| TC-REPORT-BACKFILL-001 | 历史回执迁移后执行 T-4 至 T-1 重算,查询对账、利润、质量及 CSV。 | 历史成功数、收入、成本、利润和平均到达时长反映修复后的主记录与提交/回执;重复重算一致。 |
|
||||||
|
| TC-DICTIONARY-DUPLICATE-001 | 对活动或逻辑删除的全局/企业黑名单重复创建相同手机号。 | 后端返回 HTTP 409、`BLACKLIST_DUPLICATE` 和 `phoneNumber` 字段提示,不返回 500,也不创建重复数据。 |
|
||||||
|
|
||||||
|
### 17.18 双门户会话隔离、深链与锁定恢复
|
||||||
|
|
||||||
|
| 用例编号 | 操作 | 预期结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-SESSION-DEEPLINK-001 | 未登录直接打开客户端13条受保护路由,完成验证码登录后逐条刷新。 | 登录页说明将恢复目标;登录后返回原深链;13条路由刷新后 URL 和客户端身份不变,数据来自真实 API。 |
|
||||||
|
| TC-SESSION-PORTAL-001 | 在同一浏览器先后登录运营端与客户端,并分别访问受保护页面。 | 浏览器同时持有独立 admin/client Cookie 和 localStorage;两端显示各自身份,不互相覆盖或错跳门户。 |
|
||||||
|
| TC-SESSION-LOGOUT-001 | 两端同时登录时退出客户端,再刷新运营端;反向重复。 | 只删除和广播当前门户会话;另一门户会话、页面与 Redis 记录继续有效。 |
|
||||||
|
| TC-SESSION-LOCK-001 | 锁定运营端会话,同时请求客户端当前会话;刷新锁定页面并输入当前密码解锁。 | 运营端返回 locked、客户端仍 active;锁定页说明解锁后返回当前页;解锁轮换当前门户 Cookie,原 URL 与业务数据恢复。 |
|
||||||
|
| TC-SESSION-COOKIE-001 | 同时携带 admin/client Cookie 请求两端接口,并仅携带错误门户 Cookie 重试。 | 中间件只读取路径对应 Cookie;错误门户 Cookie 返回401且不会尝试认证或泄露另一门户状态。 |
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A2安全上传与日志导出用例
|
||||||
|
|
||||||
|
- `TC-UIUX-A2-UPLOAD-001`:客户端带伪造`x-tenant-id`上传允许用途文件,数据库`FileObject.tenantId`仍等于当前会话用户企业,MinIO对象可经客户端下载接口读回。
|
||||||
|
- `TC-UIUX-A2-UPLOAD-002`:任意用途、目录穿越和跨企业文件下载分别返回4xx,且拒绝发生在对象存储写入前。
|
||||||
|
- `TC-UIUX-A2-EXPORT-001`:两端按当前筛选导出真实日志,按钮在请求期间禁用;成功显示记录数、截断提示、操作单号和下载入口,重复点击不产生并发请求。
|
||||||
|
- `TC-UIUX-A2-EXPORT-002`:客户端忽略请求体租户,只导出当前企业;CSV表头不包含详情、IP、User-Agent,危险公式前缀被转义。
|
||||||
|
- `TC-UIUX-A2-EXPORT-003`:导出失败后页面保留筛选并提供原地重试;会话跳转恢复后可读取当前门户专用瞬时恢复条件,不错跳另一门户。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A3审核风险治理用例
|
||||||
|
|
||||||
|
- `TC-UIUX-A3-REVIEW-001`:待审签名缺应用、企业资料或资质文件时,预检返回具体`blockedReasons`,`allowedActions`不含`approve`,直接提交批准同样返回4xx。
|
||||||
|
- `TC-UIUX-A3-REVIEW-002`:完整待审签名/模板打开通过确认层,展示名称、ID、企业、应用、资格结果和影响;取消不改变数据库状态。
|
||||||
|
- `TC-UIUX-A3-REVIEW-003`:确认后按钮立即进入提交中并禁止重复点击;成功返回审计操作单号,AuditRecord记录当前会话审核人、前后状态和幂等标识。
|
||||||
|
- `TC-UIUX-A3-REVIEW-004`:相同幂等键重复请求返回同一操作单号及`replayed=true`,数据库只有一次状态变更;相同键用于不同决定返回409。
|
||||||
|
- `TC-UIUX-A3-REVIEW-005`:两个审核员使用同一`expectedUpdatedAt`并发决策,仅一个`updateMany`成功,另一个返回`REVIEW_VERSION_CONFLICT`且不得覆盖赢家。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A4报备生成风险治理用例
|
||||||
|
|
||||||
|
- `TC-UIUX-A4-REPORT-001`:已审核资料未绑定应用、应用停用或没有启用路由时调用预检;返回`eligible=false`和具体阻断原因,列表复选框及生成按钮不可用。
|
||||||
|
- `TC-UIUX-A4-REPORT-002`:应用路由到启用通道,但通道缺当前资料类型字段或资料缺必填值;预检逐通道返回缺失项,零可生成目标不得创建`ReportMaterialBatch`、任务或文件。
|
||||||
|
- `TC-UIUX-A4-REPORT-003`:完整资料打开生成确认层;显示企业、应用、资料版本、预计通道、运营商及成功/跳过计数,取消后数据库无批次、任务和文件变化。
|
||||||
|
- `TC-UIUX-A4-REPORT-004`:相同资料版本、应用、通道和运营商已存在成功批次时再次预检;返回既有批次并跳过。资料版本或路由运营商变化后使用新的业务键重新评估。
|
||||||
|
- `TC-UIUX-A4-REPORT-005`:相同幂等键并发或重试生成同一范围,仅产生一次批次并返回同一操作单;相同键改换资料范围返回409;按钮在请求中禁止重复提交。
|
||||||
|
- `TC-UIUX-A4-REPORT-006`:在1440×900、1366×768、768×1024、390×844和375×667打开确认层;页面无横向溢出,弹窗完整位于视口且成功/跳过/失败、取消和确认操作均可见,控制台无error/warn。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A5删除治理用例
|
||||||
|
|
||||||
|
- `TC-UIUX-A5-DELETE-001`:活动通道组引用通道时打开删除确认层;真实预检返回引用数量、组名和优先级,`allowedActions`为空,前后端均禁止删除且通道状态不变。
|
||||||
|
- `TC-UIUX-A5-DELETE-002`:签名仍被未删除模板、引流信息或未结束报备任务引用;运营端和客户端均显示租户内依赖摘要并禁止删除,客户端不能读取其他企业对象。
|
||||||
|
- `TC-UIUX-A5-DELETE-003`:模板存在未结束发送或批量任务时阻断;无依赖模板填写原因后逻辑删除,返回操作单号,PostgreSQL状态为`deleted`且OperationLog包含原因、依赖、影响和幂等键。
|
||||||
|
- `TC-UIUX-A5-DELETE-004`:相同删除幂等键重试返回相同操作单号且不重复审计;旧版本并发提交返回409并要求重新预检;旧删除接口不能绕过治理规则。
|
||||||
|
- `TC-UIUX-A5-DELETE-005`:在1440×900、1366×768、768×1024、390×844和375×667打开依赖确认层;对象、依赖、影响和底部操作可滚动到达,无页面级横向溢出,控制台无error/warn。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A6人工充值治理用例
|
||||||
|
|
||||||
|
- `TC-UIUX-A6-RECHARGE-001`:两个人工充值入口分别输入金额和备注后点击取消、右上角关闭,再次打开;金额、备注、预检和幂等状态均为空,不产生订单、流水或余额变化。
|
||||||
|
- `TC-UIUX-A6-RECHARGE-002`:输入正数或负数金额进入核对;后端返回企业名称/编码/ID、当前余额、方向、变动和预计余额,页面完整展示,返回修改不入账。
|
||||||
|
- `TC-UIUX-A6-RECHARGE-003`:最终确认后只生成一个RechargeOrder、一个AccountTransaction和一个OperationLog,账户余额等于前余额加变动金额;响应显示订单号、后余额和操作单号,操作者来自当前会话。
|
||||||
|
- `TC-UIUX-A6-RECHARGE-004`:相同幂等键并发或重试同一请求返回相同订单与操作单且`replayed=true`;键用于不同企业/金额返回409。预检后账户版本变化,旧确认请求返回409且不产生部分数据。
|
||||||
|
- `TC-UIUX-A6-RECHARGE-005`:在1440×900、1366×768、768×1024、390×844和375×667打开核对层;资金摘要、返回和最终确认可滚动到达,控制台无error/warn。
|
||||||
|
|
||||||
|
## 2026-07-22 UI/UX A7公共Dialog验收用例
|
||||||
|
|
||||||
|
| 用例ID | 场景 | 验收标准 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A7-DIALOG-001 | 打开代表Dialog | 焦点进入弹窗;dialog由可见标题命名;背景具备inert/aria-hidden;body滚动锁定;遮罩不可聚焦 |
|
||||||
|
| A7-DIALOG-002 | 主Dialog键盘循环 | 最后一个可用控件按Tab回到第一个,首控件按Shift+Tab回到最后一个,焦点不进入背景 |
|
||||||
|
| A7-DIALOG-003 | dirty表单按Escape/取消/关闭/遮罩 | 四种入口均打开具名alertdialog,不销毁已输入草稿,父Dialog不可交互 |
|
||||||
|
| A7-DIALOG-004 | dirty确认层键盘与返回 | 确认层Tab循环;按Escape或继续编辑后确认层关闭、草稿保留、焦点返回原字段 |
|
||||||
|
| A7-DIALOG-005 | 明确放弃 | 两层弹窗关闭、草稿销毁、背景隔离和滚动锁恢复、焦点返回原触发按钮 |
|
||||||
|
| A7-DIALOG-006 | 两端五视口视觉回归 | 运营与客户端真实登录态页面在1440×900、1366×768、768×1024、390×844、375×667无裁切或横向溢出,控制台无业务error/warn |
|
||||||
|
|
||||||
|
## 2026-07-22 UI/UX A2/A3收口用例
|
||||||
|
|
||||||
|
| 用例ID | 场景 | 验收标准 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A23-UPLOAD-001 | 客户端企业认证页选择文件 | 请求进入`/api/client/files/upload`;FileObject租户来自会话;MinIO对象字节数与上传文件一致并可回读 |
|
||||||
|
| A23-UPLOAD-002 | 伪造租户、非法用途/目录、跨租户下载 | 伪造租户不生效;非法用途或目录在对象存储写入前拒绝;跨租户文件返回404 |
|
||||||
|
| A23-UPLOAD-003 | 上传成功五视口反馈 | 1440×900、1366×768、768×1024、390×844、375×667均显示完整文件名;页面无横向溢出,步骤一可见,省市选择不裁切 |
|
||||||
|
| A23-LOG-001 | 客户端真实筛选导出 | 页面显示完成数量、操作单号和下载入口;重复点击期间按钮锁定,失败时原地重试且不跳门户 |
|
||||||
|
| A23-LOG-002 | 客户端CSV字段安全 | 表头仅六列;任意OperationLog详情、来源IP、供应商或内部字段均不得进入导出内容 |
|
||||||
|
| A23-REVIEW-001 | 签名/模板点击通过 | 首先显示对象名、唯一ID、企业、应用、资格检查和影响;没有最终确认不得改变pending状态或写AuditRecord |
|
||||||
|
| A23-REVIEW-002 | 审核并发与幂等 | 使用pending+updatedAt条件更新;同键重放返回同一操作单且仅一条审计;版本变化返回冲突 |
|
||||||
|
| A23-REVIEW-003 | 审核确认层五视口 | 签名确认层截图覆盖五视口;模板覆盖桌面截图和390px DOM尺寸测量;内容与按钮可达、无横向溢出、console无业务error/warn |
|
||||||
|
|||||||
@@ -2060,3 +2060,107 @@ git diff --check
|
|||||||
- 生产运行提交为`f02c33cbb7248410c189f75502d6e497fff7b355`。`cmpp-gateway`、`cmpp-api`、Nginx、PostgreSQL、Redis和MinIO均active,`12026/17890/8090/3000/9000`监听;API/Gateway health、Redis PONG、PostgreSQL readiness、外部首页、运营登录、客户端登录、API health和Swagger JSON均通过,公网CMPP `8.160.169.106:17890`可连接。根目录/API生产依赖audit均为0漏洞。
|
- 生产运行提交为`f02c33cbb7248410c189f75502d6e497fff7b355`。`cmpp-gateway`、`cmpp-api`、Nginx、PostgreSQL、Redis和MinIO均active,`12026/17890/8090/3000/9000`监听;API/Gateway health、Redis PONG、PostgreSQL readiness、外部首页、运营登录、客户端登录、API health和Swagger JSON均通过,公网CMPP `8.160.169.106:17890`可连接。根目录/API生产依赖audit均为0漏洞。
|
||||||
- 两个active上游通道均为`connected/currentConnections=1`,权威TPS配置已恢复;Redis Stream `gateway.submit.commands` consumer group为`pending=0、lag=0`。部署后API/Gateway error级日志均为0。未发送或重投真实短信,未执行充值、审核、删除或生产业务数据修改。
|
- 两个active上游通道均为`connected/currentConnections=1`,权威TPS配置已恢复;Redis Stream `gateway.submit.commands` consumer group为`pending=0、lag=0`。部署后API/Gateway error级日志均为0。未发送或重投真实短信,未执行充值、审核、删除或生产业务数据修改。
|
||||||
- 本地Browser五视口LG2-P0-01证据已通过;生产浏览器烟测被企业网络策略禁止访问该公网HTTP地址,未使用其他浏览器或自动化方式绕过。生产外部HTTP/TCP、真实服务、数据库、Redis和运行产物均已核验,但本次不把生产浏览器交互标记为通过。
|
- 本地Browser五视口LG2-P0-01证据已通过;生产浏览器烟测被企业网络策略禁止访问该公网HTTP地址,未使用其他浏览器或自动化方式绕过。生产外部HTTP/TCP、真实服务、数据库、Redis和运行产物均已核验,但本次不把生产浏览器交互标记为通过。
|
||||||
|
|
||||||
|
## 2026-07-21 北向连接名额、历史回执与报表回填复查(未提交、未部署)
|
||||||
|
|
||||||
|
- 生产只读复查确认北向普通/长短信无 `SUBMIT_RESP` 的直接阻塞发生在企业应用下游接入侧:账号 `695829` 的旧连接登记为 `connected`,但 TCP 和 Redis 均无对应在线连接;`lastHeartbeatAt=NULL` 使既有 `lastHeartbeatAt < cutoff` 条件永远不成立,陈旧记录持续占用 `cmppMaxConnections`,新连接在业务层登记时被关闭,Submit 因而未被 Gateway 读取。经用户授权只清理该条已确认无真实连接的陈旧登记,未修改账号、应用或短信数据,连接名额恢复为 0;仍须部署本轮代码后重新做生产普通、长短信和多号码端到端验证。
|
||||||
|
- `markTimedOutDownstreamConnections` 已增加安全的 NULL 心跳清理:仅当 `lastHeartbeatAt IS NULL` 且 `connectedAt` 也早于超时窗口时才删除,避免误清刚建立但首个心跳尚未到达的连接。回归测试先在旧实现上失败,修复后 `sms-config.service.spec.ts` 46 项全部通过。
|
||||||
|
- 生产历史数据只读核对找到 3 条“已有唯一 DELIVRD、主记录仍 submitted”的旧记录,均为同一上游账号复用两个通道时历史回执 `channelId` 归属反转;2026-07-21 新回执已按唯一提交记录正确归属并聚合,说明当前实时匹配代码已生效,遗留缺口是旧数据回填而不是继续发生的实时抢占。
|
||||||
|
- 新增 migration `20260721150000_backfill_misattributed_delivery_receipts`:仅在“同一内部消息、同一 Gateway Msg_Id、同一目的号码恰好只有一条提交记录”时修正回执通道并把 DELIVRD 聚合到短信主记录;零匹配或多匹配保持原样,避免猜测修复。迁移可重复执行;本地真实 PostgreSQL 构造同账号双通道错归属样本后应用及重放均通过,主记录变为 `delivered`,回执状态、原始码/文本、到达时间、通道和通道消息号一致。
|
||||||
|
- 在上述真实样本上执行既有 T-4 至 T-1 报表重算,发送数/成功数/失败数为 `1/1/0`,利润成功数为 1,质量成功率为 100%,平均到达时长为 5000ms;证明 7 月 18、19 日历史报表缺口应按“先迁移回填主记录,再重算对应日期”处理。生产尚未应用 migration 或重算,历史页面当前仍会保持旧结果。
|
||||||
|
- HTTP 正向发送旧结论经 2026-07-21 较新生产证据纠正:仅传 `mobile/content` 的合法请求已返回 202、自动关联正确签名和模板、返回 MessageId,并通过真实路由和计费;不能再归类为当前未修复。重复用户名生产接口也已返回 409。黑名单 P2002 映射代码原已存在,本轮补充全局/企业及逻辑删除占用的 `BLACKLIST_DUPLICATE` 409 回归测试。
|
||||||
|
- 完整验证:API 21 suites/240 tests、Gateway `go test ./...`、API build、前端 TypeScript/Vite build、Prisma generate/validate/status(58 条 migration,schema 最新)均通过;前端仅有既有约 1.9MB chunk warning,Jest 仍需 `--forceExit` 结束既有异步句柄。`git diff --check` 在文档收尾后另行复核。
|
||||||
|
- `npm run verify:phase8` 的契约和 Gateway 阶段通过,但共享 Redis 的 BullMQ 15,000 条/500 并发结果为 enqueue 3577.89 TPS、端到端 431.29 TPS,未达到 500 TPS,因此完整命令未通过;临时隔离 Redis 同参数端到端为 567.37 TPS 并通过。该项按共享环境性能阻塞记录,不把共享 Redis 结果标为通过。
|
||||||
|
- 本地正式链路启动真实 NestJS API、PostgreSQL、Redis 和 Go Gateway:API `/api/health`、Gateway `/health` 均为 `ok`,Redis `PONG`,Gateway 监听 `127.0.0.1:7890` 且恢复候选数为 0;验收后精确停止本轮 3000/8090/7890 端口进程。未向生产号码发送短信,未部署、未提交、未 push。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A1双门户会话隔离与深链恢复(未提交、未部署)
|
||||||
|
|
||||||
|
- 修复`LG2-P1-01/02/03/05`:前端会话存储、DOM事件和BroadcastChannel按`admin/client`命名空间隔离;NestJS使用`cmpp_admin_session/cmpp_client_session`(安全Cookie环境使用对应`__Host-`名称),中间件按目标门户路径只读取对应Cookie;所有touch/lock/unlock/reauthenticate/logout/password接口改为门户专用路径。
|
||||||
|
- 新增真实会话初始化边界和安全returnUrl:受保护路由先读取后端当前会话,未登录时保存同源、同门户白名单目标;登录后回原深链。当前会话接口只返回安全用户DTO和时序状态,不输出令牌。锁定刷新时暂停首次业务路由和运营看板请求,解锁后原URL重新挂载真实数据;当前标签已加载的路由不会被另一门户广播卸载。
|
||||||
|
- 自动化:认证会话2 suites/10 tests、API TypeScript build、前端TypeScript/Vite build及`git diff --check`通过;项目没有前端lint/组件测试脚本,未虚报。前端仍有既有约1.90MB大chunk警告。本批无Prisma schema变更。
|
||||||
|
- 真实链路:本地PostgreSQL建立隔离验收用户,Redis真实保存不透明会话;同一Cookie容器同时得到`cmpp_admin_session,cmpp_client_session`。锁定admin时client仍active,admin解锁轮换Cookie,client退出后admin当前会话仍返回`a1_admin`。
|
||||||
|
- 浏览器:客户端13/13受保护路由逐条打开和刷新均保持目标URL及客户端身份,console error/warn为0;同浏览器双门户并存、客户端退出后运营端刷新继续有效。运营用户页在1440×900、1366×768、768×1024、390×844、375×667实际视口均显示真实用户且console为0;390×844锁定、刷新、解锁后仍在`/admin/users`且真实用户重新出现,锁定阶段console为0。证据在测试项目`平台LG_UIUX二轮走查证据/A1会话隔离-20260721/`。
|
||||||
|
- 未修改生产业务数据,未发送短信、充值、审核、删除或报备;代码未提交、未push、未部署,生产仍运行旧会话实现。生产发布后旧共享Cookie需要重新登录,故台账暂记“待验证”。本地临时API由本会话启动,收尾时精确停止;PostgreSQL/Redis/既有前端进程不属于本会话,不停止。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A2客户端安全上传与日志导出(未提交、未部署)
|
||||||
|
|
||||||
|
- `LG2-P1-04`改为客户端专用上传/下载接口:租户从当前会话用户反查,不再信任`x-tenant-id`;企业认证、签名报备、引流报备用途和目录白名单由后端强制,跨企业下载返回404。前端客户端预览/下载也不再调用admin端点。
|
||||||
|
- `LG2-P1-06`新增两端共用日志导出状态组件:提交中防重复、完成数量/截断提示/操作单号、CSV下载、失败原地重试,并按门户在`sessionStorage`仅保存瞬时恢复筛选。后端从PostgreSQL真实筛选导出;客户端租户由会话反查且CSV只保留时间、级别、模块、操作人、动作、资源ID,另有10000条上限和公式注入防护。
|
||||||
|
- 自动化通过:文件/运营服务2 suites、24 tests,API TypeScript build,前端TypeScript/Vite build,Prisma validate/migrate status(本地58条、schema最新)和`git diff --check`。项目仍无前端lint/组件测试脚本;前端仍有既有约1.9MB chunk告警。
|
||||||
|
- 真实链路:本地NestJS、PostgreSQL、Redis和MinIO在线;客户端真实验证码登录后,携带伪造租户头上传仍落当前企业`a2-local-tenant`,MinIO对象经客户端接口读回55字节。客户端日志导出真实返回安全6列表头、5条记录;Browser在1440×900点击导出显示3条和操作单号`2aa2521d-111d-48a6-87c2-27cdbcf680be`。本轮未完成console专项读取,不虚报console通过。
|
||||||
|
- Browser上传因隐藏file input点击超时未完成页面级验收;1366×768、768×1024、390×844、375×667截图也尚未补齐,因此两项台账均保持“待验证”,不标记已通过。未改生产数据,未提交、未push、未部署;并行会话的短信配置、字典、回执migration及其文档未改动。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A3审核通过风险治理(未提交、未部署)
|
||||||
|
|
||||||
|
- 新增独立审核治理服务和`RiskAction`组件,签名/模板“通过”从直接终态调用改为“后端资格预检→对象/影响确认→提交中锁定→结构化结果”。签名预检覆盖应用绑定、公司/信用代码、法人、责任人/手机号和资质文件;模板覆盖内容、签名绑定及签名审核状态。
|
||||||
|
- 后端从当前会话写审核人,使用`pending + updatedAt`条件更新和Serializable事务阻止并发覆盖;AuditRecord ID作为操作单号,幂等键随审计持久化,同键重试返回原结果。旧批准路由也统一进入治理服务。未修改并行会话占用的`sms-config.service.ts/spec.ts`。
|
||||||
|
- 自动化通过:审核治理1 suite/5 tests、API TypeScript build、前端TypeScript/Vite build、Prisma validate及`git diff --check`。前端仍有既有约1.91MB chunk告警;Jest断言通过后仍需`--forceExit`结束既有异步句柄。
|
||||||
|
- 真实本地API/PostgreSQL:完整签名预检为`approve,reject`且0阻断,首次批准写入操作单号`cmruefurl0002msyuftczvswt`并变为approved;相同幂等键重放返回同一单号和`replayed=true`。验收企业、应用、签名、审核记录、用户和日志已精确清理。
|
||||||
|
- Browser实际打开运营登录页并读取验证码,但登录后受A1未提交会话链的“登录会话已失效”恢复提示阻断,未打开A3确认层;console专项返回空数组。未改生产数据。驳回统一协议、有限撤销/双人复核和五视口页面证据未完成,因此台账记“部分通过”,代码未提交、未push、未部署。
|
||||||
|
|
||||||
|
## 2026-07-21 定时短信自动派发与多实例幂等(未提交、未部署)
|
||||||
|
|
||||||
|
- 根因:到期任务此前只有 `POST /api/admin/send/scheduled/dispatch-due` 手工入口,API 启动后没有自动扫描;派发前也没有数据库条件更新认领,多实例同时扫描会重复冻结和入队。
|
||||||
|
- API 启动后默认 1 秒首次扫描、每 5 秒继续扫描,可通过 `SMS_SCHEDULED_DISPATCH_SCAN_ENABLED` 和 `SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS` 配置;进程内重入保护避免同一实例扫描重叠。
|
||||||
|
- 派发使用 `id + status (+ stale updatedAt)` 的 `updateMany` 条件更新原子认领。正常任务进入 `scheduled_dispatching`;超过默认 2 分钟的陈旧认领在 `scheduled_dispatching/scheduled_recovering` 间交替认领,保证多实例仅一个恢复者胜出。
|
||||||
|
- 恢复时按 `tenantId + transactionType=frozen + relatedType=sms_batch_task + relatedId` 查询既有冻结流水,存在则不再冻结;BullMQ 继续使用消息记录 ID 作为 jobId。0 元任务在资源和余额检查通过后若 Redis 入队失败,也保留调度中状态等待恢复,不会被错误终结。
|
||||||
|
- 回归测试先覆盖旧代码失败,再完成实现;`send-chain.service.spec.ts` 共 67/67 通过,新增自动启动扫描、并发扫描唯一认领、陈旧认领恢复不重复冻结和 0 元任务入队失败可恢复用例。API TypeScript build 通过。
|
||||||
|
- 该 Jest 套件断言约 24 秒完成,但仍需 `--forceExit` 结束仓库既有 BullMQ/Redis 异步句柄;未把句柄问题标记为通过。本批没有 Prisma schema/migration 变化,尚未执行真实 PostgreSQL/Redis 双实例故障注入,留待本地链路总验收。
|
||||||
|
- 代码和文档均未提交、未 push、未部署;生产仍需发布后验证无需手工接口即可自动派发到期任务。
|
||||||
|
|
||||||
|
## 2026-07-21 下游人工重投与Gateway提交异常恢复(未提交、未部署)
|
||||||
|
|
||||||
|
- 根因一:`requeueDownstreamDelivery` 原先先读取再普通 `update`,没有状态版本条件;并发请求可重复递增人工次数并多次调用 Gateway。若直接把认领结果写为 pending,Gateway 恢复扫描还可能与同步控制面调用形成双发窗口。
|
||||||
|
- 下游人工重投改为 `id + status + updatedAt` 的 `updateMany` 原子认领,认领态为 `manual_requeueing`;Gateway pending 查询不会选中该状态。成功写出后进入 `awaiting_ack`,失败走既有明确状态机;超过默认 2 分钟的陈旧认领自动恢复为 pending,阈值可通过 `CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS` 配置。
|
||||||
|
- 根因二:Gateway提交异常虽然已有 pending→requeueing 抢占,但 Redis XADD 成功、数据库更新 requeued 失败时会永久停留 requeueing;直接重放又可能产生第二条 SubmitCommand。重复异常上报还会无条件把已解决记录重置为 pending。
|
||||||
|
- 提交异常重入队现在使用 `gateway:submit:requeue:{deadLetterId}:{nextAttempt}` 稳定键和 Redis Lua,原子执行幂等检查、XADD 和 30 天 Stream ID 保存;陈旧 requeueing 由后台扫描抢占为 requeue_recovering,并复用同一键完成数据库落账。SubmitResult 可从 pending/requeueing/requeue_recovering/requeued 任一在途状态直接闭环 resolved,恢复不会覆盖 resolved;重复上报只更新失败详情,不回退处理状态。
|
||||||
|
- 回归测试先在旧实现失败,修复后 `send-chain.service.spec.ts` 71/71 通过;新增重复异常上报不回退、陈旧提交重入队恢复、下游并发唯一认领和陈旧人工认领恢复用例。API TypeScript build 通过;Jest 仍需 `--forceExit` 结束仓库既有 BullMQ/Redis异步句柄。
|
||||||
|
- 真实 Redis 验证:同一幂等键并发发布两次返回相同 Stream ID,Stream entry 数为 1,幂等键 TTL 为 2592000 秒;测试 Stream 和 key 已删除。
|
||||||
|
- 真实 PostgreSQL 并发验证:两个请求在读取同一版本后同时重投,结果为 1 成功、1 冲突,Gateway 控制面仅调用 1 次,`manualRetryCount=1`,成功记录进入 awaiting_ack;陈旧 manual_requeueing 记录自动恢复为 pending。另以真实 PostgreSQL+Redis 验证陈旧提交异常恢复后 status=requeued、人工次数=1、重复幂等发布仍只有一条 Stream entry。所有本地临时企业、应用、投递、异常、日志、Stream 和 key 均已清理。
|
||||||
|
- 本批无 Prisma schema 或 migration 变化,没有修改 Go Gateway。未发送短信,未修改生产数据;代码未提交、未 push、未部署。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A4报备生成资格、幂等与结果治理(未提交、未部署)
|
||||||
|
|
||||||
|
- `LG2-P1-15`后端新增`POST /api/admin/report-materials/batches/preflight`,逐资料/通道检查审核与待报备状态、应用启用、版本、路由、通道状态、字段配置和必填值;非法资料ID返回可读400,0可生成目标在创建批次前返回`REPORT_BATCH_NOT_ELIGIBLE`。
|
||||||
|
- 生成接口要求幂等键,通过PostgreSQL advisory transaction lock认领操作;资料类型/ID/版本/应用/通道/运营商形成持久化业务键。相同请求重放返回原操作单和结果,不同范围复用键返回409;成功响应包含成功、跳过、失败分项及每项阻断原因。
|
||||||
|
- 运营端列表由真实预检控制选择资格,明确显示“待补充”及首个阻断原因;生成前再次预检,确认层展示企业、应用、版本、预计通道、运营商和分项计数,提交中锁定,完成后显示批次与操作单号。
|
||||||
|
- 自动化:`report-materials.service.spec.ts` 7/7、API TypeScript build、前端TypeScript/Vite build、Prisma validate及migrate status(本地58条、schema最新)均通过,`git diff --check`通过。API build首次被并行会话尚未完成的`send-chain.service.ts`变量错误阻断,未修改该文件;对方完成后收尾重试已通过。项目没有独立前端lint/组件测试脚本;前端仍有既有约1.91MB单chunk告警。
|
||||||
|
- 真实API/PostgreSQL:临时已审核但未绑定应用的资料经真实管理员登录、待报备和预检接口返回`eligible=false`、0目标、“未绑定短信应用”;临时企业、用户、签名和日志清理后均为0。没有调用生成接口。
|
||||||
|
- Browser只打开到确认层并取消:完整临时应用、路由、通道和字段显示1个可生成组合;1440×900、1366×768、768×1024、390×844、375×667均无横向溢出且弹窗在视口内,console error/warn为0。截图位于测试项目`平台LG_UIUX二轮走查证据/A4_LG2-P1-15_报备预检_*.png`;所有临时数据清理为0。
|
||||||
|
- 未修改生产业务数据,未生成报备、发送短信、审核、充值或删除生产对象;代码未提交、未push、未部署,生产仍运行旧实现。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A5通道/签名/模板删除治理(未提交、未部署)
|
||||||
|
|
||||||
|
- `LG2-P1-20`新增统一`DeletionGovernanceService`、admin/client预检与删除接口及共享`DeleteRiskAction`。确认层展示对象名称/ID、活动依赖数量和对象摘要、阻断原因、影响范围、可恢复说明、原因、提交态及结构化操作结果;客户端查询由会话企业强制裁剪。
|
||||||
|
- 后端对通道组/路由/活动连接/报备、签名关联模板/引流/报备、模板发送/批量任务做真实依赖检查。删除使用`updatedAt`乐观锁、Serializable事务、幂等键、逻辑删除和OperationLog操作单,旧通道删除及旧签名/模板删除状态入口统一委托治理服务。
|
||||||
|
- 正向真实链路首次暴露`PrismaService.operationLog`代理属性不可配置导致事务客户端访问代理时报500;将属性改为可配置并新增`prisma.service.spec.ts`,修复后同一页面请求成功。真实PostgreSQL模板状态变为`deleted`并写入`governance.delete`日志、原因、依赖、影响和幂等键。
|
||||||
|
- 自动化通过:`deletion-governance.service.spec.ts`与`prisma.service.spec.ts`共2 suites / 7 tests、API TypeScript build、前端TypeScript/Vite build、Prisma migrate status(本地58条、schema最新)和`git diff --check`。项目没有独立前端lint/组件测试脚本;前端仍有既有约1.91MB单chunk告警。
|
||||||
|
- Browser真实页面:通道被活动组引用时,1440×900、1366×768、768×1024、390×844、375×667均展示1项引用和后端阻断原因,确认按钮断言disabled;无依赖模板填写原因后页面列表变空,数据库和审计同步落账。console error/warn为0。证据位于测试项目`平台LG_UIUX二轮走查证据/整改_A5_LG2-P1-20_20260721/`。
|
||||||
|
- 本轮只写入并精确清理本地验收数据,未操作生产对象。代码未提交、未push、未部署;通用Dialog焦点/背景/dirty保护归A7,前端拆包与缓存归C阶段。
|
||||||
|
|
||||||
|
## 2026-07-21 UI/UX A6人工充值草稿、确认与幂等治理(未提交、未部署)
|
||||||
|
|
||||||
|
- `LG2-P1-21`将充值记录页和企业管理页两个入口收敛到共享`ManualRechargeDialog`。取消、右上角关闭和完成都会销毁金额、备注、预检、结果及幂等键;浏览器分别填写`123.45/88.88`和备注后取消/关闭,重开字段均为空。
|
||||||
|
- 新增`POST /api/admin/billing/manual-recharges/preflight`,从真实企业账户返回版本、现金余额、授信、方向和预计余额。确认层显示企业名称/编码/ID、方向、当前余额、变动、预计余额及备注,提交期间防重复,成功显示订单号、余额、操作单和幂等重放状态。
|
||||||
|
- 最终接口由当前会话注入操作者,要求8—128位幂等键和账户版本;advisory transaction lock串行同键请求,`updatedAt`条件更新阻止覆盖新余额。订单、余额增量、账户流水和OperationLog在Serializable事务中原子写入。
|
||||||
|
- 真实API/PostgreSQL:本地临时账户10.0000元,经页面充值1.2345元后为11.2345元;订单、流水、审计各1条。相同幂等键再次调用真实API返回同订单、同操作单、`replayed=true`,三个计数仍各1。充值记录页和企业管理页均回读11.2345元。
|
||||||
|
- Browser五视口1440×900、1366×768、768×1024、390×844、375×667通过;375短屏底部操作可达,console error/warn为0。截图和API日志位于测试项目`平台LG_UIUX二轮走查证据/整改_A6_LG2-P1-21_20260721/`。
|
||||||
|
- 自动化通过:`billing.service.spec.ts` 1 suite / 11 tests、API TypeScript build、前端TypeScript/Vite build、Prisma migrate status(58条、schema最新)及`git diff --check`。项目没有独立前端lint/组件测试脚本;约1.92MB单chunk告警归C阶段性能项。
|
||||||
|
- 仅修改并精确清理本地验收数据,未操作生产充值。代码未提交、未push、未部署;生产仍运行旧实现,通用Dialog焦点/背景隔离/dirty guard继续由A7处理。
|
||||||
|
|
||||||
|
## 2026-07-22 UI/UX A7公共Dialog整改(未提交、未部署)
|
||||||
|
|
||||||
|
- 修复`LG2-P1-23`:公共Modal新增初始焦点、顶层焦点栈、Tab/Shift+Tab约束、背景`inert`/`aria-hidden`、滚动锁、`aria-labelledby`和关闭后焦点恢复;遮罩由可聚焦button改为非交互div。
|
||||||
|
- 新增dirty关闭协议:右上角、取消、Escape和遮罩统一进入具名`alertdialog`;父弹窗暂时inert,继续编辑保留草稿并恢复原字段焦点,明确放弃后才关闭并销毁。人工充值、删除风险动作和两端模板表单接入该协议。
|
||||||
|
- 真实浏览器:本地NestJS/PostgreSQL/Redis会话分别登录运营与客户端;运营企业模板留存1440×900、1366×768、768×1024、390×844、375×667五张截图,客户端模板在1440×900和390×844完成DOM/键盘实测。五视口无横向溢出,主弹窗与确认层焦点循环、草稿保留、背景恢复、触发按钮焦点恢复均通过;两端console error/warn为空。
|
||||||
|
- 验证:前端TypeScript/Vite build、API TypeScript build、Prisma validate/migrate status和`git diff --check`通过。项目无独立前端lint/组件/axe脚本,未虚报;构建仍有既有约1.92MB单chunk告警。
|
||||||
|
- 未创建模板、未执行审核/删除/充值/发送或其他生产业务写入;本地临时用户、企业、日志和Redis会话已精确清理。代码未提交、未push、未部署,生产仍为旧Dialog实现。
|
||||||
|
|
||||||
|
## 2026-07-22 UI/UX A2/A3收口(未提交、未部署)
|
||||||
|
|
||||||
|
- `LG2-P1-04`通过真实Browser文件选择完成客户端企业认证材料上传;PostgreSQL FileObject归属当前会话企业,MinIO对象回读93字节。复验发现并修复手机端文件名、步骤条和省市选择的卡片内裁切,五视口重新截图后文件名完整且页面无横向溢出。
|
||||||
|
- `LG2-P1-06`在客户端系统日志页真实点击导出,五视口均显示5条和操作单号;独立客户端会话API再次导出7条,表头严格为`时间,级别,模块,操作人,动作,资源ID`,测试注入的详情/IP/供应商内部字段均未泄露。
|
||||||
|
- `LG2-P1-14`在运营签名和模板审核页分别打开通过确认层,仅取消时数据库状态仍为pending且审计为0。真实API随后首次批准签名并用相同幂等键重放,两次返回同一操作单`cmrvlgtrq000gakyumhm1iuiv`,重放标志为true且只写一次审核。
|
||||||
|
- 浏览器证据覆盖上传页和日志导出页五视口、签名确认层五视口截图,以及模板确认层1440×900截图和390×844 DOM尺寸测量;两端console error/warn为空。证据位于测试项目`平台LG_UIUX二轮走查证据/整改_A2_A3收口_20260722/`。
|
||||||
|
- 自动化通过:files、operations、review-governance 3 suites / 31 tests;前端build、API build、Prisma validate/status(58条、schema最新)和`git diff --check`。前端仍有约1.92MB单chunk告警,项目无独立前端lint/组件/axe脚本。
|
||||||
|
- 客户端日志列表仍展示内部详情/IP,继续归`LG2-P1-07`,未因导出安全而标记完成。临时数据、MinIO对象和Redis会话已清理;未触碰生产,未提交、未push、未部署。
|
||||||
|
|||||||
+222
-62
@@ -1,11 +1,41 @@
|
|||||||
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session';
|
import {
|
||||||
|
clearSession,
|
||||||
|
currentRouteForPortal,
|
||||||
|
dispatchSessionEvent,
|
||||||
|
getSessionTenantId,
|
||||||
|
hasRecentUserActivity,
|
||||||
|
portalFromPath,
|
||||||
|
readSession,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
requestReauthentication,
|
||||||
|
saveSessionRecovery,
|
||||||
|
type LoginSession,
|
||||||
|
type Portal,
|
||||||
|
} from './session';
|
||||||
import { assertUploadFileSize } from '@/utils/fileUpload';
|
import { assertUploadFileSize } from '@/utils/fileUpload';
|
||||||
|
|
||||||
type RequestOptions = RequestInit & {
|
type RequestOptions = RequestInit & {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
reauthenticationAttempted?: boolean;
|
reauthenticationAttempted?: boolean;
|
||||||
|
suppressSessionRedirect?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeletionTargetType = 'channel' | 'signature' | 'template';
|
||||||
|
export type DeletionDependency = { kind: string; label: string; count: number; items: string[] };
|
||||||
|
export type DeletionPreflight = {
|
||||||
|
type: DeletionTargetType;
|
||||||
|
id: string;
|
||||||
|
expectedUpdatedAt: string;
|
||||||
|
identity: Record<string, string>;
|
||||||
|
dependencies: DeletionDependency[];
|
||||||
|
impacts: string[];
|
||||||
|
blockedReasons: string[];
|
||||||
|
allowedActions: Array<'delete'>;
|
||||||
|
recoverability: { mode: 'soft_delete'; description: string };
|
||||||
|
};
|
||||||
|
export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string };
|
||||||
|
export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean };
|
||||||
|
|
||||||
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
|
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
|
||||||
|
|
||||||
async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||||
@@ -20,6 +50,35 @@ async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
|||||||
|
|
||||||
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
||||||
|
|
||||||
|
type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>;
|
||||||
|
|
||||||
|
function requestPortal(path: string): Portal | undefined {
|
||||||
|
return portalFromPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) {
|
||||||
|
if (!portal) return false;
|
||||||
|
const session = readSession(portal);
|
||||||
|
const body = await readErrorBody(response.clone());
|
||||||
|
if (body.code === 'SESSION_LOCKED' && session) {
|
||||||
|
dispatchSessionEvent(portal, 'locked', { message: body.message });
|
||||||
|
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||||
|
}
|
||||||
|
if (suppressRedirect) return false;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
saveSessionRecovery(portal, {
|
||||||
|
returnUrl: currentRouteForPortal(portal),
|
||||||
|
code: body.code,
|
||||||
|
message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearSession(portal);
|
||||||
|
dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message });
|
||||||
|
redirectToPortalLogin(portal);
|
||||||
|
throw new Error('登录会话已失效,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
async function readErrorMessage(response: Response) {
|
async function readErrorMessage(response: Response) {
|
||||||
const fallback = `请求失败(${response.status})`;
|
const fallback = `请求失败(${response.status})`;
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -40,7 +99,8 @@ async function readErrorMessage(response: Response) {
|
|||||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
const session = readSession();
|
const portal = requestPortal(path);
|
||||||
|
const session = portal ? readSession(portal) : null;
|
||||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
@@ -48,16 +108,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
}
|
}
|
||||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||||
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
|
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
|
||||||
if (response.status === 401 && session && !isLoginAttempt) {
|
if (response.status === 401 && !isLoginAttempt) {
|
||||||
const body = await readErrorBody(response.clone());
|
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
|
||||||
if (body.code === 'SESSION_LOCKED') {
|
|
||||||
dispatchSessionEvent('locked', { message: body.message });
|
|
||||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
|
||||||
}
|
|
||||||
clearSession();
|
|
||||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
|
||||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
|
||||||
throw new Error('登录会话已失效,请重新登录');
|
|
||||||
}
|
}
|
||||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||||
const body = await readErrorBody(response.clone());
|
const body = await readErrorBody(response.clone());
|
||||||
@@ -74,23 +126,16 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
|
|
||||||
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
const session = readSession();
|
const portal = requestPortal(path);
|
||||||
|
const session = portal ? readSession(portal) : null;
|
||||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
headers.set('x-tenant-id', tenantId);
|
headers.set('x-tenant-id', tenantId);
|
||||||
}
|
}
|
||||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||||
if (response.status === 401 && session) {
|
if (response.status === 401) {
|
||||||
const body = await readErrorBody(response.clone());
|
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
|
||||||
if (body.code === 'SESSION_LOCKED') {
|
|
||||||
dispatchSessionEvent('locked', { message: body.message });
|
|
||||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
|
||||||
}
|
|
||||||
clearSession();
|
|
||||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
|
||||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
|
||||||
throw new Error('登录会话已失效,请重新登录');
|
|
||||||
}
|
}
|
||||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||||
const body = await readErrorBody(response.clone());
|
const body = await readErrorBody(response.clone());
|
||||||
@@ -107,18 +152,12 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise<
|
|||||||
|
|
||||||
async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
|
async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const session = readSession();
|
const portal = requestPortal(path);
|
||||||
|
const session = portal ? readSession(portal) : null;
|
||||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||||
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||||
if (response.status === 401 && session) {
|
if (response.status === 401) {
|
||||||
const body = await readErrorBody(response.clone());
|
await handleSessionFailure(response, portal);
|
||||||
if (body.code === 'SESSION_LOCKED') {
|
|
||||||
dispatchSessionEvent('locked', { message: body.message });
|
|
||||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
|
||||||
}
|
|
||||||
clearSession();
|
|
||||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
|
||||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
|
||||||
throw new Error('登录会话已失效,请重新登录');
|
throw new Error('登录会话已失效,请重新登录');
|
||||||
}
|
}
|
||||||
if (response.status === 403 && session && !reauthenticationAttempted) {
|
if (response.status === 403 && session && !reauthenticationAttempted) {
|
||||||
@@ -208,6 +247,27 @@ export type SmsTemplateAudit = {
|
|||||||
tenant?: { name: string };
|
tenant?: { name: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReviewPreflight = {
|
||||||
|
type: 'signature' | 'template';
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
status: string;
|
||||||
|
expectedUpdatedAt: string;
|
||||||
|
identity: Record<string, string>;
|
||||||
|
impacts: string[];
|
||||||
|
materialSummary: Record<string, string | number>;
|
||||||
|
blockedReasons: string[];
|
||||||
|
allowedActions: Array<'approve' | 'reject'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewDecisionResult = {
|
||||||
|
operationId: string;
|
||||||
|
replayed: boolean;
|
||||||
|
decision: 'approve' | 'reject';
|
||||||
|
status: string;
|
||||||
|
item: ClientSmsSignature | SmsTemplateAudit;
|
||||||
|
};
|
||||||
|
|
||||||
export type TenantOption = {
|
export type TenantOption = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -305,6 +365,25 @@ export type RechargeOrder = {
|
|||||||
tenant?: TenantOption;
|
tenant?: TenantOption;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ManualRechargePreflight = {
|
||||||
|
tenant: Pick<TenantOption, 'id' | 'name' | 'code'>;
|
||||||
|
accountId: string;
|
||||||
|
expectedAccountUpdatedAt: string;
|
||||||
|
balanceCents: number;
|
||||||
|
creditCents: number;
|
||||||
|
amountCents: number;
|
||||||
|
balanceAfterCents: number;
|
||||||
|
direction: 'topup' | 'correction';
|
||||||
|
allowedActions: Array<'confirm'>;
|
||||||
|
blockedReasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManualRechargeResult = RechargeOrder & {
|
||||||
|
balanceAfterCents: number;
|
||||||
|
operationId: string;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientSmsApplication = {
|
export type ClientSmsApplication = {
|
||||||
id: string;
|
id: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -683,6 +762,50 @@ export type ReportMaterialPendingItem = {
|
|||||||
application?: ClientSmsApplication | null;
|
application?: ClientSmsApplication | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReportMaterialPreflightTarget = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
carrier: string;
|
||||||
|
businessKey: string;
|
||||||
|
eligible: boolean;
|
||||||
|
blockedReasons: string[];
|
||||||
|
duplicateBatchId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportMaterialPreflightItem = {
|
||||||
|
id: string;
|
||||||
|
reportType: 'signature' | 'drainage';
|
||||||
|
signatureId: string;
|
||||||
|
drainageItemId?: string;
|
||||||
|
materialVersion: number;
|
||||||
|
name: string;
|
||||||
|
tenantName: string;
|
||||||
|
applicationId?: string;
|
||||||
|
applicationName: string;
|
||||||
|
eligible: boolean;
|
||||||
|
blockedReasons: string[];
|
||||||
|
targets: ReportMaterialPreflightTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportMaterialBatchPreflight = {
|
||||||
|
checkedAt: string;
|
||||||
|
eligible: boolean;
|
||||||
|
eligibleItemCount: number;
|
||||||
|
blockedItemCount: number;
|
||||||
|
eligibleTargetCount: number;
|
||||||
|
skippedTargetCount: number;
|
||||||
|
items: ReportMaterialPreflightItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportMaterialBatchResult = Record<string, unknown> & {
|
||||||
|
id: string;
|
||||||
|
batchNo: string;
|
||||||
|
status: string;
|
||||||
|
operationId: string;
|
||||||
|
replayed: boolean;
|
||||||
|
result: { successCount: number; skippedCount: number; failedCount: number; items: ReportMaterialPreflightItem[] };
|
||||||
|
};
|
||||||
|
|
||||||
export type ReportImportMapping = {
|
export type ReportImportMapping = {
|
||||||
sourceHeader: string;
|
sourceHeader: string;
|
||||||
sourceHeaderPath?: string;
|
sourceHeaderPath?: string;
|
||||||
@@ -771,8 +894,8 @@ export type FileRef = {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment') {
|
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') {
|
||||||
return `/api/admin/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RiskReviewTask = {
|
export type RiskReviewTask = {
|
||||||
@@ -810,6 +933,7 @@ export type TenantAccount = {
|
|||||||
balanceCents: number;
|
balanceCents: number;
|
||||||
creditCents: number;
|
creditCents: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
updatedAt?: string;
|
||||||
tenant?: TenantOption;
|
tenant?: TenantOption;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -835,6 +959,16 @@ export type OperationLogResponse = {
|
|||||||
modules: string[];
|
modules: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SystemLogExportResult = {
|
||||||
|
operationId: string;
|
||||||
|
status: 'completed';
|
||||||
|
fileName: string;
|
||||||
|
recordCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
content: string;
|
||||||
|
filters: { keyword?: string; level?: string; module?: string; range?: string };
|
||||||
|
};
|
||||||
|
|
||||||
export type PagedResponse<T> = {
|
export type PagedResponse<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -1180,17 +1314,28 @@ function withQuery(path: string, query: Record<string, string | number | undefin
|
|||||||
return `${path}${suffix}`;
|
return `${path}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const portalSessionApi = {
|
||||||
|
current: (portal: Portal) => request<LoginSession>(`/${portal}/auth/session`, { suppressSessionRedirect: true }),
|
||||||
|
touch: (portal: Portal) => request<SessionTiming>(`/${portal}/auth/session/touch`, { method: 'POST', body: '{}' }),
|
||||||
|
lock: (portal: Portal) => request<{ locked: boolean }>(`/${portal}/auth/session/lock`, { method: 'POST', body: '{}' }),
|
||||||
|
unlock: (portal: Portal, password: string) => request<SessionTiming>(`/${portal}/auth/session/unlock`, { method: 'POST', body: JSON.stringify({ password }) }),
|
||||||
|
reauthenticate: (portal: Portal, password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>(`/${portal}/auth/reauthenticate`, { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
|
||||||
|
logout: (portal: Portal) => request<{ success: boolean }>(`/${portal}/auth/logout`, { method: 'POST', body: '{}' }),
|
||||||
|
changeOwnPassword: (portal: Portal, body: { currentPassword: string; password: string }) =>
|
||||||
|
request<ManagedUser>(`/${portal}/auth/password`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
};
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
||||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
touchSession: () => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/touch', { method: 'POST', body: '{}' }),
|
touchSession: () => portalSessionApi.touch('admin'),
|
||||||
lockSession: () => request<{ locked: boolean }>('/auth/session/lock', { method: 'POST', body: '{}' }),
|
lockSession: () => portalSessionApi.lock('admin'),
|
||||||
unlockSession: (password: string) => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/unlock', { method: 'POST', body: JSON.stringify({ password }) }),
|
unlockSession: (password: string) => portalSessionApi.unlock('admin', password),
|
||||||
reauthenticate: (password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>('/auth/reauthenticate', { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
|
reauthenticate: (password: string) => portalSessionApi.reauthenticate('admin', password),
|
||||||
logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST', body: '{}' }),
|
logout: () => portalSessionApi.logout('admin'),
|
||||||
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
|
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
|
||||||
request<ManagedUser>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
|
portalSessionApi.changeOwnPassword('admin', body),
|
||||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||||
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
||||||
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
||||||
@@ -1212,12 +1357,16 @@ export const adminApi = {
|
|||||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||||
|
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
|
||||||
|
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||||
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
||||||
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
|
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
|
||||||
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
|
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
|
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
|
||||||
createManualRecharge: (body: { tenantId: string; amountCents: number; operatorId?: string; remark?: string }) =>
|
preflightManualRecharge: (body: { tenantId: string; amountCents: number }) =>
|
||||||
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
|
||||||
|
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
|
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
|
||||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||||
getEnterpriseApplication: (id: string) =>
|
getEnterpriseApplication: (id: string) =>
|
||||||
@@ -1272,6 +1421,10 @@ export const adminApi = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason }),
|
||||||
}),
|
}),
|
||||||
|
getDeletionPreflight: (type: DeletionTargetType, id: string) =>
|
||||||
|
request<DeletionPreflight>(`/admin/deletions/${type}/${id}/preflight`),
|
||||||
|
deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) =>
|
||||||
|
request<DeletionResult>(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listChannelConnectionLogs: (id: string) => request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
|
listChannelConnectionLogs: (id: string) => request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
|
||||||
listTemplateAudits: (query: { keyword?: string; status?: string }) => {
|
listTemplateAudits: (query: { keyword?: string; status?: string }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -1290,6 +1443,10 @@ export const adminApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason }),
|
||||||
}),
|
}),
|
||||||
|
getReviewPreflight: (type: 'signature' | 'template', id: string) =>
|
||||||
|
request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`),
|
||||||
|
submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) =>
|
||||||
|
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
|
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
|
||||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
||||||
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||||
@@ -1370,8 +1527,10 @@ export const adminApi = {
|
|||||||
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
|
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
|
||||||
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
|
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
|
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
|
||||||
createReportMaterialBatch: (body: { createdById?: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }> }) =>
|
preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) =>
|
||||||
request<Record<string, unknown>>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
request<ReportMaterialBatchPreflight>('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) =>
|
||||||
|
request<ReportMaterialBatchResult>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
@@ -1462,7 +1621,7 @@ export const adminApi = {
|
|||||||
form.set('prefix', body.prefix);
|
form.set('prefix', body.prefix);
|
||||||
}
|
}
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const session = readSession();
|
const session = readSession('admin');
|
||||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
headers.set('x-tenant-id', tenantId);
|
headers.set('x-tenant-id', tenantId);
|
||||||
@@ -1471,11 +1630,11 @@ export const adminApi = {
|
|||||||
if (response.status === 401 && session) {
|
if (response.status === 401 && session) {
|
||||||
const error = await readErrorBody(response.clone());
|
const error = await readErrorBody(response.clone());
|
||||||
if (error.code === 'SESSION_LOCKED') {
|
if (error.code === 'SESSION_LOCKED') {
|
||||||
dispatchSessionEvent('locked', { message: error.message });
|
dispatchSessionEvent('admin', 'locked', { message: error.message });
|
||||||
} else {
|
} else {
|
||||||
clearSession();
|
clearSession('admin');
|
||||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
dispatchSessionEvent('admin', 'logout', { code: error.code, message: error.message });
|
||||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
redirectToPortalLogin('admin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1513,6 +1672,8 @@ export const clientApi = {
|
|||||||
}),
|
}),
|
||||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||||
|
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string }) =>
|
||||||
|
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||||
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||||
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
@@ -1566,6 +1727,10 @@ export const clientApi = {
|
|||||||
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||||
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||||
|
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`, { tenantId }),
|
||||||
|
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||||
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
|
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
|
||||||
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
@@ -1582,9 +1747,7 @@ export const clientApi = {
|
|||||||
request<SmsMessageRecord[]>(withQuery('/client/operations/messages', query), { tenantId }),
|
request<SmsMessageRecord[]>(withQuery('/client/operations/messages', query), { tenantId }),
|
||||||
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
||||||
createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
|
||||||
request<FileObject>('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }),
|
|
||||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => {
|
|
||||||
assertUploadFileSize(file);
|
assertUploadFileSize(file);
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('file', file);
|
form.set('file', file);
|
||||||
@@ -1593,20 +1756,17 @@ export const clientApi = {
|
|||||||
form.set('prefix', body.prefix);
|
form.set('prefix', body.prefix);
|
||||||
}
|
}
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const session = readSession();
|
const session = readSession('client');
|
||||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||||
if (tenantId) {
|
const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||||
headers.set('x-tenant-id', tenantId);
|
|
||||||
}
|
|
||||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
|
||||||
if (response.status === 401 && session) {
|
if (response.status === 401 && session) {
|
||||||
const error = await readErrorBody(response.clone());
|
const error = await readErrorBody(response.clone());
|
||||||
if (error.code === 'SESSION_LOCKED') {
|
if (error.code === 'SESSION_LOCKED') {
|
||||||
dispatchSessionEvent('locked', { message: error.message });
|
dispatchSessionEvent('client', 'locked', { message: error.message });
|
||||||
} else {
|
} else {
|
||||||
clearSession();
|
clearSession('client');
|
||||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
dispatchSessionEvent('client', 'logout', { code: error.code, message: error.message });
|
||||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
redirectToPortalLogin('client');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+106
-14
@@ -19,32 +19,116 @@ export type LoginSession = {
|
|||||||
absoluteExpiresAt: string;
|
absoluteExpiresAt: string;
|
||||||
lastActivityAt: string;
|
lastActivityAt: string;
|
||||||
recentAuthenticationExpiresAt: string;
|
recentAuthenticationExpiresAt: string;
|
||||||
|
locked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionKey = 'cmpp-auth-session';
|
export type SessionRecovery = {
|
||||||
|
returnUrl: string;
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function readSession(): LoginSession | null {
|
const legacySessionKey = 'cmpp-auth-session';
|
||||||
|
const sessionKey = (portal: Portal) => `cmpp-auth-session:${portal}`;
|
||||||
|
const recoveryKey = (portal: Portal) => `cmpp-session-recovery:${portal}`;
|
||||||
|
const sessionEventName = (portal: Portal, type: 'locked' | 'unlocked' | 'logout') => `cmpp-session-${portal}-${type}`;
|
||||||
|
|
||||||
|
export function portalFromPath(path: string): Portal | undefined {
|
||||||
|
if (/^\/?(?:api\/)?admin(?:\/|$)/.test(path)) return 'admin';
|
||||||
|
if (/^\/?(?:api\/)?client(?:\/|$)/.test(path)) return 'client';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSession(portal: Portal): LoginSession | null {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(sessionKey);
|
const raw = window.localStorage.getItem(sessionKey(portal));
|
||||||
return raw ? JSON.parse(raw) as LoginSession : null;
|
if (raw) return JSON.parse(raw) as LoginSession;
|
||||||
|
|
||||||
|
// One-time migration for sessions created before portal storage was isolated.
|
||||||
|
const legacyRaw = window.localStorage.getItem(legacySessionKey);
|
||||||
|
if (!legacyRaw) return null;
|
||||||
|
const legacy = JSON.parse(legacyRaw) as LoginSession;
|
||||||
|
if (legacy.portal !== portal) return null;
|
||||||
|
window.localStorage.setItem(sessionKey(portal), legacyRaw);
|
||||||
|
window.localStorage.removeItem(legacySessionKey);
|
||||||
|
return legacy;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeSession(session: LoginSession) {
|
export function writeSession(session: LoginSession) {
|
||||||
window.localStorage.setItem(sessionKey, JSON.stringify(session));
|
window.localStorage.setItem(sessionKey(session.portal), JSON.stringify(session));
|
||||||
|
window.localStorage.removeItem(legacySessionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSession() {
|
export function clearSession(portal: Portal) {
|
||||||
window.localStorage.removeItem(sessionKey);
|
window.localStorage.removeItem(sessionKey(portal));
|
||||||
|
try {
|
||||||
|
const legacyRaw = window.localStorage.getItem(legacySessionKey);
|
||||||
|
if (legacyRaw && (JSON.parse(legacyRaw) as LoginSession).portal === portal) {
|
||||||
|
window.localStorage.removeItem(legacySessionKey);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem(legacySessionKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSessionTiming(timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
|
export function updateSessionTiming(portal: Portal, timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
|
||||||
const current = readSession();
|
const current = readSession(portal);
|
||||||
if (current) writeSession({ ...current, ...timing });
|
if (current) writeSession({ ...current, ...timing });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function safeReturnUrl(portal: Portal, value: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized.startsWith(`/${portal}`) || normalized.startsWith(`/${portal}/login`)) return undefined;
|
||||||
|
if (normalized.startsWith('//') || normalized.includes('\\')) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(normalized, window.location.origin);
|
||||||
|
if (parsed.origin !== window.location.origin || !parsed.pathname.startsWith(`/${portal}`)) return undefined;
|
||||||
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSessionRecovery(portal: Portal, recovery: SessionRecovery) {
|
||||||
|
const returnUrl = safeReturnUrl(portal, recovery.returnUrl);
|
||||||
|
if (!returnUrl) return;
|
||||||
|
window.sessionStorage.setItem(recoveryKey(portal), JSON.stringify({ ...recovery, returnUrl }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSessionRecovery(portal: Portal): SessionRecovery | null {
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(recoveryKey(portal));
|
||||||
|
if (!raw) return null;
|
||||||
|
const recovery = JSON.parse(raw) as SessionRecovery;
|
||||||
|
const returnUrl = safeReturnUrl(portal, recovery.returnUrl);
|
||||||
|
return returnUrl ? { ...recovery, returnUrl } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeSessionRecovery(portal: Portal) {
|
||||||
|
const recovery = readSessionRecovery(portal);
|
||||||
|
window.sessionStorage.removeItem(recoveryKey(portal));
|
||||||
|
return recovery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionRecovery(portal: Portal) {
|
||||||
|
window.sessionStorage.removeItem(recoveryKey(portal));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentRouteForPortal(portal: Portal) {
|
||||||
|
const hashRoute = window.location.hash.startsWith('#/') ? window.location.hash.slice(1) : '';
|
||||||
|
return safeReturnUrl(portal, hashRoute) ?? safeReturnUrl(portal, `${window.location.pathname}${window.location.search}${window.location.hash}`) ?? `/${portal}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectToPortalLogin(portal: Portal) {
|
||||||
|
window.location.assign(`${window.location.origin}/#/${portal}/login`);
|
||||||
|
}
|
||||||
|
|
||||||
let lastUserActivityAt = Date.now();
|
let lastUserActivityAt = Date.now();
|
||||||
let reauthenticationHandler: (() => Promise<void>) | undefined;
|
let reauthenticationHandler: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
@@ -69,10 +153,10 @@ export function requestReauthentication() {
|
|||||||
return reauthenticationHandler();
|
return reauthenticationHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
|
export function dispatchSessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
|
||||||
window.dispatchEvent(new CustomEvent(`cmpp-session-${type}`, { detail }));
|
window.dispatchEvent(new CustomEvent(sessionEventName(portal, type), { detail }));
|
||||||
try {
|
try {
|
||||||
const channel = new BroadcastChannel('cmpp-session');
|
const channel = new BroadcastChannel(`cmpp-session:${portal}`);
|
||||||
channel.postMessage({ type, detail });
|
channel.postMessage({ type, detail });
|
||||||
channel.close();
|
channel.close();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -80,6 +164,14 @@ export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', det
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionTenantId() {
|
export function sessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout') {
|
||||||
return readSession()?.user.tenantId ?? undefined;
|
return sessionEventName(portal, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionChannel(portal: Portal) {
|
||||||
|
return `cmpp-session:${portal}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionTenantId() {
|
||||||
|
return readSession('client')?.user.tenantId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
|
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
|
||||||
import { writeSession, type Portal } from '@/api/session';
|
import { consumeSessionRecovery, readSessionRecovery, writeSession, type Portal } from '@/api/session';
|
||||||
import { Button, Input, Modal } from '@/components/ui';
|
import { Button, Input, Modal } from '@/components/ui';
|
||||||
|
|
||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
@@ -31,6 +31,7 @@ export function LoginPage({ portal }: LoginPageProps) {
|
|||||||
const [alertMessage, setAlertMessage] = useState('');
|
const [alertMessage, setAlertMessage] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const isAdmin = portal === 'admin';
|
const isAdmin = portal === 'admin';
|
||||||
|
const recovery = readSessionRecovery(portal);
|
||||||
|
|
||||||
async function refreshCaptcha(options: { clearError?: boolean } = {}) {
|
async function refreshCaptcha(options: { clearError?: boolean } = {}) {
|
||||||
if (options.clearError ?? true) {
|
if (options.clearError ?? true) {
|
||||||
@@ -56,7 +57,8 @@ export function LoginPage({ portal }: LoginPageProps) {
|
|||||||
captchaText,
|
captchaText,
|
||||||
});
|
});
|
||||||
writeSession(session);
|
writeSession(session);
|
||||||
navigate(isAdmin ? '/admin' : '/client', { replace: true });
|
const target = consumeSessionRecovery(portal)?.returnUrl;
|
||||||
|
navigate(target ?? (isAdmin ? '/admin' : '/client'), { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = loginErrorMessage(err);
|
const message = loginErrorMessage(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
@@ -78,6 +80,11 @@ export function LoginPage({ portal }: LoginPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-form">
|
<div className="login-form">
|
||||||
|
{recovery ? (
|
||||||
|
<p className="login-session-notice" role="status">
|
||||||
|
{recovery.message ?? '登录会话已失效,请重新登录。'} 登录成功后将返回之前访问的页面。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Input label="用户名/登录账号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} />
|
<Input label="用户名/登录账号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} />
|
||||||
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
|
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
|
||||||
<div className="login-captcha-row">
|
<div className="login-captcha-row">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ type ChannelModalState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ChannelConfirmAction = {
|
type ChannelConfirmAction = {
|
||||||
type: 'toggle' | 'delete' | 'copy';
|
type: 'toggle' | 'copy';
|
||||||
channel: SmsChannel;
|
channel: SmsChannel;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -532,11 +532,6 @@ export function AdminChannelsPage() {
|
|||||||
loadChannels();
|
loadChannels();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteChannel(id: string) {
|
|
||||||
await adminApi.deleteChannel(id, '运营端删除通道');
|
|
||||||
setChannels((items) => items.filter((item) => item.id !== id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyChannel(channel: SmsChannel) {
|
async function copyChannel(channel: SmsChannel) {
|
||||||
await adminApi.copyChannel(channel.id);
|
await adminApi.copyChannel(channel.id);
|
||||||
loadChannels();
|
loadChannels();
|
||||||
@@ -563,10 +558,6 @@ export function AdminChannelsPage() {
|
|||||||
void toggleChannel(confirmAction.channel);
|
void toggleChannel(confirmAction.channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmAction.type === 'delete') {
|
|
||||||
void deleteChannel(confirmAction.channel.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirmAction.type === 'copy') {
|
if (confirmAction.type === 'copy') {
|
||||||
void copyChannel(confirmAction.channel);
|
void copyChannel(confirmAction.channel);
|
||||||
}
|
}
|
||||||
@@ -574,17 +565,13 @@ export function AdminChannelsPage() {
|
|||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmTitle = confirmAction?.type === 'delete'
|
const confirmTitle = confirmAction?.type === 'copy'
|
||||||
? '确认删除通道'
|
|
||||||
: confirmAction?.type === 'copy'
|
|
||||||
? '确认复制通道'
|
? '确认复制通道'
|
||||||
: confirmAction?.channel.status === 'stopped'
|
: confirmAction?.channel.status === 'stopped'
|
||||||
? '确认启用通道'
|
? '确认启用通道'
|
||||||
: '确认停用通道';
|
: '确认停用通道';
|
||||||
|
|
||||||
const confirmDescription = confirmAction?.type === 'delete'
|
const confirmDescription = confirmAction?.type === 'copy'
|
||||||
? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。'
|
|
||||||
: confirmAction?.type === 'copy'
|
|
||||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||||
: confirmAction?.channel.status === 'stopped'
|
: confirmAction?.channel.status === 'stopped'
|
||||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||||
@@ -651,7 +638,7 @@ export function AdminChannelsPage() {
|
|||||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
||||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||||
</button>
|
</button>
|
||||||
<button className="is-danger" onClick={() => setConfirmAction({ type: 'delete', channel })} type="button"><Trash2 size={15} />删除</button>
|
<DeleteRiskAction onCompleted={() => void loadChannels()} portal="admin" targetId={channel.id} targetType="channel" />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -691,7 +678,7 @@ export function AdminChannelsPage() {
|
|||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
||||||
<Button onClick={submitConfirmAction} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button>
|
<Button onClick={submitConfirmAction}>确认</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onClose={() => setConfirmAction(null)}
|
onClose={() => setConfirmAction(null)}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
import { formatCents } from '@/utils/currency';
|
||||||
|
|
||||||
type AdminCustomersPageProps = {
|
type AdminCustomersPageProps = {
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
@@ -11,11 +11,6 @@ type AdminCustomersPageProps = {
|
|||||||
|
|
||||||
type CustomerRow = TenantManagementRow;
|
type CustomerRow = TenantManagementRow;
|
||||||
|
|
||||||
type RechargeForm = {
|
|
||||||
amount: string;
|
|
||||||
remark: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Modal footer={<><Button onClick={onCancel} variant="ghost">取消</Button><Button onClick={onConfirm}>确认</Button></>} onClose={onCancel} open title="操作确认">
|
<Modal footer={<><Button onClick={onCancel} variant="ghost">取消</Button><Button onClick={onConfirm}>确认</Button></>} onClose={onCancel} open title="操作确认">
|
||||||
@@ -24,13 +19,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyRechargeForm(): RechargeForm {
|
|
||||||
return {
|
|
||||||
amount: '',
|
|
||||||
remark: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
|
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [records, setRecords] = useState<CustomerRow[]>([]);
|
const [records, setRecords] = useState<CustomerRow[]>([]);
|
||||||
@@ -39,9 +27,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
|||||||
const [filters, setFilters] = useState({ name: '', status: 'all' });
|
const [filters, setFilters] = useState({ name: '', status: 'all' });
|
||||||
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
|
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
|
||||||
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
|
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
|
||||||
const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm);
|
|
||||||
const [rechargeError, setRechargeError] = useState('');
|
|
||||||
const [recharging, setRecharging] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
@@ -108,37 +93,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
|||||||
|
|
||||||
function openRechargeModal(record: CustomerRow) {
|
function openRechargeModal(record: CustomerRow) {
|
||||||
setRechargeTarget(record);
|
setRechargeTarget(record);
|
||||||
setRechargeForm(emptyRechargeForm());
|
|
||||||
setRechargeError('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRechargeForm<K extends keyof RechargeForm>(key: K, value: RechargeForm[K]) {
|
|
||||||
setRechargeForm((current) => ({ ...current, [key]: value }));
|
|
||||||
setRechargeError('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitRecharge() {
|
|
||||||
if (!rechargeTarget) return;
|
|
||||||
const amount = Number(rechargeForm.amount);
|
|
||||||
if (!Number.isFinite(amount) || !isValidMoneyInput(rechargeForm.amount, { allowNegative: true, allowZero: false })) {
|
|
||||||
setRechargeError('请填写非 0 的充值金额,支持负数冲正');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRecharging(true);
|
|
||||||
try {
|
|
||||||
await adminApi.createManualRecharge({
|
|
||||||
tenantId: rechargeTarget.id,
|
|
||||||
amountCents: yuanToMoneyUnits(rechargeForm.amount),
|
|
||||||
remark: rechargeForm.remark,
|
|
||||||
});
|
|
||||||
setRechargeTarget(null);
|
|
||||||
setRechargeForm(emptyRechargeForm());
|
|
||||||
await loadData();
|
|
||||||
} catch (failure) {
|
|
||||||
setRechargeError(failure instanceof Error ? failure.message : '企业充值失败');
|
|
||||||
} finally {
|
|
||||||
setRecharging(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitConfirmAction() {
|
function submitConfirmAction() {
|
||||||
@@ -191,28 +145,19 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
|||||||
onConfirm={submitConfirmAction}
|
onConfirm={submitConfirmAction}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{rechargeTarget ? (
|
<ManualRechargeDialog
|
||||||
<Modal
|
initialTargetId={rechargeTarget?.id}
|
||||||
footer={(
|
lockTarget
|
||||||
<>
|
onClose={() => setRechargeTarget(null)}
|
||||||
<Button disabled={recharging} onClick={() => setRechargeTarget(null)} variant="ghost">取消</Button>
|
onCompleted={loadData}
|
||||||
<Button disabled={recharging} onClick={() => { void submitRecharge(); }}>{recharging ? '充值中...' : '确认充值'}</Button>
|
open={Boolean(rechargeTarget)}
|
||||||
</>
|
targets={rechargeTarget ? [{
|
||||||
)}
|
id: rechargeTarget.id,
|
||||||
onClose={() => setRechargeTarget(null)}
|
name: rechargeTarget.name,
|
||||||
open
|
code: rechargeTarget.code,
|
||||||
size="md"
|
balanceCents: rechargeTarget.account?.balanceCents ?? 0,
|
||||||
title="企业人工充值"
|
}] : []}
|
||||||
>
|
/>
|
||||||
<div className="admin-system-modal-form">
|
|
||||||
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
|
||||||
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
|
|
||||||
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} />
|
|
||||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
|
||||||
</div>
|
|
||||||
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
|
||||||
</Modal>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { formatDateTime } from '@/utils/dateTime';
|
|||||||
|
|
||||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||||
pending: 'warning',
|
pending: 'warning',
|
||||||
|
manual_requeueing: 'info',
|
||||||
awaiting_ack: 'info',
|
awaiting_ack: 'info',
|
||||||
delivered: 'success',
|
delivered: 'success',
|
||||||
failed: 'danger',
|
failed: 'danger',
|
||||||
@@ -14,6 +15,7 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
|
manual_requeueing: '人工重投处理中',
|
||||||
awaiting_ack: '等待客户端确认',
|
awaiting_ack: '等待客户端确认',
|
||||||
delivered: '客户端已确认',
|
delivered: '客户端已确认',
|
||||||
failed: '投递失败',
|
failed: '投递失败',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||||
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||||
import { displayFileName } from '@/utils/fileName';
|
import { displayFileName } from '@/utils/fileName';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
@@ -563,7 +563,7 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
|||||||
export function AdminEnterpriseSignaturesPage() {
|
export function AdminEnterpriseSignaturesPage() {
|
||||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
||||||
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
|
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
|
||||||
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
||||||
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
||||||
@@ -672,11 +672,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
if (!deleteTarget) {
|
if (!deleteTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (deleteTarget.kind === 'signature') {
|
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
|
||||||
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
|
|
||||||
} else {
|
|
||||||
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
|
|
||||||
}
|
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
await loadData();
|
await loadData();
|
||||||
}
|
}
|
||||||
@@ -708,7 +704,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||||
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost">报备状态</Button>
|
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost">报备状态</Button>
|
||||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
||||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||||
|
|
||||||
type TemplateFormState = {
|
type TemplateFormState = {
|
||||||
@@ -99,6 +99,8 @@ function TemplateFormModal({
|
|||||||
category: item?.category ?? '行业通知',
|
category: item?.category ?? '行业通知',
|
||||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||||
});
|
});
|
||||||
|
const initialForm = useRef(form).current;
|
||||||
|
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||||
const tenantSignatures = signatures.filter((signature) => (
|
const tenantSignatures = signatures.filter((signature) => (
|
||||||
signature.tenantId === form.tenantId
|
signature.tenantId === form.tenantId
|
||||||
@@ -146,9 +148,10 @@ function TemplateFormModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
dirty={dirty}
|
||||||
|
footer={({ requestClose }) => (
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
<Button onClick={requestClose} variant="ghost">取消</Button>
|
||||||
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -277,7 +280,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
|||||||
export function AdminEnterpriseTemplatesPage() {
|
export function AdminEnterpriseTemplatesPage() {
|
||||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
|
|
||||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||||
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
||||||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||||||
@@ -363,15 +365,6 @@ export function AdminEnterpriseTemplatesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deleteTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
|
|
||||||
setDeleteTarget(null);
|
|
||||||
await loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack admin-customer-split-page">
|
<section className="page-stack admin-customer-split-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
@@ -442,7 +435,7 @@ export function AdminEnterpriseTemplatesPage() {
|
|||||||
<div className="admin-enterprise-template-row__actions">
|
<div className="admin-enterprise-template-row__actions">
|
||||||
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost">预览</Button>
|
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost">预览</Button>
|
||||||
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost">编辑</Button>
|
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost">编辑</Button>
|
||||||
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(template)} size="sm" variant="danger">删除</Button>
|
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={template.id} targetType="template" />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -475,13 +468,6 @@ export function AdminEnterpriseTemplatesPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
|
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
|
||||||
{deleteTarget ? (
|
|
||||||
<ConfirmModal
|
|
||||||
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
|
||||||
onCancel={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={() => { void confirmDelete(); }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Search } from 'lucide-react';
|
import { Plus, Search } from 'lucide-react';
|
||||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, Tag, type DateRangeValue } from '@/components/ui';
|
||||||
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
import { formatCents } from '@/utils/currency';
|
||||||
|
|
||||||
type ManualRechargeForm = {
|
|
||||||
tenantId: string;
|
|
||||||
amount: string;
|
|
||||||
remark: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDate(value: string) {
|
function getDate(value: string) {
|
||||||
return value.slice(0, 10);
|
return value.slice(0, 10);
|
||||||
@@ -26,27 +20,26 @@ function RemarkCell({ value }: { value?: string }) {
|
|||||||
export function AdminRechargeRecordsPage() {
|
export function AdminRechargeRecordsPage() {
|
||||||
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
||||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
|
||||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||||
const [manualOpen, setManualOpen] = useState(false);
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', remark: '' });
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [manualError, setManualError] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [nextTenants, nextRecords] = await Promise.all([
|
const [nextTenants, nextAccounts, nextRecords] = await Promise.all([
|
||||||
adminApi.listTenants(),
|
adminApi.listTenants(),
|
||||||
|
adminApi.listAccounts(),
|
||||||
adminApi.listManualRecharges(),
|
adminApi.listManualRecharges(),
|
||||||
]);
|
]);
|
||||||
setTenants(nextTenants);
|
setTenants(nextTenants);
|
||||||
|
setAccounts(nextAccounts);
|
||||||
setRecords(nextRecords);
|
setRecords(nextRecords);
|
||||||
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
||||||
setRecords([]);
|
setRecords([]);
|
||||||
@@ -84,35 +77,6 @@ export function AdminRechargeRecordsPage() {
|
|||||||
setDateRange({});
|
setDateRange({});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
|
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
|
||||||
setManualError('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitManualRecharge() {
|
|
||||||
const amount = Number(form.amount);
|
|
||||||
if (!form.tenantId || !Number.isFinite(amount) || !isValidMoneyInput(form.amount, { allowNegative: true, allowZero: false })) {
|
|
||||||
setManualError('请填写非 0 的充值金额;金额支持负数冲正。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setManualError('');
|
|
||||||
try {
|
|
||||||
await adminApi.createManualRecharge({
|
|
||||||
tenantId: form.tenantId,
|
|
||||||
amountCents: yuanToMoneyUnits(form.amount),
|
|
||||||
remark: form.remark,
|
|
||||||
});
|
|
||||||
await loadData();
|
|
||||||
setManualOpen(false);
|
|
||||||
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', remark: '' });
|
|
||||||
} catch (failure) {
|
|
||||||
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack admin-recharge-page">
|
<section className="page-stack admin-recharge-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
@@ -181,33 +145,18 @@ export function AdminRechargeRecordsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{manualOpen ? (
|
<ManualRechargeDialog
|
||||||
<Modal
|
initialTargetId={tenants.find((tenant) => tenant.status !== 'deleted')?.id}
|
||||||
footer={(
|
onClose={() => setManualOpen(false)}
|
||||||
<>
|
onCompleted={loadData}
|
||||||
<Button disabled={submitting} onClick={() => setManualOpen(false)} variant="ghost">取消</Button>
|
open={manualOpen}
|
||||||
<Button disabled={submitting} onClick={() => { void submitManualRecharge(); }}>{submitting ? '充值中...' : '确认充值'}</Button>
|
targets={tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
|
||||||
</>
|
id: tenant.id,
|
||||||
)}
|
name: tenant.name,
|
||||||
onClose={() => setManualOpen(false)}
|
code: tenant.code,
|
||||||
open
|
balanceCents: accounts.find((account) => account.tenantId === tenant.id)?.balanceCents ?? 0,
|
||||||
size="md"
|
}))}
|
||||||
title="企业人工充值"
|
/>
|
||||||
>
|
|
||||||
<div className="admin-system-modal-form">
|
|
||||||
<Select
|
|
||||||
label="企业名称"
|
|
||||||
onChange={(event) => updateForm('tenantId', event.target.value)}
|
|
||||||
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
|
||||||
required
|
|
||||||
value={form.tenantId}
|
|
||||||
/>
|
|
||||||
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={form.amount} />
|
|
||||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
|
||||||
</div>
|
|
||||||
{manualError ? <p className="form-error">{manualError}</p> : null}
|
|
||||||
</Modal>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||||
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
|
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||||
|
|
||||||
@@ -17,38 +17,68 @@ export function AdminReportMaterialsPage() {
|
|||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [preflightBusy, setPreflightBusy] = useState(false);
|
||||||
|
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
|
||||||
|
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map());
|
||||||
|
const [operationKey, setOperationKey] = useState('');
|
||||||
|
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
|
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
|
||||||
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
|
.then(async ([pendingItems, batchItems]) => {
|
||||||
|
setItems(pendingItems); setBatches(batchItems as Batch[]); setError('');
|
||||||
|
const eligibility = pendingItems.length ? await adminApi.preflightReportMaterialBatch({ items: pendingItems.map(toBatchItem) }) : null;
|
||||||
|
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
|
||||||
|
setPoolEligibility(eligibilityMap);
|
||||||
|
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
|
||||||
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
|
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(loadData, [reportType]);
|
useEffect(loadData, [reportType]);
|
||||||
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
|
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
|
||||||
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
|
const eligibleVisibleItems = visibleItems.filter((item) => poolEligibility.get(item.id)?.eligible);
|
||||||
|
const allSelected = eligibleVisibleItems.length > 0 && eligibleVisibleItems.every((item) => selected.has(item.id));
|
||||||
|
|
||||||
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
function toggle(id: string) { if (!poolEligibility.get(id)?.eligible) return; setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||||
|
|
||||||
|
async function beginCreateBatch() {
|
||||||
|
const chosen = items.filter((item) => selected.has(item.id));
|
||||||
|
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
|
||||||
|
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
|
||||||
|
setOperationKey(`report-batch:${crypto.randomUUID()}`);
|
||||||
|
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
|
||||||
|
finally { setPreflightBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
async function createBatch() {
|
async function createBatch() {
|
||||||
const chosen = items.filter((item) => selected.has(item.id));
|
const chosen = items.filter((item) => selected.has(item.id));
|
||||||
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
|
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
|
||||||
setBusy(true); setError(''); setMessage('');
|
setBusy(true); setError(''); setMessage('');
|
||||||
try {
|
try {
|
||||||
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
|
const batch = await adminApi.createReportMaterialBatch({ idempotencyKey: operationKey, items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })) });
|
||||||
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
|
setBatchResult(batch); setMessage(`批次 ${batch.batchNo} 已完成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`); setSelected(new Set()); loadData();
|
||||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
|
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return <section className="page-stack report-material-page">
|
return <section className="page-stack report-material-page">
|
||||||
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;人工勾选后一次生成所有关联通道的任务和 XLSX 文件。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备(${selected.size})`}</Button></div></div>
|
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;生成前会重新检查应用、路由、通道字段、资料版本和重复批次。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size})`}</Button></div></div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
|
||||||
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost">刷新</Button></div>
|
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost">刷新</Button></div>
|
||||||
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本</span><span>变更时间</span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems.filter((entry) => poolEligibility.get(entry.id)?.eligible)) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本 / 资格</span><span>变更时间</span></div>{visibleItems.map((item) => { const eligibility = poolEligibility.get(item.id); const disabled = !eligibility?.eligible; return <label className={`report-material-row${disabled ? ' is-disabled' : ''}`} key={item.id}><input checked={selected.has(item.id)} disabled={disabled} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><span><Tag tone={disabled ? 'warning' : 'success'}>V{item.materialVersion} · {disabled ? '待补充' : `${eligibility.targets.filter((target) => target.eligible).length} 通道可生成`}</Tag>{disabled ? <small title={eligibility?.blockedReasons.join(';')}>{eligibility?.blockedReasons[0] ?? '资格检查中'}</small> : null}</span><span>{formatDateTime(item.changedAt)}</span></label>; })}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
||||||
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2>最近生成批次</h2><p>一个批次可按路由展开成多个通道文件,图片直接嵌入 XLSX。</p></div><Tag tone="neutral">{batches.length} 个批次</Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · 选择 {batch.selectedCount ?? 0} 条 · {batch.channelCount ?? 0} 个通道</small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}({String(file.rowCount ?? 0)} 行)</a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty">尚未生成报备批次</div> : null}</div>
|
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2>最近生成批次</h2><p>一个批次可按路由展开成多个通道文件,图片直接嵌入 XLSX。</p></div><Tag tone="neutral">{batches.length} 个批次</Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · 选择 {batch.selectedCount ?? 0} 条 · {batch.channelCount ?? 0} 个通道</small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}({String(file.rowCount ?? 0)} 行)</a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty">尚未生成报备批次</div> : null}</div>
|
||||||
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
|
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
|
||||||
|
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}>关闭</Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost">取消</Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
|
||||||
|
<div className="report-batch-preflight">{preflightBusy ? <p role="status">正在核对资料版本、应用路由、通道字段与历史批次…</p> : null}{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} />可生成 {preflight.eligibleTargetCount} 个资料通道组合</span><span><AlertTriangle size={17} />跳过 {preflight.skippedTargetCount} 个组合</span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li key={target.businessKey} className={target.eligible ? 'is-eligible' : 'is-blocked'}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join(';')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join(';')}</p>}</article>)}</> : null}{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>报备批次 {batchResult.batchNo} 已处理</strong><span>成功 {batchResult.result.successCount} · 跳过 {batchResult.result.skippedCount} · 失败 {batchResult.result.failedCount}</span><span>操作单号:{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}{error ? <p className="form-error" role="alert">{error}</p> : null}</div>
|
||||||
|
</Modal>
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toBatchItem(item: ReportMaterialPendingItem) {
|
||||||
|
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Check, Eye, Search, X } from 'lucide-react';
|
import { Eye, Search, X } from 'lucide-react';
|
||||||
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||||
@@ -84,10 +84,6 @@ export function AdminSignatureAuditPage() {
|
|||||||
|
|
||||||
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
|
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
|
||||||
|
|
||||||
async function approve(item: ClientSmsSignature) {
|
|
||||||
try { await adminApi.approveSignature(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核通过失败'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reject() {
|
async function reject() {
|
||||||
if (!rejectTarget || !reason.trim()) return;
|
if (!rejectTarget || !reason.trim()) return;
|
||||||
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); }
|
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); }
|
||||||
@@ -99,7 +95,7 @@ export function AdminSignatureAuditPage() {
|
|||||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
|
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
|
||||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
|
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
|
||||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
|
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
|
||||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><Button disabled={!canReviewSignature(record)} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success">通过</Button><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><RiskAction disabled={!canReviewSignature(record)} onCompleted={loadData} targetId={record.id} targetType="signature" /><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
return <section className="page-stack admin-template-audit-page">
|
return <section className="page-stack admin-template-audit-page">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
import { CalendarDays, FileText, Search } from 'lucide-react';
|
||||||
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Input, Pagination, Select, SystemLogExport, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export function AdminSystemLogsPage() {
|
|||||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||||
<h1>系统日志</h1>
|
<h1>系统日志</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
<SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="system-log-filters">
|
<div className="system-log-filters">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Check, Search, X } from 'lucide-react';
|
import { Search, X } from 'lucide-react';
|
||||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
@@ -29,10 +29,8 @@ export function AdminTemplateAuditPage() {
|
|||||||
.catch(() => setAudits([]));
|
.catch(() => setAudits([]));
|
||||||
}, [keyword, status]);
|
}, [keyword, status]);
|
||||||
|
|
||||||
async function reviewTemplate(id: string, nextStatus: 'approved' | 'rejected') {
|
async function rejectTemplate(id: string) {
|
||||||
const updated = nextStatus === 'approved'
|
const updated = await adminApi.rejectTemplate(id);
|
||||||
? await adminApi.approveTemplate(id)
|
|
||||||
: await adminApi.rejectTemplate(id);
|
|
||||||
setAudits((items) => items.map((item) => (item.id === id ? updated : item)));
|
setAudits((items) => items.map((item) => (item.id === id ? updated : item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,19 +56,11 @@ export function AdminTemplateAuditPage() {
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<Button
|
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status }).then(setAudits)} targetId={record.id} targetType="template" />
|
||||||
disabled={record.auditStatus !== 'pending'}
|
|
||||||
icon={<Check size={15} />}
|
|
||||||
onClick={() => void reviewTemplate(record.id, 'approved')}
|
|
||||||
size="sm"
|
|
||||||
variant="success"
|
|
||||||
>
|
|
||||||
通过
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
disabled={record.auditStatus !== 'pending'}
|
disabled={record.auditStatus !== 'pending'}
|
||||||
icon={<X size={15} />}
|
icon={<X size={15} />}
|
||||||
onClick={() => void reviewTemplate(record.id, 'rejected')}
|
onClick={() => void rejectTemplate(record.id)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
@@ -80,7 +70,7 @@ export function AdminTemplateAuditPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[keyword, status],
|
||||||
);
|
);
|
||||||
const templateAudits = audits;
|
const templateAudits = audits;
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function generateInitialPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const session = readSession();
|
const session = readSession('admin');
|
||||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; upl
|
|||||||
<label className="enterprise-upload">
|
<label className="enterprise-upload">
|
||||||
<Upload size={38} />
|
<Upload size={38} />
|
||||||
<strong>{uploading ? '上传中...' : file ? displayFileName(file.fileName) : '点击上传'}</strong>
|
<strong>{uploading ? '上传中...' : file ? displayFileName(file.fileName) : '点击上传'}</strong>
|
||||||
<FileActions file={fileRef} />
|
<FileActions file={fileRef} portal="client" />
|
||||||
<input
|
<input
|
||||||
accept="image/png,image/jpeg,image/webp,application/pdf"
|
accept="image/png,image/jpeg,image/webp,application/pdf"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type RecentTaskRow = {
|
|||||||
taskNo: string;
|
taskNo: string;
|
||||||
scene: string;
|
scene: string;
|
||||||
count: number;
|
count: number;
|
||||||
channel: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
@@ -30,7 +29,6 @@ const columns: Array<TableColumn<RecentTaskRow>> = [
|
|||||||
{ key: 'taskNo', title: '批次编号', render: (record) => record.taskNo },
|
{ key: 'taskNo', title: '批次编号', render: (record) => record.taskNo },
|
||||||
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
||||||
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
||||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
|
||||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
|
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
|
||||||
];
|
];
|
||||||
@@ -61,7 +59,6 @@ export function ClientHome() {
|
|||||||
taskNo: String(task.taskNo ?? task.id),
|
taskNo: String(task.taskNo ?? task.id),
|
||||||
scene: String(task.category ?? task.content ?? '短信发送'),
|
scene: String(task.category ?? task.content ?? '短信发送'),
|
||||||
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
||||||
channel: Array.isArray(task.messages) && task.messages[0]?.channel?.name ? String(task.messages[0].channel.name) : '待路由',
|
|
||||||
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
||||||
status: String(task.status ?? 'unknown'),
|
status: String(task.status ?? 'unknown'),
|
||||||
})), [dashboard]);
|
})), [dashboard]);
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ export function ClientSendDetailPage() {
|
|||||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
||||||
) : visibleRows.map((record) => {
|
) : visibleRows.map((record) => {
|
||||||
const receipt = getReceipt(record);
|
const receipt = getReceipt(record);
|
||||||
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
|
const carrier = record.carrier ? carrierLabelMap[record.carrier] ?? record.carrier : '-';
|
||||||
const region = record.channel?.sendRegion ?? '-';
|
const region = record.province ?? '-';
|
||||||
return (
|
return (
|
||||||
<Fragment key={record.id}>
|
<Fragment key={record.id}>
|
||||||
<tr className="send-detail-main-row">
|
<tr className="send-detail-main-row">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
|
||||||
import { Button, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
import { Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
clientApi,
|
clientApi,
|
||||||
type ClientApplicationReportField,
|
type ClientApplicationReportField,
|
||||||
@@ -67,7 +67,7 @@ function ReviewFields({
|
|||||||
<Upload size={26} />
|
<Upload size={26} />
|
||||||
<strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong>
|
<strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong>
|
||||||
<small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small>
|
<small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small>
|
||||||
<FileActions file={reportFileRef(values[field.code])} />
|
<FileActions file={reportFileRef(values[field.code])} portal="client" />
|
||||||
<input
|
<input
|
||||||
accept={field.fieldType === 'image' ? 'image/*' : undefined}
|
accept={field.fieldType === 'image' ? 'image/*' : undefined}
|
||||||
onChange={(event) => onUpload(field, event.target.files?.[0])}
|
onChange={(event) => onUpload(field, event.target.files?.[0])}
|
||||||
@@ -282,8 +282,7 @@ export function ClientSignaturesPage() {
|
|||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
try {
|
try {
|
||||||
if (deleting.type === 'signature') await clientApi.changeSignatureStatus(deleting.id, 'disabled');
|
await clientApi.changeDrainageInfoStatus(deleting.id, 'deleted');
|
||||||
else await clientApi.changeDrainageInfoStatus(deleting.id, 'deleted');
|
|
||||||
setDeleting(undefined);
|
setDeleting(undefined);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
@@ -335,7 +334,7 @@ export function ClientSignaturesPage() {
|
|||||||
<span>{formatDate(signature.updatedAt)}</span>
|
<span>{formatDate(signature.updatedAt)}</span>
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">修改</Button>
|
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">修改</Button>
|
||||||
<Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={signature.id} targetType="signature" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{signature.rejectReason ? <div className="client-signature-inline-reason"><strong>修改说明:</strong>{signature.rejectReason}</div> : null}
|
{signature.rejectReason ? <div className="client-signature-inline-reason"><strong>修改说明:</strong>{signature.rejectReason}</div> : null}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
import { CalendarDays, FileText, Search } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Pagination,
|
Pagination,
|
||||||
Select,
|
Select,
|
||||||
|
SystemLogExport,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
@@ -78,7 +79,7 @@ export function ClientSystemLogsPage() {
|
|||||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||||
<h1>系统日志</h1>
|
<h1>系统日志</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
<SystemLogExport exportLogs={clientApi.exportSystemLogs} filters={{ keyword, level, module, range }} portal="client" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="system-log-filters">
|
<div className="system-log-filters">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
|
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||||
@@ -86,6 +86,8 @@ function TemplateModal({
|
|||||||
content: initialContent,
|
content: initialContent,
|
||||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||||
});
|
});
|
||||||
|
const initialForm = useRef(form).current;
|
||||||
|
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||||
const availableSignatures = signatures.filter((signature) => (
|
const availableSignatures = signatures.filter((signature) => (
|
||||||
signature.auditStatus === 'approved'
|
signature.auditStatus === 'approved'
|
||||||
@@ -130,9 +132,10 @@ function TemplateModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
dirty={dirty}
|
||||||
|
footer={({ requestClose }) => (
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
<Button onClick={requestClose} variant="ghost">取消</Button>
|
||||||
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -266,12 +269,6 @@ export function ClientTemplatesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableTemplate(id: string) {
|
|
||||||
clientApi.changeTemplateStatus(id, 'disabled')
|
|
||||||
.then(loadData)
|
|
||||||
.catch((reason: Error) => setError(reason.message || '模板禁用失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
<div className="template-page-header">
|
<div className="template-page-header">
|
||||||
@@ -315,10 +312,7 @@ export function ClientTemplatesPage() {
|
|||||||
<Edit3 size={14} />
|
<Edit3 size={14} />
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => disableTemplate(template.id)} type="button">
|
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={template.id} targetType="template" />
|
||||||
<Trash2 size={14} />
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function toForm(user?: ManagedUser): UserForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClientUsersPage() {
|
export function ClientUsersPage() {
|
||||||
const session = readSession();
|
const session = readSession('client');
|
||||||
const tenantId = session?.user.tenantId ?? undefined;
|
const tenantId = session?.user.tenantId ?? undefined;
|
||||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
|
import { adminApi, clientApi, type DeletionPreflight, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Textarea } from './Textarea';
|
||||||
|
|
||||||
|
export function DeleteRiskAction({ portal, targetType, targetId, children = '删除', icon = <Trash2 size={15} />, disabled, onCompleted }: {
|
||||||
|
portal: 'admin' | 'client';
|
||||||
|
targetType: DeletionTargetType;
|
||||||
|
targetId: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onCompleted?: (result: DeletionResult) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [preflight, setPreflight] = useState<DeletionPreflight | null>(null);
|
||||||
|
const [result, setResult] = useState<DeletionResult | null>(null);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||||
|
|
||||||
|
async function begin() {
|
||||||
|
const key = `delete:${targetType}:${targetId}:${crypto.randomUUID()}`;
|
||||||
|
setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setError(''); setIdempotencyKey(key);
|
||||||
|
try {
|
||||||
|
const data = portal === 'admin'
|
||||||
|
? await adminApi.getDeletionPreflight(targetType, targetId)
|
||||||
|
: await clientApi.getDeletionPreflight(targetType as Exclude<DeletionTargetType, 'channel'>, targetId);
|
||||||
|
setPreflight(data);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '删除资格预检失败');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!preflight?.allowedActions.includes('delete') || reason.trim().length < 4) return;
|
||||||
|
setSubmitting(true); setError('');
|
||||||
|
try {
|
||||||
|
const body = { expectedUpdatedAt: preflight.expectedUpdatedAt, idempotencyKey, reason: reason.trim() };
|
||||||
|
const completed = portal === 'admin'
|
||||||
|
? await adminApi.deleteGovernedTarget(targetType, targetId, body)
|
||||||
|
: await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body);
|
||||||
|
setResult(completed); onCompleted?.(completed);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '删除失败,请重新检查依赖后重试');
|
||||||
|
} finally { setSubmitting(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() { if (!submitting) setOpen(false); }
|
||||||
|
const blocked = Boolean(preflight && !preflight.allowedActions.includes('delete'));
|
||||||
|
const footer = (requestClose: () => void) => result ? <Button onClick={close}>关闭</Button> : <>
|
||||||
|
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
||||||
|
<Button disabled={loading || submitting || blocked || !preflight || reason.trim().length < 4} icon={<Trash2 size={15} />} onClick={() => void confirm()} variant="danger">
|
||||||
|
{submitting ? '删除处理中…' : '确认删除'}
|
||||||
|
</Button>
|
||||||
|
</>;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="danger">{children}</Button>
|
||||||
|
<Modal dirty={!result && reason.trim().length > 0} footer={({ requestClose }) => footer(requestClose)} onClose={close} open={open} title="删除资格与影响确认">
|
||||||
|
<div className="risk-action-content delete-risk-action">
|
||||||
|
{loading ? <p role="status">正在从后台检查引用关系与当前状态…</p> : null}
|
||||||
|
{preflight ? <>
|
||||||
|
<div className="risk-action-identity">
|
||||||
|
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
|
||||||
|
</div>
|
||||||
|
<section><h3>依赖与资格检查</h3>{preflight.dependencies.length ? <ul className="delete-risk-dependencies">{preflight.dependencies.map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} 项</span>{item.items.length ? <small>{item.items.join(';')}</small> : <small>无活动引用</small>}</li>)}</ul> : null}</section>
|
||||||
|
{preflight.blockedReasons.length
|
||||||
|
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
|
||||||
|
: <p className="risk-action-passed"><ShieldCheck size={16} />资格检查通过,可以执行逻辑删除</p>}
|
||||||
|
<section><h3>影响范围</h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul><p className="muted">{preflight.recoverability.description}</p></section>
|
||||||
|
{!blocked ? <Textarea label="删除原因" onChange={(event) => setReason(event.target.value)} placeholder="至少填写 4 个字符,原因将写入审计记录" required value={reason} /> : null}
|
||||||
|
</> : null}
|
||||||
|
{result ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>删除已完成</strong><span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
|
||||||
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityLabels: Record<string, string> = { name: '对象名称', id: '唯一标识', code: '通道编码', tenant: '所属企业', application: '短信应用', signature: '关联签名' };
|
||||||
@@ -7,6 +7,7 @@ import { Modal } from './Modal';
|
|||||||
|
|
||||||
type FileActionsProps = {
|
type FileActionsProps = {
|
||||||
file?: FileRef | null;
|
file?: FileRef | null;
|
||||||
|
portal?: 'admin' | 'client';
|
||||||
};
|
};
|
||||||
|
|
||||||
function isImageFile(file: FileRef) {
|
function isImageFile(file: FileRef) {
|
||||||
@@ -15,14 +16,14 @@ function isImageFile(file: FileRef) {
|
|||||||
return contentType.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name);
|
return contentType.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileActions({ file }: FileActionsProps) {
|
export function FileActions({ file, portal = 'admin' }: FileActionsProps) {
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
if (!file?.fileObjectId) {
|
if (!file?.fileObjectId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline');
|
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline', portal);
|
||||||
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment');
|
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment', portal);
|
||||||
const fileName = displayFileName(file.fileName);
|
const fileName = displayFileName(file.fileName);
|
||||||
return (
|
return (
|
||||||
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
|
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi';
|
||||||
|
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Input } from './Input';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Select } from './Select';
|
||||||
|
import { Textarea } from './Textarea';
|
||||||
|
|
||||||
|
export type ManualRechargeTarget = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
balanceCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ManualRechargeDialogProps = {
|
||||||
|
initialTargetId?: string;
|
||||||
|
lockTarget?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCompleted: () => void | Promise<void>;
|
||||||
|
open: boolean;
|
||||||
|
targets: ManualRechargeTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onClose, onCompleted, open, targets }: ManualRechargeDialogProps) {
|
||||||
|
const [tenantId, setTenantId] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const [review, setReview] = useState<ManualRechargePreflight | null>(null);
|
||||||
|
const [result, setResult] = useState<ManualRechargeResult | null>(null);
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTenantId(initialTargetId ?? targets[0]?.id ?? '');
|
||||||
|
setAmount('');
|
||||||
|
setRemark('');
|
||||||
|
setReview(null);
|
||||||
|
setResult(null);
|
||||||
|
setIdempotencyKey('');
|
||||||
|
setError('');
|
||||||
|
setSubmitting(false);
|
||||||
|
}, [initialTargetId, open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
const selectedTarget = targets.find((target) => target.id === tenantId);
|
||||||
|
|
||||||
|
function resetReview() {
|
||||||
|
setReview(null);
|
||||||
|
setResult(null);
|
||||||
|
setIdempotencyKey('');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAndDestroyDraft() {
|
||||||
|
if (submitting) return;
|
||||||
|
setAmount('');
|
||||||
|
setRemark('');
|
||||||
|
resetReview();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preflight() {
|
||||||
|
if (!tenantId || !Number.isFinite(Number(amount)) || !isValidMoneyInput(amount, { allowNegative: true, allowZero: false })) {
|
||||||
|
setError('请填写非 0 的充值金额;金额支持负数冲正。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) });
|
||||||
|
setReview(preview);
|
||||||
|
setIdempotencyKey(`manual-recharge:${crypto.randomUUID()}`);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '人工充值资格核对失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!review || !idempotencyKey) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const nextResult = await adminApi.createManualRecharge({
|
||||||
|
tenantId,
|
||||||
|
amountCents: review.amountCents,
|
||||||
|
expectedAccountUpdatedAt: review.expectedAccountUpdatedAt,
|
||||||
|
idempotencyKey,
|
||||||
|
remark,
|
||||||
|
});
|
||||||
|
setResult(nextResult);
|
||||||
|
await onCompleted();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const footer = (requestClose: () => void) => result ? (
|
||||||
|
<Button onClick={closeAndDestroyDraft}>完成</Button>
|
||||||
|
) : review ? (
|
||||||
|
<>
|
||||||
|
<Button disabled={submitting} onClick={resetReview} variant="ghost">返回修改</Button>
|
||||||
|
<Button disabled={submitting} onClick={() => { void submit(); }}>{submitting ? '入账中...' : review.direction === 'topup' ? '确认充值' : '确认冲正'}</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
||||||
|
<Button disabled={submitting} onClick={() => { void preflight(); }}>{submitting ? '核对中...' : '下一步:核对信息'}</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
dirty={!result && !submitting && Boolean(amount.trim() || remark.trim() || review)}
|
||||||
|
footer={({ requestClose }) => footer(requestClose)}
|
||||||
|
onClose={closeAndDestroyDraft}
|
||||||
|
open
|
||||||
|
size="md"
|
||||||
|
title={result ? '人工充值结果' : review ? '确认人工充值' : '企业人工充值'}
|
||||||
|
>
|
||||||
|
{result ? (
|
||||||
|
<div className="manual-recharge-result" role="status">
|
||||||
|
<strong>{result.amountCents > 0 ? '充值已入账' : '余额冲正已入账'}</strong>
|
||||||
|
<span>订单号:{result.orderNo}</span>
|
||||||
|
<span>充值后余额:¥{formatCents(result.balanceAfterCents)}</span>
|
||||||
|
<span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span>
|
||||||
|
</div>
|
||||||
|
) : review ? (
|
||||||
|
<div className="manual-recharge-review">
|
||||||
|
<p>请核对以下资金变更,确认后将立即写入企业账户。</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>充值对象</dt><dd><strong>{review.tenant.name}</strong><span>{review.tenant.code} · {review.tenant.id}</span></dd></div>
|
||||||
|
<div><dt>操作方向</dt><dd>{review.direction === 'topup' ? '余额充值' : '余额冲正'}</dd></div>
|
||||||
|
<div><dt>当前现金余额</dt><dd>¥{formatCents(review.balanceCents)}</dd></div>
|
||||||
|
<div><dt>本次变动</dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</dd></div>
|
||||||
|
<div><dt>预计现金余额</dt><dd><strong>¥{formatCents(review.balanceAfterCents)}</strong></dd></div>
|
||||||
|
</dl>
|
||||||
|
{remark.trim() ? <p className="manual-recharge-review__remark"><strong>备注:</strong>{remark.trim()}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-system-modal-form">
|
||||||
|
{lockTarget ? (
|
||||||
|
<Input disabled label="企业名称" value={selectedTarget?.name ?? ''} />
|
||||||
|
) : (
|
||||||
|
<Select label="企业名称" onChange={(event) => { setTenantId(event.target.value); resetReview(); }} options={targets.map((target) => ({ label: target.name, value: target.id }))} required value={tenantId} />
|
||||||
|
)}
|
||||||
|
<Input disabled label="当前现金余额" prefix="¥" value={formatCents(selectedTarget?.balanceCents ?? 0)} />
|
||||||
|
<Input label="充值金额" onChange={(event) => { setAmount(event.target.value); resetReview(); }} prefix="¥" required step="0.0001" type="number" value={amount} />
|
||||||
|
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => { setRemark(event.target.value); resetReview(); }} rows={4} value={remark} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
+226
-21
@@ -1,51 +1,256 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode, RefObject } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export type ModalCloseControls = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
type ModalProps = {
|
type ModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode | ((controls: ModalCloseControls) => ReactNode);
|
||||||
size?: 'md' | 'xl';
|
size?: 'md' | 'xl';
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
dirty?: boolean;
|
||||||
|
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||||
|
closeGuardTitle?: string;
|
||||||
|
closeGuardDescription?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Modal({ open, title, children, footer, size = 'md', onClose }: ModalProps) {
|
const focusableSelector = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled]):not([type="hidden"])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
'[contenteditable="true"]',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const modalStack: HTMLElement[] = [];
|
||||||
|
let documentLockCount = 0;
|
||||||
|
let bodyOverflow = '';
|
||||||
|
let bodyPaddingRight = '';
|
||||||
|
let backgroundState: Array<{ element: HTMLElement; inert: boolean; ariaHidden: string | null }> = [];
|
||||||
|
|
||||||
|
function modalLayer() {
|
||||||
|
let layer = document.getElementById('ui-modal-layer');
|
||||||
|
if (!layer) {
|
||||||
|
layer = document.createElement('div');
|
||||||
|
layer.id = 'ui-modal-layer';
|
||||||
|
document.body.appendChild(layer);
|
||||||
|
}
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockDocument(layer: HTMLElement) {
|
||||||
|
documentLockCount += 1;
|
||||||
|
if (documentLockCount !== 1) return;
|
||||||
|
|
||||||
|
bodyOverflow = document.body.style.overflow;
|
||||||
|
bodyPaddingRight = document.body.style.paddingRight;
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
|
||||||
|
backgroundState = Array.from(document.body.children)
|
||||||
|
.filter((child): child is HTMLElement => child instanceof HTMLElement && child !== layer)
|
||||||
|
.map((element) => ({ element, inert: element.inert, ariaHidden: element.getAttribute('aria-hidden') }));
|
||||||
|
backgroundState.forEach(({ element }) => {
|
||||||
|
element.inert = true;
|
||||||
|
element.setAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockDocument() {
|
||||||
|
documentLockCount = Math.max(0, documentLockCount - 1);
|
||||||
|
if (documentLockCount !== 0) return;
|
||||||
|
|
||||||
|
document.body.style.overflow = bodyOverflow;
|
||||||
|
document.body.style.paddingRight = bodyPaddingRight;
|
||||||
|
backgroundState.forEach(({ element, inert, ariaHidden }) => {
|
||||||
|
element.inert = inert;
|
||||||
|
if (ariaHidden === null) element.removeAttribute('aria-hidden');
|
||||||
|
else element.setAttribute('aria-hidden', ariaHidden);
|
||||||
|
});
|
||||||
|
backgroundState = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusableElements(root: HTMLElement) {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector))
|
||||||
|
.filter((element) => !element.hidden && element.getClientRects().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
onClose,
|
||||||
|
dirty = false,
|
||||||
|
initialFocusRef,
|
||||||
|
closeGuardTitle = '放弃未保存的修改?',
|
||||||
|
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
||||||
|
}: ModalProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const guardTitleId = useId();
|
||||||
|
const guardDescriptionId = useId();
|
||||||
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
|
const guardRef = useRef<HTMLElement>(null);
|
||||||
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [showCloseGuard, setShowCloseGuard] = useState(false);
|
||||||
|
const [layer] = useState(() => modalLayer());
|
||||||
|
|
||||||
|
const requestClose = useCallback(() => {
|
||||||
|
if (dirty) {
|
||||||
|
guardRestoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
setShowCloseGuard(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}, [dirty, onClose]);
|
||||||
|
|
||||||
|
const discardAndClose = useCallback(() => {
|
||||||
|
setShowCloseGuard(false);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setShowCloseGuard(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel) return undefined;
|
||||||
|
|
||||||
|
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
modalStack.push(panel);
|
||||||
|
lockDocument(layer);
|
||||||
|
|
||||||
|
const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel;
|
||||||
|
requestAnimationFrame(() => focusTarget.focus());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const stackIndex = modalStack.lastIndexOf(panel);
|
||||||
|
if (stackIndex >= 0) modalStack.splice(stackIndex, 1);
|
||||||
|
unlockDocument();
|
||||||
|
const restoreTarget = restoreFocusRef.current;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (restoreTarget?.isConnected && !restoreTarget.inert) restoreTarget.focus();
|
||||||
|
else modalStack[modalStack.length - 1]?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [initialFocusRef, layer, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel || modalStack[modalStack.length - 1] !== panel) return;
|
||||||
|
const trapRoot = showCloseGuard ? guardRef.current : panel;
|
||||||
|
if (!trapRoot) return;
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
onClose();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (showCloseGuard) setShowCloseGuard(false);
|
||||||
|
else requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
|
||||||
|
const items = focusableElements(trapRoot);
|
||||||
|
if (!items.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
trapRoot.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (event.shiftKey && (active === first || !trapRoot.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && (active === last || !trapRoot.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (open) {
|
document.addEventListener('keydown', handleKeyDown, true);
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||||
}
|
}, [open, requestClose, showCloseGuard]);
|
||||||
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
useEffect(() => {
|
||||||
}, [open, onClose]);
|
if (!showCloseGuard) return;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (panel) panel.inert = true;
|
||||||
|
requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus());
|
||||||
|
return () => {
|
||||||
|
if (panel) panel.inert = false;
|
||||||
|
const restoreTarget = guardRestoreFocusRef.current;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (restoreTarget?.isConnected && panel?.isConnected) restoreTarget.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [showCloseGuard]);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) return null;
|
||||||
return null;
|
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="ui-modal" role="presentation">
|
<div className="ui-modal" data-ui-modal-root>
|
||||||
<button className="ui-modal__mask" type="button" aria-label="关闭弹窗" onClick={onClose} />
|
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => {
|
||||||
<section aria-modal="true" className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')} role="dialog">
|
if (event.target === event.currentTarget) requestClose();
|
||||||
|
}} />
|
||||||
|
<section
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-modal="true"
|
||||||
|
className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')}
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
<header className="ui-modal__header">
|
<header className="ui-modal__header">
|
||||||
<div className="ui-modal__title">{title}</div>
|
<div className="ui-modal__title" id={titleId}>{title}</div>
|
||||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={onClose}>
|
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="ui-modal__body">{children}</div>
|
<div className="ui-modal__body">{children}</div>
|
||||||
{footer ? <footer className="ui-modal__footer">{footer}</footer> : null}
|
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null}
|
||||||
</section>
|
</section>
|
||||||
|
{showCloseGuard ? (
|
||||||
|
<div className="ui-modal__guard-layer">
|
||||||
|
<section
|
||||||
|
aria-describedby={guardDescriptionId}
|
||||||
|
aria-labelledby={guardTitleId}
|
||||||
|
aria-modal="true"
|
||||||
|
className="ui-modal__guard"
|
||||||
|
ref={guardRef}
|
||||||
|
role="alertdialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<AlertTriangle aria-hidden="true" size={22} />
|
||||||
|
<div>
|
||||||
|
<h2 id={guardTitleId}>{closeGuardTitle}</h2>
|
||||||
|
<p id={guardDescriptionId}>{closeGuardDescription}</p>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">继续编辑</Button>
|
||||||
|
<Button onClick={discardAndClose} variant="danger">放弃并关闭</Button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
layer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, Check, ShieldCheck } from 'lucide-react';
|
||||||
|
import { adminApi, type ReviewDecisionResult, type ReviewPreflight } from '@/api/adminApi';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
|
||||||
|
export function RiskAction({
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
disabled,
|
||||||
|
children = '通过',
|
||||||
|
icon = <Check size={15} />,
|
||||||
|
onCompleted,
|
||||||
|
}: {
|
||||||
|
targetType: 'signature' | 'template';
|
||||||
|
targetId: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
onCompleted?: (result: ReviewDecisionResult) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [preflight, setPreflight] = useState<ReviewPreflight | null>(null);
|
||||||
|
const [result, setResult] = useState<ReviewDecisionResult | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||||
|
|
||||||
|
async function begin() {
|
||||||
|
const key = `review:${targetType}:${targetId}:${crypto.randomUUID()}`;
|
||||||
|
setOpen(true);
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
setError('');
|
||||||
|
setPreflight(null);
|
||||||
|
setIdempotencyKey(key);
|
||||||
|
try {
|
||||||
|
setPreflight(await adminApi.getReviewPreflight(targetType, targetId));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '审核资格预检失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!preflight || !preflight.allowedActions.includes('approve')) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const completed = await adminApi.submitReviewDecision(targetType, targetId, {
|
||||||
|
decision: 'approve',
|
||||||
|
expectedUpdatedAt: preflight.expectedUpdatedAt,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
setResult(completed);
|
||||||
|
onCompleted?.(completed);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '审核提交失败,请刷新后重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!submitting) setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = Boolean(preflight && !preflight.allowedActions.includes('approve'));
|
||||||
|
const footer = result ? <Button onClick={close}>关闭</Button> : <>
|
||||||
|
<Button disabled={submitting} onClick={close} variant="ghost">取消</Button>
|
||||||
|
<Button disabled={loading || submitting || blocked || !preflight} icon={<ShieldCheck size={16} />} onClick={() => void confirm()} variant="success">
|
||||||
|
{submitting ? '提交审核中…' : '确认通过'}
|
||||||
|
</Button>
|
||||||
|
</>;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="success">{children}</Button>
|
||||||
|
<Modal footer={footer} onClose={close} open={open} title="审核通过确认">
|
||||||
|
<div className="risk-action-content">
|
||||||
|
{loading ? <p role="status">正在检查审核资格…</p> : null}
|
||||||
|
{preflight ? <>
|
||||||
|
<div className="risk-action-identity">
|
||||||
|
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
|
||||||
|
</div>
|
||||||
|
<section><h3>资格检查</h3>{preflight.blockedReasons.length
|
||||||
|
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
|
||||||
|
: <p className="risk-action-passed"><ShieldCheck size={16} />必需资料与当前状态检查通过</p>}</section>
|
||||||
|
<section><h3>影响范围</h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul></section>
|
||||||
|
</> : null}
|
||||||
|
{result ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>审核已完成</strong><span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
|
||||||
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityLabels: Record<string, string> = { name: '对象名称', id: '唯一标识', tenant: '所属企业', application: '短信应用' };
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Download, RotateCcw } from 'lucide-react';
|
||||||
|
import type { SystemLogExportResult } from '@/api/adminApi';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
type Filters = { keyword?: string; level?: string; module?: string; range?: string };
|
||||||
|
|
||||||
|
export function SystemLogExport({
|
||||||
|
portal,
|
||||||
|
filters,
|
||||||
|
exportLogs,
|
||||||
|
}: {
|
||||||
|
portal: 'admin' | 'client';
|
||||||
|
filters: Filters;
|
||||||
|
exportLogs: (filters: Filters) => Promise<SystemLogExportResult>;
|
||||||
|
}) {
|
||||||
|
const storageKey = `cmpp:${portal}:system-log-export-recovery`;
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [result, setResult] = useState<SystemLogExportResult | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [retryFilters, setRetryFilters] = useState<Filters | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(storageKey);
|
||||||
|
if (saved) {
|
||||||
|
setRetryFilters(JSON.parse(saved) as Filters);
|
||||||
|
setError('上次导出未完成,筛选条件已保留,可直接重试。');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
async function run(nextFilters: Filters) {
|
||||||
|
setExporting(true);
|
||||||
|
setError('');
|
||||||
|
setResult(null);
|
||||||
|
setRetryFilters(nextFilters);
|
||||||
|
sessionStorage.setItem(storageKey, JSON.stringify(nextFilters));
|
||||||
|
try {
|
||||||
|
const exported = await exportLogs(nextFilters);
|
||||||
|
setResult(exported);
|
||||||
|
setRetryFilters(null);
|
||||||
|
sessionStorage.removeItem(storageKey);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '日志导出失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!result) return;
|
||||||
|
const url = URL.createObjectURL(new Blob([`\uFEFF${result.content}`], { type: 'text/csv;charset=utf-8' }));
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = result.fileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-log-export">
|
||||||
|
<Button disabled={exporting} icon={<Download size={17} />} onClick={() => run(filters)} variant="secondary">
|
||||||
|
{exporting ? '导出中…' : '导出日志'}
|
||||||
|
</Button>
|
||||||
|
{result ? (
|
||||||
|
<div className="system-log-export__result" role="status">
|
||||||
|
<span>导出完成,共 {result.recordCount} 条{result.truncated ? '(已截取前 10000 条)' : ''},操作单号 {result.operationId}</span>
|
||||||
|
<Button icon={<Download size={15} />} onClick={download} size="sm" variant="ghost">下载文件</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="system-log-export__error" role="alert">
|
||||||
|
<span>{error}</span>
|
||||||
|
<Button disabled={exporting} icon={<RotateCcw size={15} />} onClick={() => run(retryFilters ?? filters)} size="sm" variant="ghost">重试</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ export { DateRangeInput } from './DateRangeInput';
|
|||||||
export { DateTimeInput } from './DateTimeInput';
|
export { DateTimeInput } from './DateTimeInput';
|
||||||
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
|
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
|
||||||
export { FileActions } from './FileActions';
|
export { FileActions } from './FileActions';
|
||||||
|
export { SystemLogExport } from './SystemLogExport';
|
||||||
|
export { RiskAction } from './RiskAction';
|
||||||
|
export { DeleteRiskAction } from './DeleteRiskAction';
|
||||||
|
export { ManualRechargeDialog } from './ManualRechargeDialog';
|
||||||
|
export type { ManualRechargeTarget } from './ManualRechargeDialog';
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
export { Modal } from './Modal';
|
export { Modal } from './Modal';
|
||||||
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
||||||
|
|||||||
@@ -32,13 +32,16 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
UserX,
|
UserX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import { adminApi } from '@/api/adminApi';
|
import { adminApi } from '@/api/adminApi';
|
||||||
import { readSession } from '@/api/session';
|
import type { LoginSession } from '@/api/session';
|
||||||
import { AppShell } from '@/layouts/AppShell';
|
import { AppShell } from '@/layouts/AppShell';
|
||||||
|
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const session = readSession();
|
return <PortalSessionBoundary portal="admin">{(session) => <AdminAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||||
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||||
const loadPendingAuditCount = useCallback(() => {
|
const loadPendingAuditCount = useCallback(() => {
|
||||||
adminApi.getDashboard()
|
adminApi.getDashboard()
|
||||||
@@ -51,7 +54,7 @@ export function AdminLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.portal !== 'admin') {
|
if (session.portal !== 'admin' || session.locked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadPendingAuditCount();
|
loadPendingAuditCount();
|
||||||
@@ -65,11 +68,7 @@ export function AdminLayout() {
|
|||||||
window.removeEventListener('focus', onFocus);
|
window.removeEventListener('focus', onFocus);
|
||||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||||
};
|
};
|
||||||
}, [loadPendingAuditCount, session?.portal]);
|
}, [loadPendingAuditCount, session.locked, session.portal]);
|
||||||
|
|
||||||
if (session?.portal !== 'admin') {
|
|
||||||
return <Navigate to="/admin/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -77,6 +76,7 @@ export function AdminLayout() {
|
|||||||
subtitle="平台运营管理中心"
|
subtitle="平台运营管理中心"
|
||||||
workspaceName="平台运营工作区"
|
workspaceName="平台运营工作区"
|
||||||
loginPath="/admin/login"
|
loginPath="/admin/login"
|
||||||
|
portal="admin"
|
||||||
userName={session.user.displayName}
|
userName={session.user.displayName}
|
||||||
userRole="平台管理员"
|
userRole="平台管理员"
|
||||||
auditNotifications={[
|
auditNotifications={[
|
||||||
|
|||||||
+49
-30
@@ -13,15 +13,20 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { adminApi } from '@/api/adminApi';
|
import { portalSessionApi } from '@/api/adminApi';
|
||||||
import {
|
import {
|
||||||
clearSession,
|
clearSession,
|
||||||
|
clearSessionRecovery,
|
||||||
dispatchSessionEvent,
|
dispatchSessionEvent,
|
||||||
getLastUserActivityAt,
|
getLastUserActivityAt,
|
||||||
markUserActivity,
|
markUserActivity,
|
||||||
readSession,
|
readSession,
|
||||||
|
saveSessionRecovery,
|
||||||
|
sessionChannel,
|
||||||
|
sessionEvent,
|
||||||
setReauthenticationHandler,
|
setReauthenticationHandler,
|
||||||
updateSessionTiming,
|
updateSessionTiming,
|
||||||
|
type Portal,
|
||||||
} from '@/api/session';
|
} from '@/api/session';
|
||||||
import { Button, Input, Modal } from '@/components/ui';
|
import { Button, Input, Modal } from '@/components/ui';
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ type AppShellProps = {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
loginPath: string;
|
loginPath: string;
|
||||||
|
portal: Portal;
|
||||||
userName: string;
|
userName: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
navSections: ShellNavSection[];
|
navSections: ShellNavSection[];
|
||||||
@@ -59,6 +65,7 @@ export function AppShell({
|
|||||||
title,
|
title,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
loginPath,
|
loginPath,
|
||||||
|
portal,
|
||||||
userName,
|
userName,
|
||||||
userRole,
|
userRole,
|
||||||
navSections,
|
navSections,
|
||||||
@@ -76,7 +83,8 @@ export function AppShell({
|
|||||||
const [passwordError, setPasswordError] = useState('');
|
const [passwordError, setPasswordError] = useState('');
|
||||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||||
const [idleWarningSeconds, setIdleWarningSeconds] = useState<number | null>(null);
|
const [idleWarningSeconds, setIdleWarningSeconds] = useState<number | null>(null);
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(() => Boolean(readSession(portal)?.locked));
|
||||||
|
const [routesSuspended, setRoutesSuspended] = useState(() => Boolean(readSession(portal)?.locked));
|
||||||
const [unlockPassword, setUnlockPassword] = useState('');
|
const [unlockPassword, setUnlockPassword] = useState('');
|
||||||
const [unlockError, setUnlockError] = useState('');
|
const [unlockError, setUnlockError] = useState('');
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
@@ -107,9 +115,10 @@ export function AppShell({
|
|||||||
setPasswordSaving(true);
|
setPasswordSaving(true);
|
||||||
setPasswordError('');
|
setPasswordError('');
|
||||||
try {
|
try {
|
||||||
await adminApi.changeOwnPassword({ currentPassword, password: newPassword });
|
await portalSessionApi.changeOwnPassword(portal, { currentPassword, password: newPassword });
|
||||||
clearSession();
|
clearSession(portal);
|
||||||
dispatchSessionEvent('logout', { message: '密码修改成功,请重新登录' });
|
clearSessionRecovery(portal);
|
||||||
|
dispatchSessionEvent(portal, 'logout', { message: '密码修改成功,请重新登录' });
|
||||||
navigate(loginPath, { replace: true });
|
navigate(loginPath, { replace: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
|
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
|
||||||
@@ -120,18 +129,19 @@ export function AppShell({
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await adminApi.logout();
|
await portalSessionApi.logout(portal);
|
||||||
} finally {
|
} finally {
|
||||||
clearSession();
|
clearSession(portal);
|
||||||
dispatchSessionEvent('logout');
|
clearSessionRecovery(portal);
|
||||||
|
dispatchSessionEvent(portal, 'logout');
|
||||||
navigate(loginPath, { replace: true });
|
navigate(loginPath, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function continueSession() {
|
async function continueSession() {
|
||||||
try {
|
try {
|
||||||
const timing = await adminApi.touchSession();
|
const timing = await portalSessionApi.touch(portal);
|
||||||
updateSessionTiming(timing);
|
updateSessionTiming(portal, { ...timing, locked: false });
|
||||||
markUserActivity();
|
markUserActivity();
|
||||||
setIdleWarningSeconds(null);
|
setIdleWarningSeconds(null);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,14 +157,15 @@ export function AppShell({
|
|||||||
setUnlocking(true);
|
setUnlocking(true);
|
||||||
setUnlockError('');
|
setUnlockError('');
|
||||||
try {
|
try {
|
||||||
const timing = await adminApi.unlockSession(unlockPassword);
|
const timing = await portalSessionApi.unlock(portal, unlockPassword);
|
||||||
updateSessionTiming(timing);
|
updateSessionTiming(portal, { ...timing, locked: false });
|
||||||
markUserActivity();
|
markUserActivity();
|
||||||
setLocked(false);
|
setLocked(false);
|
||||||
|
setRoutesSuspended(false);
|
||||||
lockRequested.current = false;
|
lockRequested.current = false;
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
setIdleWarningSeconds(null);
|
setIdleWarningSeconds(null);
|
||||||
dispatchSessionEvent('unlocked');
|
dispatchSessionEvent(portal, 'unlocked');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUnlockError(error instanceof Error ? error.message : '解锁失败');
|
setUnlockError(error instanceof Error ? error.message : '解锁失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -170,8 +181,8 @@ export function AppShell({
|
|||||||
setReauthenticating(true);
|
setReauthenticating(true);
|
||||||
setReauthenticationError('');
|
setReauthenticationError('');
|
||||||
try {
|
try {
|
||||||
const timing = await adminApi.reauthenticate(reauthenticationPassword);
|
const timing = await portalSessionApi.reauthenticate(portal, reauthenticationPassword);
|
||||||
updateSessionTiming(timing);
|
updateSessionTiming(portal, timing);
|
||||||
reauthenticationResolve.current?.();
|
reauthenticationResolve.current?.();
|
||||||
reauthenticationResolve.current = null;
|
reauthenticationResolve.current = null;
|
||||||
reauthenticationReject.current = null;
|
reauthenticationReject.current = null;
|
||||||
@@ -204,14 +215,17 @@ export function AppShell({
|
|||||||
|
|
||||||
const onLocked = () => setLocked(true);
|
const onLocked = () => setLocked(true);
|
||||||
const onUnlocked = () => { setLocked(false); markUserActivity(); };
|
const onUnlocked = () => { setLocked(false); markUserActivity(); };
|
||||||
const onLogout = () => { clearSession(); navigate(loginPath, { replace: true }); };
|
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); };
|
||||||
window.addEventListener('cmpp-session-locked', onLocked);
|
const lockedEvent = sessionEvent(portal, 'locked');
|
||||||
window.addEventListener('cmpp-session-unlocked', onUnlocked);
|
const unlockedEvent = sessionEvent(portal, 'unlocked');
|
||||||
window.addEventListener('cmpp-session-logout', onLogout);
|
const logoutEvent = sessionEvent(portal, 'logout');
|
||||||
|
window.addEventListener(lockedEvent, onLocked);
|
||||||
|
window.addEventListener(unlockedEvent, onUnlocked);
|
||||||
|
window.addEventListener(logoutEvent, onLogout);
|
||||||
|
|
||||||
let channel: BroadcastChannel | undefined;
|
let channel: BroadcastChannel | undefined;
|
||||||
try {
|
try {
|
||||||
channel = new BroadcastChannel('cmpp-session');
|
channel = new BroadcastChannel(sessionChannel(portal));
|
||||||
channel.onmessage = (event: MessageEvent<{ type?: string }>) => {
|
channel.onmessage = (event: MessageEvent<{ type?: string }>) => {
|
||||||
if (event.data?.type === 'locked') onLocked();
|
if (event.data?.type === 'locked') onLocked();
|
||||||
if (event.data?.type === 'unlocked') onUnlocked();
|
if (event.data?.type === 'unlocked') onUnlocked();
|
||||||
@@ -230,12 +244,17 @@ export function AppShell({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
const session = readSession();
|
const session = readSession(portal);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now >= Date.parse(session.absoluteExpiresAt)) {
|
if (now >= Date.parse(session.absoluteExpiresAt)) {
|
||||||
clearSession();
|
saveSessionRecovery(portal, {
|
||||||
dispatchSessionEvent('logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
|
returnUrl: `${location.pathname}${location.search}${location.hash}`,
|
||||||
|
code: 'SESSION_ABSOLUTE_TIMEOUT',
|
||||||
|
message: '登录已达到最长有效期,请重新登录后继续。',
|
||||||
|
});
|
||||||
|
clearSession(portal);
|
||||||
|
dispatchSessionEvent(portal, 'logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
|
||||||
navigate(loginPath, { replace: true });
|
navigate(loginPath, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -244,7 +263,7 @@ export function AppShell({
|
|||||||
lockRequested.current = true;
|
lockRequested.current = true;
|
||||||
setLocked(true);
|
setLocked(true);
|
||||||
setIdleWarningSeconds(null);
|
setIdleWarningSeconds(null);
|
||||||
void adminApi.lockSession().catch(() => undefined);
|
void portalSessionApi.lock(portal).catch(() => undefined);
|
||||||
} else if (remaining <= 5 * 60 * 1000) {
|
} else if (remaining <= 5 * 60 * 1000) {
|
||||||
setIdleWarningSeconds(Math.ceil(remaining / 1000));
|
setIdleWarningSeconds(Math.ceil(remaining / 1000));
|
||||||
} else {
|
} else {
|
||||||
@@ -254,15 +273,15 @@ export function AppShell({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
activityEvents.forEach((eventName) => window.removeEventListener(eventName, onActivity));
|
activityEvents.forEach((eventName) => window.removeEventListener(eventName, onActivity));
|
||||||
window.removeEventListener('cmpp-session-locked', onLocked);
|
window.removeEventListener(lockedEvent, onLocked);
|
||||||
window.removeEventListener('cmpp-session-unlocked', onUnlocked);
|
window.removeEventListener(unlockedEvent, onUnlocked);
|
||||||
window.removeEventListener('cmpp-session-logout', onLogout);
|
window.removeEventListener(logoutEvent, onLogout);
|
||||||
channel?.close();
|
channel?.close();
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
setReauthenticationHandler(undefined);
|
setReauthenticationHandler(undefined);
|
||||||
reauthenticationReject.current?.(new Error('身份验证已取消'));
|
reauthenticationReject.current?.(new Error('身份验证已取消'));
|
||||||
};
|
};
|
||||||
}, [loginPath, navigate]);
|
}, [location.hash, location.pathname, location.search, loginPath, navigate, portal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auditTotal <= 0 || typeof window === 'undefined') {
|
if (auditTotal <= 0 || typeof window === 'undefined') {
|
||||||
@@ -431,7 +450,7 @@ export function AppShell({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
<Outlet />
|
{routesSuspended ? null : <Outlet />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -453,7 +472,7 @@ export function AppShell({
|
|||||||
open={locked}
|
open={locked}
|
||||||
title="会话已安全锁定"
|
title="会话已安全锁定"
|
||||||
>
|
>
|
||||||
<p>由于长时间未操作,请输入当前密码继续使用。锁定超过 4 小时后需要完整登录。</p>
|
<p>由于长时间未操作,请输入当前密码继续使用。解锁后将返回当前页面;锁定超过 4 小时后需要完整登录。</p>
|
||||||
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
|
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
|
||||||
{unlockError ? <p className="login-error">{unlockError}</p> : null}
|
{unlockError ? <p className="login-error">{unlockError}</p> : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,22 +10,21 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import { readSession } from '@/api/session';
|
|
||||||
import { AppShell } from '@/layouts/AppShell';
|
import { AppShell } from '@/layouts/AppShell';
|
||||||
|
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||||
|
|
||||||
export function ClientLayout() {
|
export function ClientLayout() {
|
||||||
const session = readSession();
|
return <PortalSessionBoundary portal="client">{(session) => <ClientAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
|
||||||
if (session?.portal !== 'client') {
|
}
|
||||||
return <Navigate to="/client/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function ClientAuthenticatedLayout({ session }: { session: import('@/api/session').LoginSession }) {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="短信服务平台"
|
title="短信服务平台"
|
||||||
subtitle="短信服务控制台"
|
subtitle="短信服务控制台"
|
||||||
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
||||||
loginPath="/client/login"
|
loginPath="/client/login"
|
||||||
|
portal="client"
|
||||||
userName={session.user.displayName}
|
userName={session.user.displayName}
|
||||||
userRole="企业管理员"
|
userRole="企业管理员"
|
||||||
navSections={[
|
navSections={[
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { portalSessionApi } from '@/api/adminApi';
|
||||||
|
import {
|
||||||
|
clearSession,
|
||||||
|
readSession,
|
||||||
|
saveSessionRecovery,
|
||||||
|
writeSession,
|
||||||
|
type LoginSession,
|
||||||
|
type Portal,
|
||||||
|
} from '@/api/session';
|
||||||
|
|
||||||
|
type PortalSessionBoundaryProps = {
|
||||||
|
portal: Portal;
|
||||||
|
children(session: LoginSession): ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PortalSessionBoundary({ portal, children }: PortalSessionBoundaryProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const targetRoute = useRef(`${location.pathname}${location.search}${location.hash}`);
|
||||||
|
const [state, setState] = useState<{ checking: boolean; session: LoginSession | null }>({
|
||||||
|
checking: true,
|
||||||
|
session: readSession(portal),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setState((current) => ({ ...current, checking: true }));
|
||||||
|
portalSessionApi.current(portal)
|
||||||
|
.then((session) => {
|
||||||
|
if (!active) return;
|
||||||
|
writeSession(session);
|
||||||
|
setState({ checking: false, session });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!active) return;
|
||||||
|
clearSession(portal);
|
||||||
|
saveSessionRecovery(portal, {
|
||||||
|
returnUrl: targetRoute.current,
|
||||||
|
message: error instanceof Error ? error.message : '登录会话已失效,请重新登录',
|
||||||
|
});
|
||||||
|
setState({ checking: false, session: null });
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [portal]);
|
||||||
|
|
||||||
|
if (state.checking) {
|
||||||
|
return (
|
||||||
|
<main aria-busy="true" aria-live="polite" className="page-loading-state">
|
||||||
|
<p>正在恢复登录会话…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!state.session) return <Navigate to={`/${portal}/login`} replace />;
|
||||||
|
return children(state.session);
|
||||||
|
}
|
||||||
@@ -841,13 +841,41 @@
|
|||||||
|
|
||||||
.ui-modal__mask {
|
.ui-modal__mask {
|
||||||
background: rgba(18, 18, 26, 0.48);
|
background: rgba(18, 18, 26, 0.48);
|
||||||
border: 0;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-modal__guard-layer {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(18, 18, 26, 0.32);
|
||||||
|
display: flex;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-modal__guard {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: var(--space-6);
|
||||||
|
width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-modal__guard > svg { color: var(--color-warning); margin-top: 2px; }
|
||||||
|
.ui-modal__guard h2 { font-size: var(--font-size-lg); margin: 0 0 var(--space-2); }
|
||||||
|
.ui-modal__guard p { color: var(--color-text-muted); line-height: var(--line-height-relaxed); margin: 0; }
|
||||||
|
.ui-modal__guard footer { display: flex; gap: var(--space-3); grid-column: 1 / -1; justify-content: flex-end; }
|
||||||
|
|
||||||
.ui-modal__panel {
|
.ui-modal__panel {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid rgba(18, 18, 26, 0.08);
|
border: 1px solid rgba(18, 18, 26, 0.08);
|
||||||
@@ -1247,6 +1275,9 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-modal__guard { padding: var(--space-5); }
|
||||||
|
.ui-modal__guard footer { display: grid; }
|
||||||
|
|
||||||
.ui-query-panel__grid,
|
.ui-query-panel__grid,
|
||||||
.ui-detail-info-grid,
|
.ui-detail-info-grid,
|
||||||
.ui-detail-progress-stats,
|
.ui-detail-progress-stats,
|
||||||
|
|||||||
@@ -4568,6 +4568,24 @@ h3 {
|
|||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-session-notice {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading-state {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.login-alert-message {
|
.login-alert-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-strong);
|
color: var(--color-text-strong);
|
||||||
@@ -4584,6 +4602,57 @@ h3 {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-log-export {
|
||||||
|
align-items: flex-end;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: min(100%, 680px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-export__result,
|
||||||
|
.system-log-export__error {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-log-export__result { background: var(--color-success-soft); color: var(--color-success-strong); }
|
||||||
|
.system-log-export__error { background: var(--color-danger-soft); color: var(--color-danger-strong); }
|
||||||
|
|
||||||
|
.risk-action-content { display: grid; gap: 18px; }
|
||||||
|
.risk-action-content section { display: grid; gap: 8px; }
|
||||||
|
.risk-action-content h3 { font-size: 14px; margin: 0; }
|
||||||
|
.risk-action-content ul { margin: 0; padding-left: 20px; }
|
||||||
|
.risk-action-identity { background: var(--color-surface-muted); border-radius: var(--radius-lg); display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 14px; }
|
||||||
|
.risk-action-identity div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.risk-action-identity span { color: var(--color-text-muted); font-size: 12px; }
|
||||||
|
.risk-action-identity strong { overflow-wrap: anywhere; }
|
||||||
|
.risk-action-blockers { color: var(--color-danger-strong); list-style: none; padding: 0 !important; }
|
||||||
|
.risk-action-blockers li, .risk-action-passed { align-items: center; display: flex; gap: 7px; }
|
||||||
|
.risk-action-passed { color: var(--color-success-strong); margin: 0; }
|
||||||
|
.risk-action-result { align-items: center; background: var(--color-success-soft); border-radius: var(--radius-lg); color: var(--color-success-strong); display: flex; gap: 10px; padding: 14px; }
|
||||||
|
.risk-action-result div { display: grid; gap: 4px; }
|
||||||
|
.risk-action-result span { font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) { .risk-action-identity { grid-template-columns: 1fr; } }
|
||||||
|
.delete-risk-dependencies { display: grid; gap: 8px; list-style: none; padding: 0 !important; }
|
||||||
|
.delete-risk-dependencies li { align-items: center; background: var(--color-surface-muted); border-radius: var(--radius-md); display: grid; gap: 3px; grid-template-columns: 1fr auto; padding: 10px 12px; }
|
||||||
|
.delete-risk-dependencies small { color: var(--color-text-muted); grid-column: 1 / -1; overflow-wrap: anywhere; }
|
||||||
|
.delete-risk-action .ui-textarea { min-height: 88px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.system-page-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
|
.system-log-export { align-items: stretch; max-width: none; }
|
||||||
|
.system-log-export > .ui-button { min-height: 44px; width: 100%; }
|
||||||
|
.system-log-export__result,
|
||||||
|
.system-log-export__error { align-items: stretch; flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
.system-filter-row {
|
.system-filter-row {
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
@@ -7298,6 +7367,8 @@ h3 {
|
|||||||
.report-material-table-head { background: var(--surface-muted); color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
.report-material-table-head { background: var(--surface-muted); color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||||
.report-material-row { border-top: 1px solid var(--border); cursor: pointer; }
|
.report-material-row { border-top: 1px solid var(--border); cursor: pointer; }
|
||||||
.report-material-row:hover { background: color-mix(in srgb, var(--primary) 3%, var(--surface)); }
|
.report-material-row:hover { background: color-mix(in srgb, var(--primary) 3%, var(--surface)); }
|
||||||
|
.report-material-row.is-disabled { cursor: not-allowed; opacity: 0.72; }
|
||||||
|
.report-material-row.is-disabled:hover { background: var(--surface); }
|
||||||
.report-material-row > span { display: grid; gap: 3px; }
|
.report-material-row > span { display: grid; gap: 3px; }
|
||||||
.report-material-row small, .report-material-row em { color: var(--text-muted); font-size: 12px; font-style: normal; }
|
.report-material-row small, .report-material-row em { color: var(--text-muted); font-size: 12px; font-style: normal; }
|
||||||
.report-material-batches { display: grid; gap: 0; }
|
.report-material-batches { display: grid; gap: 0; }
|
||||||
@@ -7305,6 +7376,16 @@ h3 {
|
|||||||
.report-material-batches article > div { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
|
.report-material-batches article > div { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
|
||||||
.report-material-batches article small { width: 100%; color: var(--text-muted); }
|
.report-material-batches article small { width: 100%; color: var(--text-muted); }
|
||||||
.report-material-batches article a { display: inline-flex; align-items: center; gap: 5px; color: var(--primary); }
|
.report-material-batches article a { display: inline-flex; align-items: center; gap: 5px; color: var(--primary); }
|
||||||
|
.report-batch-preflight { display: grid; gap: 16px; }
|
||||||
|
.report-batch-summary { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.report-batch-summary span { display: inline-flex; align-items: center; gap: 7px; padding: 9px 12px; border-radius: 8px; background: var(--surface-muted); font-weight: 700; }
|
||||||
|
.report-batch-preflight article { display: grid; gap: 10px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; }
|
||||||
|
.report-batch-preflight article > div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||||
|
.report-batch-preflight article small { color: var(--text-muted); }
|
||||||
|
.report-batch-preflight ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.report-batch-preflight li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 9px 10px; border-radius: 8px; background: var(--surface-muted); }
|
||||||
|
.report-batch-preflight li.is-eligible { border-left: 3px solid var(--success); }
|
||||||
|
.report-batch-preflight li.is-blocked { border-left: 3px solid var(--warning); }
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.channel-field-mapping-grid, .report-import-basic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.channel-field-mapping-grid, .report-import-basic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -9490,6 +9571,99 @@ h3 {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review,
|
||||||
|
.manual-recharge-result {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review > p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review dl {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review dl > div {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: minmax(110px, 0.7fr) minmax(0, 1.5fr);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review dl > div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review dt {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review dd {
|
||||||
|
display: grid;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
gap: var(--space-1);
|
||||||
|
justify-items: end;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review dd span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review .is-positive {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review .is-negative {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review__remark {
|
||||||
|
background: var(--color-warning-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-strong) !important;
|
||||||
|
padding: var(--space-4);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-result {
|
||||||
|
background: var(--color-success-soft);
|
||||||
|
border: 1px solid var(--color-success);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-result > strong {
|
||||||
|
color: var(--color-success);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.manual-recharge-review dl > div {
|
||||||
|
align-items: start;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-recharge-review dd {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-recharge-pagination {
|
.admin-recharge-pagination {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
@@ -10166,6 +10340,63 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
@media (max-width: 780px) {
|
||||||
|
.enterprise-flow-card {
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-stepper {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-stepper__item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-stepper__dot {
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-stepper__arrow {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-form-panel,
|
||||||
|
.enterprise-upload {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-upload {
|
||||||
|
padding: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-upload strong {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-upload .file-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-address-selects > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-address-selects .ui-field {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.client-signature-heading,
|
.client-signature-heading,
|
||||||
.client-drainage-panel__head {
|
.client-drainage-panel__head {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
Reference in New Issue
Block a user