18 Commits
Author SHA1 Message Date
hectorzhao 3af145abe5 docs: record code quality remediation rollout 2026-08-28 12:23:17 +08:00
hectorzhao fc4a6a7afc fix: enforce strong hashes in maintenance tools 2026-08-28 12:00:55 +08:00
hectorzhao d85ff85999 chore: add rollback-safe password rollout window 2026-08-28 11:47:08 +08:00
hectorzhao 2744690f9f fix: harden tenant auth and quality gates 2026-08-28 11:44:16 +08:00
hectorzhao c3bf8af3e6 docs: record dashboard direct-text deployment 2026-08-28 10:35:13 +08:00
hectorzhao 0b84370270 fix: remove dashboard amount wrappers 2026-08-28 10:27:31 +08:00
hectorzhao 171c7d38e8 docs: record dashboard metrics deployment 2026-08-28 10:20:38 +08:00
hectorzhao a70e9e2c07 fix: align client dashboard metrics 2026-08-28 10:14:31 +08:00
hectorzhao 4d4c1f39d4 docs: record client billing test deployment 2026-08-28 09:44:57 +08:00
hectorzhao 5bd347e010 fix: normalize client billing presentation 2026-08-28 09:37:59 +08:00
hectorzhao 088d7aaacb docs: record amount and log-filter deployment 2026-08-27 20:11:09 +08:00
hectorzhao 33fa3709d9 fix: clarify client amounts and log date filters 2026-08-27 19:50:12 +08:00
hectorzhao ec721bf95a docs: record test deployment verification 2026-08-27 18:48:04 +08:00
hectorzhao 070a951e7c fix: allow explicit HTTP origin in test environments 2026-08-27 18:32:21 +08:00
hectorzhao 01cffa4758 feat: refine client status and review views 2026-08-27 17:32:05 +08:00
hectorzhao d6edb88b76 fix: recover gateway callbacks and client send flows 2026-08-27 14:30:02 +08:00
hectorzhao a280b4bb22 feat: densify sms records and improve uplink matching 2026-08-27 10:15:08 +08:00
hectorzhao 898471423f fix: hide deleted signatures and fit uplink table 2026-08-26 16:10:46 +08:00
126 changed files with 3723 additions and 863 deletions
+6
View File
@@ -10,6 +10,8 @@ REDIS_URL=redis://127.0.0.1:6379
HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters.
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
# Keep false in production. Only isolated test environments without TLS may opt in to HTTP.
HTTP_API_ALLOW_INSECURE_ORIGIN=false
API_ENABLE_SEND_WORKER=true
API_SEND_WORKER_CONCURRENCY=50
API_WORKER_DATABASE_URL=
@@ -27,6 +29,10 @@ CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
SESSION_LOCK_RECOVERY_MS=14400000
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
SESSION_RECENT_AUTH_MS=1800000
# First-rollout safety only: both values must be set and the deadline must be within two hours.
# Omit them during normal operation so all password writes use scrypt.
PASSWORD_HASH_LEGACY_TRANSITION=false
PASSWORD_HASH_LEGACY_WRITE_UNTIL=
OPERATION_LOG_ARCHIVE_ENABLED=true
OPERATION_LOG_RETENTION_DAYS=180
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
+7
View File
@@ -12,8 +12,15 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
logs/
coverage/
gateway/gateway.exe
dump.rdb
*.tsbuildinfo
outputs/
tmp_*.py
tmp_*.ps1
tmp_*.sh
.DS_Store
Thumbs.db
+8
View File
@@ -9,4 +9,12 @@ module.exports = {
testMatch: ['**/*.spec.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
clearMocks: true,
coverageThreshold: {
global: {
statements: 59,
branches: 50,
functions: 60,
lines: 62,
},
},
};
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
"test:watch": "jest --watch",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
+7 -3
View File
@@ -1,13 +1,17 @@
import { defineConfig } from 'prisma/config';
const databaseUrl = process.env.DATABASE_URL?.trim();
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
if (!databaseUrl && !allowDevelopmentDefault) {
throw new Error('DATABASE_URL is required outside development and test environments');
}
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url:
process.env.DATABASE_URL ??
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
url: databaseUrl ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
},
});
@@ -0,0 +1,2 @@
ALTER TABLE "SmsUplinkMessage"
ADD COLUMN "gatewayMessageId" TEXT;
+16 -15
View File
@@ -2116,21 +2116,22 @@ model SmsReceiptAnomaly {
}
model SmsUplinkMessage {
id String @id @default(cuid())
eventId String? @unique
tenantId String?
applicationId String?
channelId String
messageRecordId String?
messageId String?
sequenceId Int?
phoneNumber String
destId String
content String
matchStatus String @default("unmatched")
matchReason String?
receivedAt DateTime
createdAt DateTime @default(now())
id String @id @default(cuid())
eventId String? @unique
tenantId String?
applicationId String?
channelId String
messageRecordId String?
messageId String?
gatewayMessageId String?
sequenceId Int?
phoneNumber String
destId String
content String
matchStatus String @default("unmatched")
matchReason String?
receivedAt DateTime
createdAt DateTime @default(now())
tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id])
+15 -6
View File
@@ -1,7 +1,9 @@
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from './current-session-user.decorator';
import { AuthService, LoginDto } from './auth.service';
import { AuthService } from './auth.service';
import { ChangeOwnPasswordDto, LoginDto, PasswordVerificationDto } from './auth.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
import type { SessionRequest } from './session-validation.middleware';
import { UsersService } from '../users/users.service';
@@ -26,6 +28,7 @@ export class AuthController {
}
@Post('admin/auth/login')
@UsePipes(strictValidationPipe)
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
let result: Awaited<ReturnType<AuthService['login']>>;
try {
@@ -43,6 +46,7 @@ export class AuthController {
}
@Post('client/auth/login')
@UsePipes(strictValidationPipe)
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
let result: Awaited<ReturnType<AuthService['login']>>;
try {
@@ -95,7 +99,9 @@ export class AuthController {
}
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
@UsePipes(strictValidationPipe)
async unlock(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto, @Res({ passthrough: true }) response: CookieResponse) {
const { password } = body;
this.assertSession(request);
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
@@ -105,7 +111,9 @@ export class AuthController {
}
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
@UsePipes(strictValidationPipe)
async reauthenticate(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto) {
const { password } = body;
this.assertSession(request);
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
@@ -122,9 +130,10 @@ export class AuthController {
}
@Post(['admin/auth/password', 'client/auth/password'])
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
@UsePipes(strictValidationPipe)
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: ChangeOwnPasswordDto) {
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) {
+17
View File
@@ -0,0 +1,17 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@IsString() @MinLength(1) @MaxLength(200) login!: string;
@IsString() @MinLength(1) @MaxLength(256) password!: string;
@IsString() @MinLength(1) @MaxLength(64) captchaId!: string;
@IsString() @MinLength(1) @MaxLength(32) captchaText!: string;
}
export class PasswordVerificationDto {
@IsString() @MinLength(1) @MaxLength(256) password!: string;
}
export class ChangeOwnPasswordDto {
@IsString() @MinLength(1) @MaxLength(256) currentPassword!: string;
@IsString() @MinLength(8) @MaxLength(256) password!: string;
}
+11 -3
View File
@@ -1,6 +1,6 @@
import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { hashPassword } from '../users/users.service';
import { legacyHashPassword } from './password-hasher';
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
const user = {
@@ -10,7 +10,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
email: 'user@example.com',
phone: '13800000000',
displayName: '用户',
passwordHash: hashPassword('secret1'),
passwordHash: legacyHashPassword('secret1'),
status: 'active',
deletedAt: null,
lockedUntil: null,
@@ -20,24 +20,32 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
};
return {
findByLogin: jest.fn().mockResolvedValue(user),
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(),
};
}
function createSessionsMock() {
const captchas = new Map<string, string>();
const failures = new Map<string, number>();
const record = {
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
};
return {
storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }),
consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }),
isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5),
recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }),
clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }),
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
};
}
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
const captcha = service.createCaptcha();
const captcha = await service.createCaptcha();
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
return service.login({
login: 'user@example.com',
+15 -43
View File
@@ -1,37 +1,20 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { hashPassword, UsersService } from '../users/users.service';
import { UsersService } from '../users/users.service';
import type { LoginDto } from './auth.dto';
import { SessionService } from './session.service';
export interface LoginDto {
login: string;
password: string;
captchaId: string;
captchaText: string;
}
type LoginPortal = 'admin' | 'client';
type CaptchaRecord = {
answer: string;
expiresAt: number;
};
const captchaStore = new Map<string, CaptchaRecord>();
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
createCaptcha() {
async createCaptcha() {
const left = Math.floor(10 + Math.random() * 40);
const right = Math.floor(1 + Math.random() * 9);
const captchaId = randomUUID();
captchaStore.set(captchaId, {
answer: String(left + right),
expiresAt: Date.now() + 5 * 60 * 1000,
});
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
return {
captchaId,
challenge: `${left} + ${right} = ?`,
@@ -44,12 +27,12 @@ export class AuthService {
if (!login || !data.password) {
throw new BadRequestException('login and password are required');
}
this.verifyCaptcha(data.captchaId, data.captchaText);
this.assertAnonymousNotLocked(login);
await this.verifyCaptcha(data.captchaId, data.captchaText);
await this.assertAnonymousNotLocked(login);
const user = await this.users.findByLogin(login);
if (!user) {
this.recordAnonymousFailure(login);
await this.sessions.recordAnonymousLoginFailure(login);
throw new UnauthorizedException('Invalid login or password');
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
@@ -59,7 +42,7 @@ export class AuthService {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('User is disabled or deleted');
}
if (user.passwordHash !== hashPassword(data.password)) {
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Invalid login or password');
}
@@ -75,7 +58,7 @@ export class AuthService {
}
await this.users.recordLoginSuccess(user.id);
anonymousFailures.delete(login);
await this.sessions.clearAnonymousLoginFailures(login);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return {
@@ -105,30 +88,19 @@ export class AuthService {
return this.sessions.markReauthenticated(token);
}
private verifyCaptcha(captchaId?: string, captchaText?: string) {
const record = captchaId ? captchaStore.get(captchaId) : undefined;
captchaStore.delete(captchaId ?? '');
if (!record || record.expiresAt < Date.now()) {
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
if (!answer) {
throw new BadRequestException('Captcha expired, refresh and try again');
}
if (record.answer !== captchaText?.trim()) {
if (answer !== captchaText?.trim()) {
throw new BadRequestException('Captcha is incorrect');
}
}
private assertAnonymousNotLocked(login: string) {
const current = anonymousFailures.get(login);
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
private async assertAnonymousNotLocked(login: string) {
if (await this.sessions.isAnonymousLoginLocked(login)) {
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
}
}
private recordAnonymousFailure(login: string) {
const current = anonymousFailures.get(login) ?? { count: 0 };
const count = current.count + 1;
anonymousFailures.set(login, {
count,
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
});
}
}
@@ -0,0 +1,14 @@
import { ForbiddenException, createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { SessionRequest } from './session-validation.middleware';
/**
* Returns the tenant bound to the authenticated client session.
* Request headers, query parameters and request bodies must never determine this value.
*/
export const CurrentTenantId = createParamDecorator((_: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<SessionRequest>();
if (request.authSession?.portal !== 'client' || !request.sessionTenantId) {
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '缺少可信企业上下文' });
}
return request.sessionTenantId;
});
+46
View File
@@ -0,0 +1,46 @@
import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher';
describe('password hasher', () => {
const originalTransition = process.env.PASSWORD_HASH_LEGACY_TRANSITION;
const originalUntil = process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL;
afterEach(() => {
if (originalTransition === undefined) delete process.env.PASSWORD_HASH_LEGACY_TRANSITION;
else process.env.PASSWORD_HASH_LEGACY_TRANSITION = originalTransition;
if (originalUntil === undefined) delete process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL;
else process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = originalUntil;
});
it('stores new passwords using versioned salted scrypt hashes', async () => {
const first = await hashPassword('correct horse battery staple');
const second = await hashPassword('correct horse battery staple');
expect(first).toMatch(/^\$scrypt\$v=1\$/);
expect(second).not.toBe(first);
await expect(verifyPassword('correct horse battery staple', first)).resolves.toBe(true);
await expect(verifyPassword('wrong password', first)).resolves.toBe(false);
expect(passwordNeedsRehash(first)).toBe(false);
});
it('recognizes and verifies legacy SHA-256 hashes for transparent migration', async () => {
const legacy = legacyHashPassword('legacy-password');
expect(isLegacySha256(legacy)).toBe(true);
expect(passwordNeedsRehash(legacy)).toBe(true);
await expect(verifyPassword('legacy-password', legacy)).resolves.toBe(true);
await expect(verifyPassword('wrong-password', legacy)).resolves.toBe(false);
});
it('rejects malformed or excessive scrypt parameters', async () => {
await expect(verifyPassword('password', '$scrypt$v=1$N=1048576,r=8,p=1$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU')).resolves.toBe(false);
});
it('supports only a bounded legacy-write window for the first compatibility rollout', async () => {
process.env.PASSWORD_HASH_LEGACY_TRANSITION = 'true';
process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 30 * 60 * 1000).toISOString();
await expect(hashPassword('transition-password')).resolves.toBe(legacyHashPassword('transition-password'));
process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
await expect(hashPassword('strong-password')).resolves.toMatch(/^\$scrypt\$/);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
const VERSION = 1;
const KEY_LENGTH = 32;
const DEFAULT_N = 32768;
const DEFAULT_R = 8;
const DEFAULT_P = 1;
const MAX_MEMORY = 64 * 1024 * 1024;
const MAX_LEGACY_WRITE_WINDOW_MS = 2 * 60 * 60 * 1000;
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
export async function hashPassword(password: string) {
if (legacyTransitionWriteEnabled()) return legacyHashPassword(password);
const salt = randomBytes(16);
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
N: DEFAULT_N,
r: DEFAULT_R,
p: DEFAULT_P,
maxmem: MAX_MEMORY,
});
return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`;
}
function legacyTransitionWriteEnabled() {
if (process.env.PASSWORD_HASH_LEGACY_TRANSITION !== 'true') return false;
const until = Date.parse(process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL ?? '');
const remaining = until - Date.now();
return Number.isFinite(until) && remaining > 0 && remaining <= MAX_LEGACY_WRITE_WINDOW_MS;
}
export async function verifyPassword(password: string, encoded: string) {
if (isLegacySha256(encoded)) {
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
const expected = Buffer.from(encoded, 'hex');
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
}
const parsed = parseScryptHash(encoded);
if (!parsed) return false;
const derived = await deriveScrypt(password, parsed.salt, parsed.hash.length, {
N: parsed.N,
r: parsed.r,
p: parsed.p,
maxmem: MAX_MEMORY,
});
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
}
export function passwordNeedsRehash(encoded: string) {
if (isLegacySha256(encoded)) return true;
const parsed = parseScryptHash(encoded);
return !parsed || parsed.version !== VERSION || parsed.N !== DEFAULT_N || parsed.r !== DEFAULT_R || parsed.p !== DEFAULT_P;
}
export function isLegacySha256(encoded: string) {
return LEGACY_SHA256.test(encoded);
}
export function legacyHashPassword(password: string) {
return createHash('sha256').update(password).digest('hex');
}
function parseScryptHash(encoded: string) {
const match = /^\$scrypt\$v=(\d+)\$N=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(encoded);
if (!match) return undefined;
const [, version, N, r, p, salt, hash] = match;
const params = { version: Number(version), N: Number(N), r: Number(r), p: Number(p) };
if (!Number.isInteger(params.N) || params.N < 2 || params.N > DEFAULT_N
|| !Number.isInteger(params.r) || params.r < 1 || params.r > DEFAULT_R
|| !Number.isInteger(params.p) || params.p < 1 || params.p > DEFAULT_P) return undefined;
try {
const decodedSalt = Buffer.from(salt, 'base64url');
const decodedHash = Buffer.from(hash, 'base64url');
if (decodedSalt.length < 16 || decodedHash.length !== KEY_LENGTH) return undefined;
return { ...params, salt: decodedSalt, hash: decodedHash };
} catch {
return undefined;
}
}
function deriveScrypt(password: string, salt: Buffer, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }) {
return new Promise<Buffer>((resolve, reject) => {
scryptCallback(password, salt, keyLength, options, (error, derivedKey) => {
if (error) reject(error);
else resolve(derivedKey);
});
});
}
@@ -1,4 +1,4 @@
import { UnauthorizedException } from '@nestjs/common';
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
const record = {
@@ -6,10 +6,10 @@ const record = {
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
};
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest {
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token', tenantId?: string): SessionRequest {
return {
originalUrl: path,
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
header: jest.fn((name: string) => name === 'cookie' ? cookie : name === 'x-tenant-id' ? tenantId : undefined),
};
}
@@ -17,7 +17,7 @@ describe('SessionValidationMiddleware', () => {
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
it('accepts an active Redis session and exposes its user and record', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
const currentRequest = request();
@@ -32,7 +32,7 @@ describe('SessionValidationMiddleware', () => {
});
it('rejects a session after the user session version changes', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 4 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
@@ -41,7 +41,7 @@ describe('SessionValidationMiddleware', () => {
});
it('only lets a locked session reach unlock and logout endpoints', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
@@ -53,7 +53,7 @@ describe('SessionValidationMiddleware', () => {
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 prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', 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');
@@ -62,6 +62,33 @@ describe('SessionValidationMiddleware', () => {
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
expect(currentRequest.sessionToken).toBe('client-token');
expect(currentRequest.sessionTenantId).toBe('tenant-a');
});
it('rejects a client session whose user is not bound to a tenant', async () => {
const clientRecord = { ...record, portal: 'client' as const };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, 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);
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token'), {}, jest.fn()))
.rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects and audits a client tenant header that disagrees with the authenticated user', async () => {
const clientRecord = { ...record, portal: 'client' as const };
const prisma = {
user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) },
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
};
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token', 'tenant-b'), {}, jest.fn()))
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'CLIENT_TENANT_MISMATCH' }) });
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: 'tenant-a', userId: 'user-1', action: 'security.client_tenant_mismatch' }),
});
});
it('does not accept an admin cookie for a client route', async () => {
+22 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { ForbiddenException, Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
@@ -8,6 +8,7 @@ export type SessionRequest = {
url?: string;
sessionUserId?: string;
sessionToken?: string;
sessionTenantId?: string;
authSession?: AuthSessionRecord;
};
@@ -38,7 +39,7 @@ export class SessionValidationMiddleware implements NestMiddleware {
const user = await this.prisma.user.findUnique({
where: { id: result.record.userId },
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
select: { id: true, tenantId: true, status: true, deletedAt: true, sessionVersion: true },
});
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
await this.sessions.remove(token);
@@ -51,6 +52,25 @@ export class SessionValidationMiddleware implements NestMiddleware {
request.sessionUserId = user.id;
request.sessionToken = token;
request.authSession = result.record;
if (portal === 'client') {
if (!user.tenantId) {
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '当前客户端账号未关联企业' });
}
const suppliedTenantId = request.header('x-tenant-id')?.trim();
if (suppliedTenantId && suppliedTenantId !== user.tenantId) {
await this.prisma.operationLog.create({
data: {
tenantId: user.tenantId,
userId: user.id,
action: 'security.client_tenant_mismatch',
resource: 'auth_session',
detail: { suppliedTenantId },
},
});
throw new ForbiddenException({ code: 'CLIENT_TENANT_MISMATCH', message: '请求企业与登录企业不一致' });
}
request.sessionTenantId = user.tenantId;
}
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
if (result.status === 'locked' && !isSessionRecoveryRoute) {
if (result.newlyLocked) {
+62
View File
@@ -21,6 +21,9 @@ export type SessionValidationResult =
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
const SESSION_PREFIX = 'cmpp:auth:session:';
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
@@ -123,6 +126,61 @@ export class SessionService implements OnModuleDestroy {
return this.client.del(this.key(token));
}
async storeCaptcha(captchaId: string, answer: string, ttlSeconds: number) {
try {
await this.client.set(`${CAPTCHA_PREFIX}${captchaId}`, answer, 'EX', ttlSeconds);
} catch {
throw new ServiceUnavailableException('验证码服务暂不可用');
}
}
async consumeCaptcha(captchaId: string) {
try {
return await this.client.getdel(`${CAPTCHA_PREFIX}${captchaId}`);
} catch {
throw new ServiceUnavailableException('验证码服务暂不可用');
}
}
async isAnonymousLoginLocked(login: string) {
try {
return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`));
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
async recordAnonymousLoginFailure(login: string) {
const digest = this.loginDigest(login);
const failureKey = `${ANONYMOUS_FAILURE_PREFIX}${digest}`;
const lockKey = `${ANONYMOUS_LOCK_PREFIX}${digest}`;
try {
const count = Number(await this.client.eval(
`local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
if count >= tonumber(ARGV[2]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[1]) end
return count`,
2,
failureKey,
lockKey,
24 * 60 * 60,
5,
));
return count;
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
async clearAnonymousLoginFailures(login: string) {
const digest = this.loginDigest(login);
try {
await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`);
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
isRecentlyAuthenticated(record: AuthSessionRecord) {
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
}
@@ -195,6 +253,10 @@ export class SessionService implements OnModuleDestroy {
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
}
private loginDigest(login: string) {
return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex');
}
private get client() {
if (!this.redis) {
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
+8 -4
View File
@@ -1,6 +1,9 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { ClientBillingEstimateDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import {
@@ -125,14 +128,15 @@ export class ClientBillingController {
constructor(private readonly billing: BillingService) {}
@Get('orders')
listRechargeOrders(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listRechargeOrders(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.billing.listRechargeOrders(tenantId);
}
@Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body);
@UsePipes(strictValidationPipe)
estimateSmsCost(@CurrentTenantId() tenantId: string, @Body() body: ClientBillingEstimateDto) {
return this.billing.estimateSmsCost({ ...body, tenantId });
}
}
@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { ClientCertificationSubmissionDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { CertificationService, ReviewCertificationDto } from './certification.service';
@ApiTags('client-certification')
@Controller('client/enterprise-certification')
@@ -10,13 +12,14 @@ export class ClientCertificationController {
constructor(private readonly certifications: CertificationService) {}
@Get()
list(@TenantId() tenantId?: string) {
list(@CurrentTenantId() tenantId: string) {
return this.certifications.list(tenantId);
}
@Post()
submit(@Body() body: SubmitCertificationDto) {
return this.certifications.submit(body);
@UsePipes(strictValidationPipe)
submit(@CurrentTenantId() tenantId: string, @Body() body: ClientCertificationSubmissionDto) {
return this.certifications.submit({ ...body, tenantId });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { BadRequestException } from '@nestjs/common';
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto';
import { strictValidationPipe } from './strict-validation.pipe';
function validate<T>(metatype: new () => T, value: unknown) {
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
}
describe('strict client write DTOs', () => {
it('accepts an import confirmation without a client-supplied phones array', async () => {
await expect(validate(ClientImportConfirmDto, {
content: '【测试】验证码 ${code}',
importContent: 'phone,code\n13800000001,1234',
})).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
});
it('rejects a direct batch task without validated phone numbers', async () => {
await expect(validate(ClientBatchTaskDto, { content: '【测试】通知' })).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a client-supplied operator identity', async () => {
await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' }))
.rejects.toBeInstanceOf(BadRequestException);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { Type } from 'class-transformer';
import { PartialType } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
IsUrl,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
export class ClientCertificationSubmissionDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
@IsOptional() @IsObject() materials?: Record<string, unknown>;
}
export class ClientTaskBaseDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string;
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
@IsOptional() @IsString() @MaxLength(64) scheduledAt?: string;
@IsOptional() @IsObject() variables?: Record<string, unknown>;
@IsOptional() @IsString() @MaxLength(64) requestedAt?: string;
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
}
export class ClientBatchTaskDto extends ClientTaskBaseDto {
@IsArray() @ArrayMaxSize(100000) @Matches(/^1\d{10}$/, { each: true }) phones!: string[];
}
export class ClientImportPreviewDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
@IsOptional() @IsIn(['utf8', 'gbk']) encoding?: 'utf8' | 'gbk';
@IsOptional() @IsIn([',', '\t']) delimiter?: ',' | '\t';
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
}
export class ClientImportConfirmDto extends ClientTaskBaseDto {
@IsString() @MinLength(1) @MaxLength(5_000_000) importContent!: string;
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
}
export class ClientBillingEstimateDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
@IsOptional() @Type(() => Number) @Min(0) unitPrice?: number;
@IsOptional() @IsString() @MaxLength(64) taskId?: string;
}
export class ClientSmsApplicationDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) scene?: string;
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
@IsOptional() @IsString() @MaxLength(64) cmppAccount?: string;
@IsOptional() @IsString() @MaxLength(64) cmppEnterpriseCode?: string;
@IsOptional() @IsString() @MaxLength(32) cmppApplicationExtension?: string;
@IsOptional() @IsBoolean() cmppAccessNumberFillEnabled?: boolean;
@IsOptional() @IsString() @MaxLength(32) cmppAccessNumberFillPrefix?: string;
@IsOptional() @IsString() @MaxLength(4096) passwordCipher?: string;
@IsOptional() @IsBoolean() interfaceEnabled?: boolean;
@IsOptional() @IsString() @MaxLength(32) interfaceType?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) cmppMaxConnections?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1000) cmppWindowSize?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) dailyLimit?: number;
@IsOptional() @Type(() => Number) @Min(0) customerUnitPrice?: number;
@IsOptional() @IsString() @MaxLength(32) queuePriority?: string;
@IsOptional() @IsString() @MaxLength(32) templateMismatchMode?: string;
@IsOptional() @IsBoolean() downstreamReceiptRetryEnabled?: boolean;
@IsOptional() @IsBoolean() downstreamUplinkRetryEnabled?: boolean;
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) ipAllowlist?: string[];
}
export class ClientSmsSignatureDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
@IsOptional() @IsObject() drainageInfo?: Record<string, unknown>;
}
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
}
export class ClientDrainageInfoDto {
@IsString() @MinLength(1) @MaxLength(200) siteName!: string;
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
@IsOptional() @IsObject() reportValues?: Record<string, unknown>;
}
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
export class ClientSignatureMaterialDto {
@IsOptional() @IsString() @MaxLength(64) fileObjectId?: string;
@IsString() @MinLength(1) @MaxLength(64) materialType!: string;
@IsString() @MinLength(1) @MaxLength(200) title!: string;
@IsOptional() @IsString() @MaxLength(2000) description?: string;
}
class TemplateVariableDto {
@IsString() @MinLength(1) @MaxLength(64) name!: string;
@IsOptional() @IsString() @MaxLength(500) example?: string;
@IsOptional() @IsBoolean() required?: boolean;
}
export class ClientSmsTemplateDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MaxLength(64) applicationId!: string;
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
@IsString() @MinLength(1) @MaxLength(200) name!: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string;
@IsOptional() @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => TemplateVariableDto) variables?: TemplateVariableDto[];
}
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
}
export class ClientStatusChangeDto {
@IsOptional() @IsString() @MaxLength(32) status?: string;
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
@IsOptional() @IsBoolean() force?: boolean;
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
}
+9
View File
@@ -0,0 +1,9 @@
import { ValidationPipe } from '@nestjs/common';
export const strictValidationPipe = new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
stopAtFirstError: false,
transformOptions: { enableImplicitConversion: false },
});
@@ -1,8 +1,8 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.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')
@@ -28,13 +28,13 @@ export class ClientDeletionGovernanceController {
constructor(private readonly deletions: DeletionGovernanceService) {}
@Get(':type/:id/preflight')
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) {
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @CurrentTenantId() 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) {
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
}
}
+11 -10
View File
@@ -1,7 +1,8 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { OpenApiService } from './open-api.service';
@ApiTags('client-http-open-api-management')
@@ -9,13 +10,13 @@ import { OpenApiService } from './open-api.service';
export class ClientOpenApiController {
constructor(private readonly service: OpenApiService) {}
@Get() getConfig(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getConfig(applicationId, tenantId); }
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listCredentials(applicationId, tenantId); }
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @TenantId() tenantId?: string) { return this.service.createCredential(applicationId, body, tenantId, true); }
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @TenantId() tenantId?: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @TenantId() tenantId?: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listRequestLogs(applicationId, tenantId); }
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @TenantId() tenantId?: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getConfig(applicationId, tenantId); }
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listCredentials(applicationId, tenantId); }
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById?: string) { return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true); }
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @CurrentTenantId() tenantId: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @CurrentTenantId() tenantId: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listRequestLogs(applicationId, tenantId); }
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @CurrentTenantId() tenantId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
}
+21
View File
@@ -26,6 +26,27 @@ describe('OpenApiService', () => {
}
});
it('only permits a plain HTTP public origin when an isolated test environment explicitly opts in', async () => {
const previousOrigin = process.env.HTTP_API_PUBLIC_ORIGIN;
const previousAllowInsecure = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/';
delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
};
try {
const service = new OpenApiService(prisma as never, {} as never);
await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN');
process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = 'true';
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }));
} finally {
if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin;
if (previousAllowInsecure === undefined) delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
else process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = previousAllowInsecure;
}
});
it('replays a completed request for the same idempotency key and body', async () => {
const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
+5 -3
View File
@@ -428,10 +428,12 @@ function httpApiPublicOrigin() {
const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, '');
if (!configured) return undefined;
const url = new URL(configured);
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
const insecureHttpExplicitlyAllowed = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:';
if ((url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
// This value is copied into customer integration parameters, so fail closed instead of
// publishing an insecure or path-dependent endpoint when deployment config is wrong.
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址');
// publishing an insecure or path-dependent endpoint unless an isolated test environment
// has explicitly opted into plain HTTP.
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN');
}
return url.origin;
}
@@ -431,10 +431,12 @@ export class AdminSystemLogsController {
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('range') range?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, page: Number(page), pageSize: Number(pageSize) });
return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
}
@Get()
@@ -445,14 +447,16 @@ export class AdminSystemLogsController {
@Query('level') level?: string,
@Query('module') module?: string,
@Query('range') range?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
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, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
}
@Post('exports')
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string }) {
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) {
return this.operations.exportSystemLogs(body);
}
}
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { OperationsService } from './operations.service';
@ApiTags('client-operations')
@@ -10,18 +10,18 @@ export class ClientOperationsController {
constructor(private readonly operations: OperationsService) {}
@Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
listBatchTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string) {
return this.operations.listClientBatchTasks({ tenantId, status });
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
}
@Get('messages')
listMessages(
@TenantId() tenantId?: string,
@CurrentTenantId() tenantId: string,
@Query('applicationId') applicationId?: string,
@Query('taskId') taskId?: string,
@Query('messageId') messageId?: string,
@@ -49,35 +49,38 @@ export class ClientOperationsController {
}
@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, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listUplinkMessages(@CurrentTenantId() 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, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true)
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
}
@Get('dashboard')
dashboard(@TenantId() tenantId?: string) {
dashboard(@CurrentTenantId() tenantId: string) {
return this.operations.clientDashboard({ tenantId });
}
@Get('system-logs')
systemLogs(
@TenantId() tenantId?: string,
@CurrentTenantId() tenantId: string,
@Query('keyword') keyword?: string,
@Query('level') level?: string,
@Query('module') module?: string,
@Query('range') range?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
return this.operations.systemLogs({ tenantId, keyword, level, module, range, createdAtFrom, createdAtTo, 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 },
@CurrentTenantId() tenantId: string,
@Body() body: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string },
) {
return this.operations.exportSystemLogs(body, userId);
return this.operations.exportSystemLogs({ ...body, tenantId }, userId);
}
}
@@ -29,6 +29,8 @@ export interface OperationLogQuery {
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}
+1
View File
@@ -303,6 +303,7 @@ export function clientUplinkView(message: Record<string, any>) {
applicationId: message.applicationId ?? null,
messageRecordId: message.messageRecordId ?? null,
messageId: message.messageId ?? null,
gatewayMessageId: message.gatewayMessageId ?? null,
phoneNumber: message.phoneNumber,
destId: message.destId,
content: message.content,
@@ -359,6 +359,50 @@ describe('OperationsService', () => {
});
});
it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => {
const prisma = createPrismaMock();
prisma.smsUplinkMessage.findMany.mockResolvedValue([{
id: 'uplink-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: null,
gatewayMessageId: '8412634832294102675',
phoneNumber: '13800000001',
destId: '10690000',
content: 'TD',
matchStatus: 'matched',
matchReason: '手机号 72 小时窗口唯一匹配',
receivedAt: new Date('2026-08-26T01:00:00.000Z'),
createdAt: new Date('2026-08-26T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
messageRecord: {
id: 'record-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
content: '通知内容',
billingUnits: 1,
amountCents: 325,
status: 'delivered',
queuedAt: new Date('2026-08-25T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
},
matchCandidates: [],
}]);
const service = new OperationsService(prisma as never);
const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' });
expect(uplink).toMatchObject({
messageId: null,
gatewayMessageId: '8412634832294102675',
messageRecordId: 'record-1',
matchStatus: 'matched',
messageRecord: { id: 'record-1', messageId: 'MSG-1' },
});
});
it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => {
const prisma = createPrismaMock();
prisma.smsMessageRecord.findMany.mockResolvedValue([{
@@ -846,6 +890,22 @@ describe('OperationsService', () => {
});
});
it('applies an explicit Beijing date interval to operation logs', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.systemLogs({ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-27' });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
createdAt: {
gte: new Date('2026-08-20T16:00:00.000Z'),
lte: new Date('2026-08-27T15:59:59.999Z'),
},
}),
}));
});
it('exports filtered operation logs with a traceable operation id', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
+8 -3
View File
@@ -31,7 +31,7 @@ async systemLogs(query: OperationLogQuery) {
const where: Prisma.OperationLogWhereInput = {
tenantId: query.tenantId,
userId: query.userId,
createdAt: createdAtRange(query.range),
createdAt: this.operationLogDateRange(query),
resource: query.module && query.module !== 'all' ? query.module : undefined,
AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined,
OR: query.keyword ? [
@@ -73,7 +73,7 @@ async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) {
const where: Prisma.OperationLogWhereInput = {
tenantId: effectiveQuery.tenantId,
userId: effectiveQuery.userId,
createdAt: createdAtRange(effectiveQuery.range),
createdAt: this.operationLogDateRange(effectiveQuery),
resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined,
AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined,
OR: effectiveQuery.keyword ? [
@@ -107,9 +107,14 @@ async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) {
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 },
filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range, createdAtFrom: effectiveQuery.createdAtFrom, createdAtTo: effectiveQuery.createdAtTo },
};
}
private operationLogDateRange(query: OperationLogQuery) {
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : createdAtRange(query.range);
}
private async resolveClientTenantId(userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } },
+4
View File
@@ -36,6 +36,10 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
const poolMax = Number.isInteger(configuredPoolMax) && configuredPoolMax > 0
? configuredPoolMax
: protocolLogRole ? 4 : outboxRole ? 6 : callbackRole ? 16 : workerRole ? 8 : 32;
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
if (!databaseUrl && !allowDevelopmentDefault) {
throw new Error(`DATABASE_URL is required for CMPP process role ${processRole}`);
}
const databasePool = new Pool({
connectionString: databaseUrl
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
@@ -76,4 +76,18 @@ describe('ProtocolLogsService', () => {
}),
}));
});
it('applies an explicit Beijing date interval to protocol logs', async () => {
const service = new ProtocolLogsService(prisma as never);
await service.list({ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-27' });
expect(prisma.protocolInteractionLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
createdAt: {
gte: new Date('2026-08-20T16:00:00.000Z'),
lte: new Date('2026-08-27T15:59:59.999Z'),
},
}),
}));
});
});
+10 -1
View File
@@ -1,6 +1,7 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { parseDateBoundary } from '../operations/operations.helpers';
export type ProtocolLogInput = {
protocol: 'cmpp' | 'http';
@@ -34,6 +35,8 @@ export type ProtocolLogQuery = {
status?: string;
keyword?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
};
@@ -115,7 +118,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
direction: selected(query.direction),
eventType: selected(query.eventType),
status: selected(query.status),
createdAt: rangeWhere(query.range),
createdAt: protocolLogDateRange(query),
OR: query.keyword ? [
{ messageId: { contains: query.keyword } },
{ gatewayMessageId: { contains: query.keyword } },
@@ -197,6 +200,12 @@ function rangeWhere(range?: string): Prisma.DateTimeFilter | undefined {
return undefined;
}
function protocolLogDateRange(query: ProtocolLogQuery): Prisma.DateTimeFilter | undefined {
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : rangeWhere(query.range);
}
function sanitizeDetail(detail?: Record<string, unknown> | null): Prisma.InputJsonValue | undefined {
if (!detail) return undefined;
const blocked = /password|secret|token|signature|authorization|content|raw|body/i;
@@ -1,7 +1,10 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { EvaluateSmsTaskDto, RiskReviewService } from './risk-review.service';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ClientBatchTaskDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { RiskReviewService } from './risk-review.service';
@ApiTags('client-risk-review')
@Controller('client/risk-review')
@@ -9,13 +12,13 @@ export class ClientRiskReviewController {
constructor(private readonly riskReview: RiskReviewService) {}
@Post('tasks/evaluate')
evaluateTask(@Body() body: EvaluateSmsTaskDto) {
return this.riskReview.evaluateTask(body);
@UsePipes(strictValidationPipe)
evaluateTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
return this.riskReview.evaluateTask({ ...body, tenantId, createdById });
}
@Get('tasks')
listTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
listTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string) {
return this.riskReview.listTasks(tenantId, status);
}
}
@@ -45,6 +45,7 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
},
smsBatchTask: {
count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([]),
},
riskHitRecord: {
createMany: jest.fn(),
@@ -152,6 +153,21 @@ describe('RiskReviewService', () => {
}));
});
it('adds the associated batch task number to SMS review rows', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findMany.mockResolvedValue([{ id: 'review-task-1', taskNo: 'REVIEW-001' }]);
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'batch-1', taskNo: 'BATCH-001', riskTaskId: 'review-task-1' }]);
const service = new RiskReviewService(prisma as never);
await expect(service.listTasks(undefined, 'pending_review')).resolves.toEqual([
expect.objectContaining({ batchTask: { id: 'batch-1', taskNo: 'BATCH-001' } }),
]);
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith({
where: { riskTaskId: { in: ['review-task-1'] } },
select: { id: true, taskNo: true, riskTaskId: true },
});
});
it('filters SMS review tasks by their submission time', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findMany.mockResolvedValue([]);
+8 -2
View File
@@ -202,8 +202,8 @@ export class RiskReviewService {
});
}
listTasks(tenantId?: string, status?: string, submittedAtFrom?: string, submittedAtTo?: string) {
return this.prisma.smsSendTask.findMany({
async listTasks(tenantId?: string, status?: string, submittedAtFrom?: string, submittedAtTo?: string) {
const tasks = await this.prisma.smsSendTask.findMany({
where: {
tenantId,
status,
@@ -231,6 +231,12 @@ export class RiskReviewService {
},
orderBy: { createdAt: 'desc' },
});
const batchTasks = tasks.length ? await this.prisma.smsBatchTask.findMany({
where: { riskTaskId: { in: tasks.map((task) => task.id) } },
select: { id: true, taskNo: true, riskTaskId: true },
}) : [];
const batchByRiskTaskId = new Map(batchTasks.map((task) => [task.riskTaskId, { id: task.id, taskNo: task.taskNo }]));
return tasks.map((task) => ({ ...task, batchTask: batchByRiskTaskId.get(task.id) ?? null }));
}
listPendingTasks() {
@@ -1,7 +1,9 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientImportPreviewDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { SendChainService } from './send-chain.service';
@ApiTags('client-send-chain')
@@ -10,46 +12,42 @@ export class ClientSendChainController {
constructor(private readonly sendChain: SendChainService) {}
@Post('batch-tasks')
createBatchTask(@Body() body: CreateBatchTaskDto) {
return this.sendChain.createBatchTask(body);
@UsePipes(strictValidationPipe)
createBatchTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
return this.sendChain.createBatchTask({ ...body, tenantId, sourceType: 'client', createdById });
}
@Post('imports/preview')
previewImport(@Body() body: ImportPreviewDto) {
return this.sendChain.previewImport(body);
@UsePipes(strictValidationPipe)
previewImport(@CurrentTenantId() tenantId: string, @Body() body: ClientImportPreviewDto) {
return this.sendChain.previewImport({ ...body, tenantId });
}
@Post('imports/confirm')
confirmImport(@Body() body: ConfirmImportDto) {
return this.sendChain.confirmImport(body);
@UsePipes(strictValidationPipe)
confirmImport(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientImportConfirmDto) {
return this.sendChain.confirmImport({ ...body, tenantId, sourceType: 'client', createdById });
}
@Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listBatchTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.sendChain.listBatchTasksPage({ tenantId: requireTenantId(tenantId), status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
: this.sendChain.listBatchTasks(requireTenantId(tenantId), status, 'client');
? this.sendChain.listBatchTasksPage({ tenantId, status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
: this.sendChain.listBatchTasks(tenantId, status, 'client');
}
@Get('batch-tasks/:id')
getBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.getBatchTask(taskId, requireTenantId(tenantId), 'client');
getBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
return this.sendChain.getBatchTask(taskId, tenantId, 'client');
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.listClientTaskMessages(taskId, requireTenantId(tenantId));
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
return this.sendChain.listClientTaskMessages(taskId, tenantId);
}
@Post('batch-tasks/:id/cancel')
cancelBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.cancelBatchTask(taskId, requireTenantId(tenantId), 'client');
cancelBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
return this.sendChain.cancelBatchTask(taskId, tenantId, 'client');
}
}
function requireTenantId(tenantId?: string) {
if (!tenantId) {
throw new BadRequestException('Tenant context is required');
}
return tenantId;
}
+2 -1
View File
@@ -118,6 +118,7 @@ export interface GatewayUplinkEventDto {
eventId?: string;
traceId?: string;
messageId?: string;
gatewayMessageId?: string;
channelId: string;
sequenceId?: number;
phoneNumber: string;
@@ -233,7 +234,7 @@ export interface ImportPreviewDto {
requiredVariables?: string[];
}
export interface ConfirmImportDto extends CreateBatchTaskDto {
export interface ConfirmImportDto extends Omit<CreateBatchTaskDto, 'phones'> {
importContent: string;
requiredVariables?: string[];
}
@@ -3749,6 +3749,7 @@ describe('SendChainService', () => {
});
await service.handleUplink({
messageId: 'MSG-1',
gatewayMessageId: '8412634832294102675',
channelId: 'channel-1',
sequenceId: 8,
phoneNumber: '13800000001',
@@ -3761,7 +3762,7 @@ describe('SendChainService', () => {
data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }),
});
expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', content: 'TD' }),
data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', gatewayMessageId: '8412634832294102675', content: 'TD' }),
});
});
@@ -45,6 +45,7 @@ export class SendDownstreamDeliveryService {
messageRecordId: match.messageRecordId,
channelId: data.channelId,
messageId: data.messageId,
gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId,
phoneNumber: data.phoneNumber,
destId: data.destId,
@@ -142,6 +142,19 @@ describe('SignatureRetirementService dimensions', () => {
page: 2,
pageSize: 10,
});
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
expect(query.strings?.join('?')).toContain('signature."auditStatus" <> \'deleted\'');
});
it('does not count deleted signatures as unread retirement warnings', async () => {
const prisma = { $queryRaw: jest.fn().mockResolvedValue([{ count: 2 }]) };
const unreadService = new SignatureRetirementService(prisma as never);
await expect(unreadService.unreadCount()).resolves.toEqual({ count: 2 });
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
const sql = query.strings?.join('?') ?? '';
expect(sql).toContain('JOIN "SmsSignature" signature ON signature.id = detection."signatureId"');
expect(sql).toContain('signature."auditStatus" <> \'deleted\'');
});
it('requires a reason for temporary and permanent suppression', async () => {
@@ -128,6 +128,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
LEFT JOIN "SmsApplication" application ON application.id = detection."applicationId"
WHERE message."createdAt" >= ${range?.gte}
AND message."createdAt" <= ${range?.lte}
AND signature."auditStatus" <> 'deleted'
AND (${dimensionType}::text IS NULL OR detection."dimensionType" = ${dimensionType})
AND (${tenantId}::text IS NULL OR detection."tenantId" = ${tenantId})
AND (${applicationId}::text IS NULL OR application.id = ${applicationId})
@@ -167,8 +168,18 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
async unreadCount() {
const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey());
const count = await this.prisma.signatureRetirementMessage.count({ where: { createdAt: range, isRead: false, suppressed: false } });
return { count };
const rows = await this.prisma.$queryRaw<Array<{ count: number }>>(Prisma.sql`
SELECT COUNT(*)::integer AS count
FROM "SignatureRetirementMessage" message
JOIN "SignatureRetirementDetection" detection ON detection.id = message."detectionId"
JOIN "SmsSignature" signature ON signature.id = detection."signatureId"
WHERE message."createdAt" >= ${range?.gte}
AND message."createdAt" <= ${range?.lte}
AND message."isRead" = FALSE
AND message.suppressed = FALSE
AND signature."auditStatus" <> 'deleted'
`);
return { count: rows[0]?.count ?? 0 };
}
async markRead(id: string) {
@@ -1,20 +1,11 @@
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import {
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
CreateSmsDrainageInfoDto,
CreateSmsSignatureDto,
CreateSmsTemplateDto,
StatusChangeDto,
UpdateSmsTemplateDto,
UpdateSmsDrainageInfoDto,
UpdateSmsSignatureDto,
} from './sms-config.contracts';
import { ClientDrainageInfoDto, ClientDrainageInfoUpdateDto, ClientSignatureMaterialDto, ClientSmsApplicationDto, ClientSmsSignatureDto, ClientSmsSignatureUpdateDto, ClientSmsTemplateDto, ClientSmsTemplateUpdateDto, ClientStatusChangeDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import { SmsConfigService } from './sms-config.service';
@ApiTags('client-sms-config')
@@ -23,29 +14,30 @@ export class ClientSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
@Get('applications')
listApplications(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listApplications(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.smsConfig.listApplications(tenantId);
}
@Get('application-options')
listApplicationOptions(@TenantId() tenantId?: string) {
listApplicationOptions(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listApplicationOptions(tenantId);
}
@Post('applications')
createApplication(@Body() body: CreateSmsApplicationDto) {
return this.smsConfig.createApplication(body);
@UsePipes(strictValidationPipe)
createApplication(@CurrentTenantId() tenantId: string, @Body() body: ClientSmsApplicationDto) {
return this.smsConfig.createApplication({ ...body, tenantId });
}
@Get('applications/:id/cmpp-params')
getApplicationCmppParams(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
getApplicationCmppParams(@Param('id') applicationId: string, @CurrentTenantId() tenantId: string) {
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
}
@Get('applications/:id/report-fields')
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) {
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @CurrentTenantId() tenantId: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
}
@@ -56,110 +48,122 @@ export class ClientSmsConfigController {
@Post('applications/:id/secret/reset')
@RequireRecentAuthentication()
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.resetApplicationSecret(applicationId, body);
@UsePipes(strictValidationPipe)
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
return this.smsConfig.resetClientApplicationSecret(applicationId, { ...body, operatorId }, tenantId);
}
@Post('applications/:id/status')
@RequireRecentAuthentication()
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.changeApplicationStatus(applicationId, body);
@UsePipes(strictValidationPipe)
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId);
}
@Get('signatures')
listSignatures(@TenantId() tenantId?: string) {
listSignatures(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listClientSignatures(tenantId);
}
@Get('signature-options')
listSignatureOptions(@TenantId() tenantId?: string) {
listSignatureOptions(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listSignatureOptions(tenantId);
}
@Get('signatures-workspace')
getSignatureWorkspace(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
getSignatureWorkspace(@CurrentTenantId() tenantId: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.smsConfig.getClientSignatureWorkspace(tenantId, { keyword, applicationId, status, page: Number(page), pageSize: Number(pageSize) });
}
@Post('signatures')
async createSignature(@Body() body: CreateSmsSignatureDto, @TenantId() tenantId?: string) {
const signature = await this.smsConfig.createSignature({ ...body, tenantId: tenantId ?? body.tenantId });
return this.smsConfig.getClientSignatureView(signature.id, tenantId ?? body.tenantId);
@UsePipes(strictValidationPipe)
async createSignature(@Body() body: ClientSmsSignatureDto, @CurrentTenantId() tenantId: string) {
const signature = await this.smsConfig.createSignature({ ...body, tenantId });
return this.smsConfig.getClientSignatureView(signature.id, tenantId);
}
@Put('signatures/:id')
async updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
async updateSignature(@Param('id') signatureId: string, @Body() body: ClientSmsSignatureUpdateDto, @CurrentTenantId() tenantId: string) {
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Post('signatures/:id/materials')
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: Omit<CreateSignatureMaterialDto, 'signatureId'>) {
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
@UsePipes(strictValidationPipe)
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: ClientSignatureMaterialDto, @CurrentTenantId() tenantId: string) {
return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId);
}
@Get('drainage-infos')
listDrainageInfos(@TenantId() tenantId?: string) {
listDrainageInfos(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listClientDrainageInfos(tenantId);
}
@Post('signatures/:id/drainage-infos')
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: ClientDrainageInfoDto, @CurrentTenantId() tenantId: string) {
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
}
@Put('drainage-infos/:id')
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: ClientDrainageInfoUpdateDto, @CurrentTenantId() tenantId: string) {
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
}
@Post('drainage-infos/:id/status')
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
await this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
@UsePipes(strictValidationPipe)
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
await this.smsConfig.changeDrainageInfoStatus(itemId, { ...body, operatorId }, tenantId);
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
}
@Post('signatures/:id/submit')
async submitSignature(@Param('id') signatureId: string, @TenantId() tenantId?: string) {
async submitSignature(@Param('id') signatureId: string, @CurrentTenantId() tenantId: string) {
await this.smsConfig.submitSignature(signatureId, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Post('signatures/:id/status')
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() 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, operatorId }, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Get('templates')
listTemplates(@TenantId() tenantId?: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listTemplates(@CurrentTenantId() tenantId: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) })
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
}
@Post('templates')
createTemplate(@Body() body: CreateSmsTemplateDto, @TenantId() tenantId?: string) {
return this.smsConfig.createTemplate({ ...body, tenantId: tenantId ?? body.tenantId });
@UsePipes(strictValidationPipe)
createTemplate(@Body() body: ClientSmsTemplateDto, @CurrentTenantId() tenantId: string) {
return this.smsConfig.createTemplate({ ...body, tenantId });
}
@Put('templates/:id')
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
updateTemplate(@Param('id') templateId: string, @Body() body: ClientSmsTemplateUpdateDto, @CurrentTenantId() tenantId: string) {
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
}
@Post('templates/:id/submit')
submitTemplate(@Param('id') templateId: string, @TenantId() tenantId?: string) {
submitTemplate(@Param('id') templateId: string, @CurrentTenantId() tenantId: string) {
return this.smsConfig.submitTemplate(templateId, tenantId);
}
@Post('templates/:id/status')
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
changeTemplateStatus(@Param('id') templateId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() 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, operatorId }, tenantId);
}
}
+35
View File
@@ -223,14 +223,47 @@ export class SmsSignatureService {
updatedAt: true,
},
},
reportTasks: {
select: { channelId: true, carrier: true, status: true, approvalScope: true, reportType: true, drainageItemId: true },
},
_count: { select: { reportMaterials: true } },
},
orderBy: { updatedAt: 'desc' },
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
take: query.pageSize,
});
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : [];
const hasCommonDrainageFields = await this.prisma.commonReportField.count({
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
}).then((count) => count > 0);
return signatures.map((signature) => {
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const applicationChannels = [...new Map(routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted')
.map((channel) => [channel.id, channel])).values()];
const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature');
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = applicationChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status
?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status
?? 'pending');
return [carrier, summarizeReportStatuses(statuses)];
}));
const drainageCarrierReportSummary = Object.fromEntries(signature.drainageItems.map((item) => {
const targets = applicationChannels.filter((channel) => hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
const tasks = signature.reportTasks.filter((task) => task.reportType === 'drainage' && task.drainageItemId === item.id);
return [item.id, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = carrierTargets.map((channel) => tasks.find((task) => task.channelId === channel.id)?.status ?? 'pending');
return [carrier, summarizeReportStatuses(statuses)];
}))];
}));
return {
id: signature.id,
tenantId: signature.tenantId,
@@ -247,6 +280,8 @@ export class SmsSignatureService {
application: signature.application,
materials: signature.materials,
submittedMaterialCount: signature.materials.length + signature._count.reportMaterials,
carrierReportSummary,
drainageCarrierReportSummary,
reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {},
drainageInfo: {
links: signature.drainageItems.map((item) => ({
+15
View File
@@ -81,10 +81,20 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
return this.applications.resetApplicationSecret(applicationId, data);
}
async resetClientApplicationSecret(applicationId: string, data: StatusChangeDto, tenantId: string) {
await this.applications.getApplication(applicationId, tenantId);
return this.applications.resetApplicationSecret(applicationId, data);
}
async changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
return this.lifecycle.changeApplicationStatus(applicationId, data);
}
async changeClientApplicationStatus(applicationId: string, data: StatusChangeDto, tenantId: string) {
await this.applications.getApplication(applicationId, tenantId);
return this.lifecycle.changeApplicationStatus(applicationId, data);
}
async getApplicationDeactivationPreview(applicationId: string) {
return this.lifecycle.getApplicationDeactivationPreview(applicationId);
}
@@ -177,6 +187,11 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
return this.signatures.createSignatureMaterial(data);
}
async createClientSignatureMaterial(data: CreateSignatureMaterialDto, tenantId: string) {
await this.signatures.getClientSignatureView(data.signatureId, tenantId);
return this.signatures.createSignatureMaterial(data);
}
async submitSignature(signatureId: string, tenantId?: string) {
return this.signatures.submitSignature(signatureId, tenantId);
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
@@ -72,7 +72,7 @@ export class UsersController {
@Get('client/users')
@ApiOkResponse({ type: [ClientUserResponseDto] })
listClient(
@TenantId() tenantId?: string,
@CurrentTenantId() tenantId: string,
@Query('displayName') displayName?: string,
@Query('login') login?: string,
@Query('status') status?: string,
@@ -82,31 +82,31 @@ export class UsersController {
@Post('client/users')
@RequireRecentAuthentication()
createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
createClient(@CurrentTenantId() tenantId: string, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Put('client/users/:id')
@RequireRecentAuthentication()
updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
updateClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Post('client/users/:id/status')
@RequireRecentAuthentication()
changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
changeClientStatus(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId);
}
@Post('client/users/:id/password')
@RequireRecentAuthentication()
changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
changeClientPassword(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.changePassword(id, { ...body, operatorId }, tenantId);
}
@Delete('client/users/:id')
@RequireRecentAuthentication()
removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
removeClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
return this.users.remove(id, operatorId, tenantId);
}
+28 -2
View File
@@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { hashPassword, UsersService } from './users.service';
import { legacyHashPassword, verifyPassword } from '../auth/password-hasher';
import { UsersService } from './users.service';
function createPrismaMock() {
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
@@ -10,6 +11,7 @@ function createPrismaMock() {
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
count: jest.fn().mockResolvedValue(2),
},
role: {
@@ -206,7 +208,7 @@ describe('UsersService', () => {
it('returns a safe view after changing the current password', async () => {
const prisma = createPrismaMock();
prisma.user.findFirst.mockResolvedValue({
id: 'user-1', tenantId: 'tenant-1', username: 'admin', passwordHash: hashPassword('old-password'),
id: 'user-1', tenantId: 'tenant-1', username: 'admin', passwordHash: legacyHashPassword('old-password'),
sessionVersion: 3, failedLoginCount: 0, roles: [{ role: { code: 'enterprise_admin' } }],
});
prisma.user.update.mockResolvedValue({
@@ -223,6 +225,30 @@ describe('UsersService', () => {
expect(user).not.toHaveProperty('failedLoginCount');
});
it('transparently upgrades a legacy password hash after successful login verification', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
const legacyHash = legacyHashPassword('correct-password');
await expect(service.verifyLoginPassword('user-1', 'correct-password', legacyHash)).resolves.toBe(true);
expect(prisma.user.updateMany).toHaveBeenCalledWith({
where: { id: 'user-1', passwordHash: legacyHash },
data: { passwordHash: expect.stringMatching(/^\$scrypt\$/) },
});
const upgradedHash = prisma.user.updateMany.mock.calls[0][0].data.passwordHash;
await expect(verifyPassword('correct-password', upgradedHash)).resolves.toBe(true);
});
it('does not rewrite a legacy password hash after failed login verification', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
await expect(service.verifyLoginPassword('user-1', 'wrong-password', legacyHashPassword('correct-password'))).resolves.toBe(false);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it('forbids deleting the current signed-in user', async () => {
const prisma = createPrismaMock();
prisma.user.findFirst.mockResolvedValue({
+22 -10
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { hashPassword, passwordNeedsRehash, verifyPassword } from '../auth/password-hasher';
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
@@ -131,6 +131,7 @@ export class UsersService {
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
const tenantId = scopeTenantId ?? data.tenantId;
const role = await this.ensureRole(roleCode);
const passwordHash = await hashPassword(data.password);
const user = await this.mapUniqueConflict(() => this.prisma.user.create({
data: {
tenantId,
@@ -138,7 +139,7 @@ export class UsersService {
email: normalizeOptional(data.email),
phone: normalizeOptional(data.phone),
displayName: data.displayName,
passwordHash: hashPassword(data.password),
passwordHash,
status: data.status ?? 'active',
roles: { create: [{ roleId: role.id }] },
},
@@ -208,9 +209,10 @@ export class UsersService {
throw new BadRequestException('password must be at least 6 characters');
}
const current = await this.getExisting(id, scopeTenantId);
const passwordHash = await hashPassword(data.password);
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
data: { passwordHash, failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
@@ -259,12 +261,13 @@ export class UsersService {
throw new BadRequestException('currentPassword and a password of at least 6 characters are required');
}
const current = await this.getExisting(id);
if (current.passwordHash !== hashPassword(currentPassword)) {
if (!await verifyPassword(currentPassword, current.passwordHash)) {
throw new BadRequestException('当前密码不正确');
}
const passwordHash = await hashPassword(password);
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
data: { passwordHash, failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
@@ -276,12 +279,25 @@ export class UsersService {
throw new BadRequestException('请输入当前密码');
}
const current = await this.getExisting(id);
if (current.passwordHash !== hashPassword(password)) {
if (!await verifyPassword(password, current.passwordHash)) {
throw new BadRequestException('当前密码不正确');
}
if (passwordNeedsRehash(current.passwordHash)) {
const passwordHash = await hashPassword(password);
await this.prisma.user.updateMany({ where: { id, passwordHash: current.passwordHash }, data: { passwordHash } });
}
return current;
}
async verifyLoginPassword(id: string, password: string, storedHash: string) {
if (!await verifyPassword(password, storedHash)) return false;
if (passwordNeedsRehash(storedHash)) {
const passwordHash = await hashPassword(password);
await this.prisma.user.updateMany({ where: { id, passwordHash: storedHash }, data: { passwordHash } });
}
return true;
}
listRoles() {
return this.prisma.role.findMany({
include: { permissions: { include: { permission: true } } },
@@ -440,7 +456,3 @@ function normalizeOptional(value?: string | null) {
const next = value?.trim();
return next ? next : null;
}
export function hashPassword(password: string) {
return createHash('sha256').update(password).digest('hex');
}
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -110,8 +110,9 @@
修复:
- 已接入 `GET /api/client/operations/uplink-messages`
- 详情弹窗按上行 `messageId` `GET /api/client/operations/messages`,展示真实匹配的下发记录
- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复
- 详情优先展示上行列表响应中已匹配的真实 `messageRecord`;仅对历史兼容数据在缺少内嵌记录且存在平台 `messageId` 时回`GET /api/client/operations/messages`
- 页面分开展示供应商 MO 的上行网关消息 ID 与关联平台消息 ID,不再把缺少平台 `messageId` 误判为没有匹配记录
- 客户端保持只读,不提供越权的运营黑名单写操作。
### 运营端短信上行记录仍是静态表
@@ -133,8 +134,9 @@
修复:
- 已接入 `GET /api/admin/operations/uplink-messages`,返回真实上行记录、企业和通道信息。
- 详情弹窗按上行 `messageId` `GET /api/admin/operations/messages`,展示真实匹配的下发记录
- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复
- 详情优先展示上行列表响应中已匹配的真实 `messageRecord`;仅对历史兼容数据在缺少内嵌记录且存在平台 `messageId` 时回`GET /api/admin/operations/messages`
- 页面分开展示供应商 MO 的上行网关消息 ID 与关联平台消息 ID,并显示匹配状态和原因
- 已匹配或人工认领到企业应用后,恢复“加入应用黑名单”按钮并调用真实 `POST /api/admin/dictionaries/blacklists/enterprise`;未匹配应用时不允许写入。
### 运营端短信任务进度仍是静态任务
@@ -0,0 +1,216 @@
# 批量发送任务人工审核状态优化方案
## 1. 文档目的
本文档记录客户端创建批量发送任务并进入运营人工审核期间的当前代码行为、已确认问题、建议修复范围和验收标准,供主任务实施与测试使用。
本文结论来自 2026-08-27 对当前工作区代码的静态核对,不代表测试环境或预生产环境的实时运行结果。实施完成后仍需通过接口、数据库和页面进行完整验证。
## 2. 当前代码行为
客户端创建批量发送任务并命中人工审核规则时,系统会创建两类关联记录:
1. `SmsSendTask`:运营审核任务。
- 风控判定结果为 `pending_review`
- 任务状态为 `pending_review`
- 运营端“短信审核”页面默认查询该状态,可执行通过或驳回。
2. `SmsBatchTask`:实际批量发送任务。
- 状态同样为 `pending_review`
- `riskTaskId` 指向对应的 `SmsSendTask`
- 客户端批量任务页面和运营端短信任务进度页面均可查询到该记录。
短信明细也会创建,并通过 `reviewTaskId` 或批量任务的 `riskTaskId` 与审核任务关联。在审核通过前,短信不应进入实际发送流程。
主要代码位置:
- `api/src/risk-review/risk-review.service.ts`:风控判定及 `SmsSendTask` 创建。
- `api/src/send-chain/send-batch-entry.service.ts``SmsBatchTask`、短信明细创建及审核任务关联。
- `api/src/risk-review/admin-risk-review.controller.ts`:运营审核任务查询与审核入口。
- `api/src/send-chain/send-review-continuation.service.ts`:审核决定后的发送流程续接。
- `src/apps/admin/AdminSmsAuditPage.tsx`:运营端短信审核页面。
- `src/apps/admin/AdminSmsTaskProgressPage.tsx`:运营端短信任务进度页面。
- `src/apps/client/ClientBatchTasksPage.tsx`:客户端批量任务页面。
## 3. 已确认问题
### 3.1 客户端审核状态显示错误
`ClientBatchTasksPage.tsx` 的状态归一化逻辑没有识别 `pending_review`,所有未单独处理的状态都会落入 `sending`,因此待人工审核任务被错误显示为“发送中”。
### 3.2 运营端任务进度状态显示错误
`src/apps/admin/sms-task-progress/taskModel.ts` 同样没有将 `pending_review` 作为独立任务状态,导致运营端短信任务进度页面也将待审核任务显示为“发送中”。
### 3.3 审核字段在客户端映射时丢失
后端任务类型和接口结果已包含以下字段:
- `auditStatus`
- `reviewReason`
- `rejectReason`
- `riskTaskId`
但客户端页面的 `mapTask()` 没有保留这些字段,因此客户无法看到任务正在审核、触发原因或驳回原因。
### 3.4 客户端展示了不可用的“终止”操作
客户端将 `pending_review` 映射为 `sending` 后,会启用“终止”按钮;但客户端取消接口只允许取消后端状态为 `scheduled` 的任务。对待审核任务点击该按钮必然返回失败。
### 3.5 存在孤立审核任务风险
当前流程先调用风控服务创建 `SmsSendTask`,之后才继续余额校验、余额冻结和 `SmsBatchTask` 创建。如果后续步骤失败,可能留下没有实际批量任务与短信明细的审核记录。
该问题属于流程可靠性风险,建议与前端状态修复分开实施和提交。
## 4. 优化目标
1. 客户端和运营端都能准确识别并显示“待人工审核”。
2. 审核期间不得误导用户认为短信正在发送。
3. 页面操作权限必须与后端状态机一致。
4. 审核原因、驳回原因及关联审核任务应可追踪。
5. 审核通过或驳回后,审核任务、批量任务、短信明细和余额状态保持一致。
6. 后续消除审核任务先创建导致的孤立记录风险。
## 5. 建议实施方案
### 5.1 第一阶段:最小充分修复
#### 5.1.1 客户端批量任务页面
- 将 `pending_review` 增加为独立展示状态。
- 标签显示“待人工审核”,使用警告色。
- 审核期间发送进度保持为零,不显示为正在发送。
- 在详情中展示 `reviewReason`
- 审核驳回后展示 `rejectReason`
- `pending_review` 状态隐藏或禁用“终止”按钮。
- 只有后端状态为 `scheduled` 时才允许调用客户端取消接口。
#### 5.1.2 运营端短信任务进度页面
- 将 `pending_review` 增加为独立展示状态。
- 标签显示“待人工审核”,使用警告色。
- 审核期间不展示发送中进度语义。
- 保留 `rawStatus``auditStatus``reviewReason``rejectReason``riskTaskId`
- 待审核任务不提供普通“终止发送”操作。
- 条件允许时增加“前往审核”入口,定位到对应的 `SmsSendTask`
#### 5.1.3 运营端短信审核页面
- 同时展示审核任务号和关联批量任务号。
- 展示企业、应用、号码数量、短信内容、触发规则和审核原因。
- 审核完成后允许跳转到批量任务进度详情。
- 若暂不实现跨页面跳转,至少保证审核任务号、批量任务号及关联关系可见。
#### 5.1.4 统一前端状态模型
建议将客户端和运营端重复的状态归一化逻辑提取到共享模块,统一维护:
- 状态显示名称。
- 标签颜色。
- 进度语义。
- 允许执行的操作。
- 是否显示审核原因或失败原因。
禁止继续使用“未识别状态默认等于发送中”的策略。未识别状态应显示“未知状态”,并保留后端原始状态值,便于发现状态协议漂移。
### 5.2 建议统一状态映射
| 后端状态 | 页面显示 | 主要允许操作 |
| --- | --- | --- |
| `pending_review` | 待人工审核 | 查看 |
| `scheduled` | 等待定时发送 | 客户端可取消 |
| `ready``queued` | 等待发送 | 查看 |
| `sending``submitted` | 发送中 | 按权限终止 |
| `completed``finished``done` | 已完成 | 查看 |
| `rejected` | 审核驳回 | 查看审核原因 |
| `canceled``cancelled``terminated` | 已取消或已终止 | 查看 |
| `failed` | 发送失败 | 查看失败原因 |
| 未识别状态 | 未知状态(附原始值) | 查看 |
### 5.3 第二阶段:流程可靠性优化
前端状态修复完成后,再处理孤立审核任务风险。建议选择以下一种方案:
1. 将审核任务、批量任务、短信明细及可纳入数据库的余额操作放入一致事务边界。
2. 如果余额服务或队列操作无法进入同一事务,则增加失败补偿:
- 将已创建的审核任务标记为 `canceled` 或专用失败状态。
- 写入失败原因。
- 释放已冻结资源。
- 确保运营端默认待审核列表不再展示该任务。
同时应保证状态联动:
- 审核通过:`SmsSendTask` 完成审核,`SmsBatchTask` 转为 `ready``scheduled`,符合条件的短信进入发送流程。
- 审核驳回:审核任务、批量任务和短信明细同步转为拒绝终态,并释放冻结余额。
- 审核取消或创建失败:关联记录全部进入不可继续审核、不可继续发送的明确终态。
- 已完成、已驳回、已取消的审核任务不得再次执行审核决定。
## 6. 建议测试用例
### 6.1 创建与可见性
1. 创建命中人工审核规则的立即发送批量任务。
2. 创建命中人工审核规则的定时发送批量任务。
3. 验证数据库同时存在关联的 `SmsSendTask``SmsBatchTask`
4. 验证客户端批量任务页面能够看到任务并显示“待人工审核”。
5. 验证运营端短信审核页面能够看到对应审核任务。
6. 验证运营端短信任务进度页面能够看到批量任务并显示“待人工审核”。
### 6.2 审核期间行为
1. 验证任务没有进入短信发送队列。
2. 验证发送数量和发送进度保持为零。
3. 验证客户端不能对 `pending_review` 调用定时任务取消接口。
4. 验证客户端和运营端均能查看审核原因。
5. 验证页面不再显示“发送中”。
### 6.3 审核通过
1. 立即发送任务审核通过后进入 `ready` 或后续合法发送状态。
2. 定时任务审核通过后进入 `scheduled`
3. 验证短信只入队一次,不发生重复发送。
4. 验证审核任务、批量任务、短信明细状态一致。
5. 验证余额冻结及最终扣费状态正确。
### 6.4 审核驳回
1. 审核任务转为 `rejected`
2. 批量任务和短信明细同步进入拒绝终态。
3. 客户端和运营端均显示“审核驳回”及原因。
4. 验证冻结余额正确释放。
5. 验证任务不会进入发送队列。
### 6.5 异常与补偿
1. 模拟余额不足。
2. 模拟余额冻结失败。
3. 模拟批量任务创建失败。
4. 模拟短信明细创建失败。
5. 验证失败后不存在仍可由运营人员通过的孤立审核任务。
6. 验证重复提交审核决定具有幂等保护。
## 7. 验收标准
本次最小修复满足以下条件即可验收:
1. 客户端和运营端任务进度页面都将 `pending_review` 显示为“待人工审核”。
2. 审核期间不显示为“发送中”,发送进度不增长。
3. 客户端待审核任务不再出现不可用的“终止”操作。
4. 客户端详情能够显示审核原因和驳回原因。
5. 运营审核页面能够找到并处理对应审核任务。
6. 审核通过和驳回后,两端状态能够正确刷新。
7. 新增或更新前后端自动化测试,覆盖状态映射、操作权限和审核状态联动。
8. 相关测试用例及 `docs/testing-progress.md` 在主任务实施时同步更新。
## 8. 推荐实施顺序
1. 增加共享状态类型和状态映射。
2. 修复客户端批量任务页面。
3. 修复运营端任务进度页面。
4. 补充审核原因和关联任务展示。
5. 增加前端单元测试和后端状态联动测试。
6. 执行真实接口、数据库、页面和队列验证。
7. 单独设计并实施事务或失败补偿机制。
第一阶段应作为独立提交,避免将低风险展示修复与高影响流程事务改造混在同一个提交中。
+238
View File
@@ -0,0 +1,238 @@
# CMPP 平台代码质量审查报告
- 报告日期:2026-08-28
- 审查类型:当前工作区只读基线审计
- 审查仓库基线:`171c7d38e8f17de0dd83570603da316d47c0d015`;对应业务代码基线为其父提交 `a70e9e2c078a6109dd87cf8e45aeff956faf24e8`
- 分支状态:`main`,相对 `origin/main` 超前 12 个提交
- 审查范围:React/Vite 前端、NestJS API、Prisma/PostgreSQL、Redis/BullMQ 接口、Go Gateway、自动化测试、构建与工程配置
- 明确未执行:提交、推送、部署、数据库写入、短信发送、远程环境变更、生产/预生产利用验证
## 1. 执行摘要
本次综合判定为:**有条件不通过,当前版本不建议继续发布**。
代码已经具备较好的业务复杂度承载能力:API 45 个测试套件共 532 项全部通过,Gateway 全包测试与 `go vet` 通过,前后端正式构建、Prisma Schema、Gateway 队列契约和已有安全部署校验均通过;PostgreSQL 连接池、事务级 advisory lock、`FOR UPDATE SKIP LOCKED` 和核心业务复合索引也已实际存在。
但审查发现 2 个 P0 发布阻断问题:
1. 客户端租户范围来自浏览器可修改的 `x-tenant-id` 请求头,后端多个客户端接口直接信任该值,部分写接口还直接信任请求体中的 `tenantId`,存在跨租户读取、创建、修改或删除业务数据的风险。
2. 用户密码使用无盐单次 SHA-256 保存和比对。数据库一旦泄漏,相同密码可直接关联,并可使用 GPU/字典进行高速离线破解,不符合生产账号系统的密码存储要求。
因此,本报告不把“532 项测试通过”解释为生产可发布,也不把现有 mock 单测解释为真实 PostgreSQL、Redis、MinIO、Gateway 和 CMPP 全链验收完成。
## 2. 质量评级
| 维度 | 评级 | 结论 |
|---|---:|---|
| 业务正确性与并发设计 | B | 关键发送链测试较多,存在 advisory lock、幂等和队列 claim 机制;未做本轮真实全链复验。 |
| 安全性 | D | 存在租户越权和弱密码哈希两个 P0。 |
| 自动化测试 | C | API/Gateway 测试数量较好;API 语句覆盖率 59.61%、分支覆盖率 50.99%,前端测试文件为 0,且没有覆盖率门槛。 |
| 前端性能 | C- | 主 JavaScript 包 2.12 MBgzip 626 KB,所有页面同步进入主包。 |
| 可维护性 | C | 存在超大 Service/Page、无 lint/format 门禁、生产类型仍依赖 `src/mock`。 |
| 数据库工程 | B | Schema 有效,核心复合索引、连接池和锁策略较完整;未对真实数据执行 `EXPLAIN (ANALYZE, BUFFERS)`。 |
| 依赖与仓库卫生 | C- | 依赖审计仍有 2 个 high advisory;构建缓存被跟踪,临时文件和产物缺少统一忽略策略。 |
综合参考分:**58/100**。该分数用于排序治理工作,不等价于功能验收通过率。
## 3. 发布阻断问题
### CQ-SEC-001 / P0:客户端租户身份可由请求方伪造
**证据链**
- `src/api/session.ts:175-177` 从浏览器 `localStorage` 中的展示会话读取 `tenantId`
- `src/api/core/httpClient.ts:90-92``117-119` 将该值写入 `x-tenant-id`
- `api/src/common/tenant-id.decorator.ts:3-6` 直接返回客户端请求头中的 `x-tenant-id`,没有绑定已认证用户。
- `api/src/auth/session-validation.middleware.ts:25-55` 验证会话用户、状态和 portal,但没有把用户所属租户写成服务端可信租户上下文,也没有校验请求头租户等于用户租户。
- `api/src/certification/certification.controller.ts:13-19` 的客户端认证查询信任请求头,提交接口直接接受请求体。
- `api/src/certification/certification.service.ts:49-82` 的提交逻辑直接使用 `data.tenantId` 创建认证记录、更新目标企业并写操作日志。
- `api/src/sms-config/client-sms-config.controller.ts:26-163` 大量客户端查询和写接口依赖同一 `@TenantId()`;其中应用密钥重置、应用状态变更、材料创建等接口甚至没有传入租户范围。
**影响**
已登录企业管理员可以修改浏览器存储、请求头、请求体或资源 ID,尝试访问其他企业的认证、应用、签名、模板、账务、日志等数据。文件服务已有“从当前会话用户反查租户”的正确实现,但该保护没有成为客户端 API 的统一规则。
**最小修复要求**
1. 在服务端认证中间件中根据 `sessionUserId` 获取并写入可信 `request.tenantId`;客户端控制器只能读取该字段。
2. 客户端路由禁止把 `x-tenant-id` 或请求体 `tenantId` 作为授权依据;即使保留请求头,也只能用于一致性检查,不得决定数据范围。
3. 所有按资源 ID 的客户端读写在数据库查询中同时带上可信 `tenantId`
4. 增加 tenant-a/tenant-b 的真实 API 越权回归,覆盖查询、创建、更新、删除、密钥重置、上传下载和导出。
### CQ-SEC-002 / P0:用户密码采用无盐 SHA-256
**证据链**
- `api/src/users/users.service.ts:444-445``createHash('sha256').update(password).digest('hex')`
- `api/src/users/users.service.ts:141``213``267` 使用该函数创建或修改密码。
- `api/src/auth/auth.service.ts:62` 使用字符串相等比较验证密码。
**影响**
密码哈希没有用户级 salt,也没有故意增加计算和内存成本。数据库泄漏后,弱密码可被高速离线破解;相同密码生成相同哈希,还会泄露账号之间的密码复用关系。
**最小修复要求**
1. 改用 Argon2id;如运行环境暂不支持,可使用带独立 salt 和合理成本参数的 scrypt/bcrypt。
2. 新哈希保存算法版本与参数;验证旧 SHA-256 成功后立即透明升级,避免一次性强制所有账号重置。
3. 使用库提供的恒定时间验证接口,不直接比较哈希字符串。
4. 增加旧哈希迁移、错误密码、参数升级、密码修改和 sessionVersion 失效测试。
## 4. 高优先级问题
### CQ-API-001 / P1API 缺少统一运行时输入校验
- `api/src/main.ts:23-27` 创建应用并配置 body parser,但没有注册全局 `ValidationPipe`
- 搜索未发现 `@IsString``@IsInt``class-validator` 规则。
- 统计到约 166 个控制器 `@Body()` 入口;多数 DTO 是 TypeScript interface 或内联类型,运行时会被擦除。
- `api/src/open-api/open-api.dto.ts:3-14` 仅包含 Swagger 装饰器,没有输入校验装饰器。
风险包括错误类型进入服务层、超长字符串、额外字段、枚举外状态和不一致的 400/500 响应。建议建立 DTO class、`transform: true``whitelist: true``forbidNonWhitelisted: true` 的统一策略,并分批为高风险写接口补齐字段长度、枚举、数组大小和格式限制。
### CQ-AUTH-001 / P1:验证码和匿名失败计数为进程内无界 Map
- `api/src/auth/auth.service.ts:20-21` 使用两个模块级 `Map`
- `createCaptcha()` 会持续写入记录;过期记录只有在同一 captchaId 被再次提交时才删除。
- 匿名失败以任意登录名为 key 写入,没有容量限制或周期清理。
这会造成多实例登录状态不一致,并可通过大量验证码请求或随机登录名制造内存增长。建议迁移到 Redis,使用 TTL、原子计数、IP+账号双维度限流和固定容量保护。
### CQ-FE-001 / P1:前端主包过大且没有路由级代码分割
当前生产构建结果:
- JavaScript2,123.63 KBgzip 626.18 KB。
- CSS270.61 KBgzip 39.84 KB。
- Vite 明确产生“chunk 大于 500 KB”警告。
- `src/routes/AppRoutes.tsx:2-59` 同步导入全部运营端和客户端页面;代码中未发现 `React.lazy()` 或动态 `import()`
- `src/components/ui/Chart.tsx:3` 使用 `import * as echarts from 'echarts'`,进一步扩大主包。
建议先按 admin/client 及页面路由使用 `React.lazy` + `Suspense` 分包,再按需引入 ECharts 图表模块。应在 CI 增加 bundle budget,例如初始 JS gzip 不高于 250 KB、单异步 chunk gzip 不高于 180 KB;实际阈值可在首轮拆包后校准。
### CQ-TEST-001 / P1:测试覆盖和验收层级不足
- API:45 套、532 项通过;全源覆盖率为 statements 59.61%、branches 50.99%、functions 60.33%、lines 62.35%。
- `api/jest.config.cjs` 没有 `coverageThreshold`
- `src/` 下前端 `*.spec.*` / `*.test.*` 文件数量为 0。
- Gateway 内部包覆盖率约 49.7% 至 100%,但 `cmd/gateway` 只有 0.7%,另两个命令包为 0%。
- 本轮测试没有启动真实 PostgreSQL、Redis、MinIO 或 CMPP 模拟器,不能证明真实后端闭环。
建议先为租户隔离、认证、账务、发送幂等、回执关联建立真实 PostgreSQL/Redis 集成测试;前端至少覆盖登录、权限、加载/空/错误、筛选分页和高风险确认操作;随后逐步设置覆盖率门槛,避免一次性追求无意义的高百分比。
## 5. 中优先级问题
### CQ-DEP-001 / P2:生产依赖仍有 2 个 high advisory
`pnpm audit --prod --json` 返回:
1. `react-router 7.18.1`GHSA-qwww-vcr4-c8h2,修复版本 `>=7.18.2`
2. `nanoid 3.3.16`GHSA-2v37-7h3g-55p8,修复版本 `>=3.3.18`
现有 `security:verify` 已证明项目没有使用 React Router RSC 模式,并验证了既有 PostCSS 缓解,因此当前可利用面低于审计工具的原始 high 评级;但版本仍处于公告范围,不能长期依赖“功能未使用”作为供应链治理。建议升级后重新执行构建、API 测试、前端浏览器回归和安全校验。
### CQ-MAINT-001 / P2:超大文件与缺失静态风格门禁
- `api/src/send-chain/send-inbound-entry.service.ts` 约 84 KB。
- `api/src/send-chain/send-gateway-submit.service.ts` 约 52 KB。
- `src/apps/admin/AdminDownstreamDeliveriesPage.tsx` 约 44 KB。
- 根项目和 API 均没有 lint/format 脚本,也未发现 ESLint/Prettier 配置。
建议先按“持久化、状态机、队列 claim、路由、计费”拆分 send-chain 服务;页面按筛选、表格、详情和恢复操作拆分。拆分时要求行为不变,并依靠当前测试防回归。
### CQ-CONFIG-001 / P2:数据库连接存在硬编码开发默认凭据
- `api/prisma.config.ts:8-11`
- `api/src/prisma/prisma.service.ts:39-41`
`DATABASE_URL` 缺失时,进程会尝试使用 `cmpp/cmpp_password` 连接本机数据库。建议生产角色 fail closed;仅在显式 `NODE_ENV=development` 或专用本地配置下允许开发默认值。
### CQ-ARCH-001 / P2:真实 API 代码仍与 mock 目录耦合
- `src/mock/` 保留完整 localStorage mock service。
- `src/apps/admin/auditColumns.tsx:3` 的生产组件仍从 `@/mock` 导入业务类型。
当前没有证据表明 mock service 仍被页面运行时调用,但该目录和类型依赖会误导后续开发,并增加重新接入静态数据的风险。建议把共享类型迁移到 `src/api/types` 或 domain 模块,并在构建/检查中禁止 `src/apps/**` 导入 `src/mock/**`
### CQ-REPO-001 / P2:仓库产物和审计基线不稳定
- `api/tsconfig.build.tsbuildinfo` 已被 Git 跟踪,每次构建产生无业务意义的修改。
- `.gitignore` 没有统一忽略 `*.tsbuildinfo``outputs/` 和任务临时文件。
- 最终工作区仍存在 `=`, `outputs/`, `pnpm-lock.yaml`, `tmp_generate_ui_drafts.py` 等既有未跟踪内容;审计窗口内还曾出现随后被并发流程处理的临时部署脚本。
- 审计开始时 HEAD 为 `4d4c1f3`,期间其他会话先后提交了业务代码 `a70e9e2` 和部署记录 `171c7d3`;本报告已对新业务代码重新运行前端类型检查和生产构建,但完整 API 覆盖率运行发生在该纯前端提交之前。
建议统一包管理器与唯一锁文件,忽略纯构建缓存,并在正式审查/发布时使用固定 commit 或独立 worktree,避免审计结论对应移动目标。
## 6. 已通过门禁
| 检查项 | 结果 |
|---|---|
| 前端 TypeScript `tsc --noEmit` | 通过;并发提交后已重跑 |
| Vite 生产构建 | 通过;存在包体警告 |
| API TypeScript 正式构建 | 通过 |
| API Jest | 45/45 套、532/532 项通过 |
| API 覆盖率 | statements 59.61%branches 50.99%functions 60.33%lines 62.35% |
| Gateway `go test ./... -count=1` | 通过 |
| Gateway `go vet ./...` | 通过 |
| Gateway `go test ./... -cover` | 通过;各包覆盖率差异较大 |
| Prisma Schema | 95 个迁移目录;`prisma validate` 通过 |
| Gateway 队列契约 | 5/5 样例通过 |
| 依赖缓解/安全部署脚本 | 通过 |
| `pnpm audit --prod` | 不通过;2 个 high advisory |
| `git diff --check` | 通过 |
## 7. 积极发现
1. PostgreSQL 连接按 API、Worker、Outbox、Callback 和 Protocol Log 角色设置独立连接池上限。
2. 账务、日配额、频控等竞争资源使用事务级 advisory lock。
3. 多个队列 claim 使用 `FOR UPDATE SKIP LOCKED`,适合并发消费者。
4. `SmsMessageRecord``SmsBatchTask``CmppDownstreamDelivery` 等高频表具备 tenant/status/time 复合索引。
5. Webhook 主链对协议、内网地址、环回地址和 DNS 解析做了 SSRF 防护,并在 HTTP 客户回调中固定解析后的目标地址。
6. 会话 cookie 使用 HttpOnly、SameSite=Lax,并按环境控制 Secure;前端 localStorage 保存的是会话展示元数据,不是 session token。
7. API、Gateway 和队列契约测试已经覆盖大量发送、补发、回执去重和异常分支。
## 8. 未验证边界
本报告不能替代以下验证:
- 真实 PostgreSQL 数据量下的 `EXPLAIN (ANALYZE, BUFFERS)` 和慢查询分析。
- Redis Stream/BullMQ pending、lag、重试、宕机恢复和重复消费验证。
- MinIO 上传、下载、租户隔离和大文件边界。
- 登录后的真实浏览器 UI、控制台、网络请求、加载/空/错误/刷新状态。
- Gateway 与 CMPP 模拟器或真实供应商的 Submit、长短信、回执、上行和断线恢复闭环。
- 测试、预生产、生产当前部署 commit、迁移数、配置和服务状态。
在完成 P0 修复前,不建议通过真实环境攻击性测试证明越权;应先补自动化隔离回归,再在隔离测试环境验证。
## 9. 建议整改顺序
### 第一批:发布阻断
1. 服务端统一可信租户上下文,关闭请求头/请求体决定客户端租户的能力。
2. 迁移密码哈希到 Argon2id/scrypt,并提供旧哈希透明升级。
3. 补 tenant-a/tenant-b 越权测试和密码迁移测试。
### 第二批:安全与质量门禁
1. 建立全局运行时 DTO 校验。
2. 将验证码、失败计数和限流迁移到 Redis TTL/原子计数。
3. 升级两个公告依赖并保持安全校验通过。
4. 在 CI 增加 lint、format check、覆盖率门槛、依赖审计和 bundle budget。
### 第三批:性能与可维护性
1. 前端路由级分包并按需加载 ECharts。
2. 拆分 send-chain 超大服务和超大页面。
3. 清理 mock 类型耦合、构建缓存和临时产物治理。
4. 在隔离真实后端完成 API/DB/Redis/MinIO/Gateway/CMPP 全链回归。
## 10. 验收出口标准
整改完成至少应满足:
- P0 为 0,P1 有明确关闭证据或书面风险接受。
- tenant-a 用户无法通过 header、body、query 或资源 ID 访问 tenant-b 数据。
- 新密码使用强哈希;旧 SHA-256 账号登录后自动升级,数据库不再新增 SHA-256 密码。
- 前端初始 JS 包达到约定预算,核心路由按需加载。
- API/Gateway/前端测试与构建全部通过;覆盖率不低于本报告基线且建立门槛。
- 依赖审计不再包含本报告两项 high advisory。
- 真实 PostgreSQL、Redis、MinIO、Gateway 和 CMPP 测试证据与 `docs/testing-progress.md` 同步。
@@ -0,0 +1,402 @@
# CMPP 平台代码质量整改方案
- 方案日期:2026-08-28
- 对应审计报告:`docs/code-quality-audit-20260828.md`
- 当前复核基线:`c3bf8af3e6fc8bac0ac5e104f09a3d6b68506b27`
- 适用范围:React/Vite 前端、NestJS API、Prisma/PostgreSQL、Redis、Go Gateway、测试与工程门禁
- 执行状态(2026-08-28):P0 代码整改、自动化验证和测试环境真实 API 验证已完成;最终结果、证据与保留项见 `docs/code-quality-remediation-result-20260828.md`
- 环境边界:只允许在本地和测试环境实施、部署与验证;预生产不得部署、覆盖或回退,除非再次取得明确授权
## 1. 整改目标
本轮整改以“先消除发布阻断,再建立防复发门禁,最后处理性能和可维护性”为原则。完成后应达到:
1. 客户端租户身份只来自服务端已认证会话,任何请求头、请求体、查询参数和资源 ID 都不能改变数据归属范围。
2. 新增和修改的密码全部使用带版本及参数信息的强密码哈希;旧 SHA-256 账号在成功登录后透明迁移。
3. 高风险写接口具备统一、可预测的运行时输入校验和错误响应。
4. 验证码、失败计数和登录限流支持多实例一致性、TTL、原子操作与容量控制。
5. 建立能够阻止租户越权、弱密码、依赖漏洞、包体回退和测试覆盖下降的自动化门禁。
6. 前端首屏资源按路由拆分,工程产物、mock 类型和超大模块进入可持续治理状态。
## 2. 范围、依赖与验证成本
| 批次 | 范围 | 关键依赖 | 主要验证 | 相对工作量 |
|---|---|---|---|---:|
| R0 | 固定基线、资产与测试数据 | Git、测试数据库、Redis、MinIO、测试账号 | 恢复演练、基线门禁 | 0.5~1 人日 |
| R1 | 可信租户上下文和全客户端接口隔离 | User.tenantId、会话中间件、Prisma | 双租户真实 API 回归 | 4~7 人日 |
| R2 | 密码强哈希与透明迁移 | Argon2id 或 scrypt、User.passwordHash | 兼容登录、迁移、会话失效 | 2~4 人日 |
| R3 | DTO 校验、验证码与限流 | class-validator、Redis | 接口契约、多实例、过期与并发 | 4~7 人日 |
| R4 | 依赖、CI、测试门禁 | 唯一包管理器、浏览器测试框架 | 全量构建、测试、安全审计 | 3~6 人日 |
| R5 | 前端分包与 ECharts 按需加载 | Vite、React Router | 包体、浏览器、弱网首屏 | 2~4 人日 |
| R6 | 超大模块、mock、配置和仓库治理 | 既有回归测试 | 行为等价、构建、Git 清洁度 | 5~10 人日 |
| R7 | 测试环境真实链路验收 | PostgreSQL、Redis、MinIO、Gateway、CMPP 模拟器 | 全链证据和恢复演练 | 2~5 人日 |
以上是工作量级别,不是承诺工期。R1 涉及的客户端控制器和资源类型较多,实际工作量取决于租户资源清单和集成测试基础设施。最小充分范围是先完成 R0~R2;R3~R7 不得反向阻塞 P0 修复,但没有完成相应门禁的项目不能视为质量治理闭环。
## 3. 总体实施顺序
```text
R0 固定基线和恢复资产
├─ R1 可信租户上下文 ─┐
└─ R2 密码哈希迁移 ───┴─ P0 安全门禁通过
R3 输入校验和登录状态
R4 测试与工程门禁
R5 前端性能 + R6 可维护性
R7 测试环境全链验收
```
R1 和 R2 可以在独立分支并行开发,但必须分别完成测试后再合并。R3 的全局校验可能改变大量接口响应,不应与 R1 混在同一个大提交中。
## 4. R0:基线、恢复资产和变更控制
### 4.1 实施内容
1. 为整改建立固定 Git 基线,记录完整 commit、分支及工作区状态;不得把现有未跟踪文件或其他会话改动误纳入提交。
2. 部署测试环境前重新建立并校验恢复资产:
- 当前服务包、配置和 systemd/Nginx 配置备份;
- PostgreSQL 可恢复备份;
- Redis 关键 key/stream/queue 状态快照;
- MinIO 关键 bucket 和租户测试文件清单;
- 当前部署 commit、Prisma 迁移数和服务健康状态。
3. 准备两个隔离企业及账号:`tenant-a/user-a``tenant-b/user-b`。两边分别准备认证、应用、签名、模板、任务、记录、账务、日志和文件样本。
4. 记录 P0 修复前的接口契约,但不执行真实环境攻击性验证,不保留可直接复用的生产攻击脚本。
### 4.2 出口标准
- 恢复资产的位置、校验值、恢复命令和验证结果有记录。
- 两个租户的测试数据可重复创建,不使用生产数据。
- 工作目录的既有脏文件归属已记录,整改提交不混入无关文件。
## 5. R1:关闭客户端跨租户访问
### 5.1 服务端可信租户上下文
修改 `api/src/auth/session-validation.middleware.ts`
1. 查询会话用户时同时读取 `tenantId`
2. 对 `/api/client/**` 请求强制要求有效 `tenantId`;缺失时返回明确的 403,而不是继续执行无租户过滤查询。
3. 将可信字段写入请求上下文,例如:
```ts
request.authContext = {
userId: user.id,
portal: result.record.portal,
tenantId: user.tenantId,
};
```
4. 新建只读取服务端上下文的装饰器,例如 `@CurrentTenantId()`。原 `@TenantId()` 不再作为客户端授权依据。
5. 若为兼容旧前端暂时保留 `x-tenant-id`,只允许进行一致性校验:请求头存在且与可信租户不一致时返回 403,并写入安全审计日志;请求头不得决定查询范围。
6. 运营端按明确权限跨租户查询的能力继续使用运营端查询参数,不复用客户端租户装饰器。
### 5.2 控制器和服务层整改
必须逐一盘点所有 `/api/client/**` 路由,不仅修改审计报告列举的两个控制器。处理规则如下:
| 接口类型 | 必须采用的规则 |
|---|---|
| 列表查询 | `where` 必须包含可信 `tenantId`;不得因 `undefined` 省略过滤 |
| 单条详情 | 使用 `findFirst({ where: { id, tenantId } })` 或等价复合条件,不得只按 `id` 查询 |
| 创建 | 服务端覆盖 `tenantId`,忽略或拒绝请求体中的 `tenantId` |
| 更新/删除 | 更新前按 `{ id, tenantId }` 验证资源;事务内再次带租户条件 |
| 密钥重置/状态变更 | 同时传入可信 `tenantId`,禁止只凭资源 ID 操作 |
| 上传/下载 | 文件对象和所属业务对象都必须校验租户;下载不得仅凭 fileObjectId |
| 导入/导出 | 导出查询和异步任务 payload 都固化可信 `tenantId` |
| 关联资源 | 应用、签名、模板、通道能力等外键必须属于同一租户或是明确的公共资源 |
首批重点文件包括但不限于:
- `api/src/certification/certification.controller.ts`
- `api/src/certification/certification.service.ts`
- `api/src/sms-config/client-sms-config.controller.ts`
- 客户端账务、短信任务、发送记录、上行短信、日志、文件和报表相关控制器及服务
建议建立一个租户资源清单,记录“路由、动作、资源、服务层方法、租户条件、测试编号、整改状态”。不能只用全局搜索替代清单验收。
### 5.3 数据库约束与审计
1. 优先使用现有 `tenantId` 列和复合索引,不为了本轮修复盲目大改数据库模型。
2. 对经常使用“资源 ID + tenantId”的高频表检查复合索引;新增索引前在测试数据上执行 `EXPLAIN (ANALYZE, BUFFERS)`
3. 对租户头不一致、资源归属不一致和客户端传入 tenantId 的尝试记录安全事件,但日志不得包含密码、应用密钥或完整敏感资料。
4. 业务层建议增加统一的 `assertTenantResource` 或仓储查询约束,但不能用一个可选 tenantId 参数制造新的绕过路径。
### 5.4 必测用例
每类资源至少覆盖:
- A 查询 A:成功。
- A 查询 B:404 或 403,响应不得暴露 B 是否存在。
- A 创建时提交 B 的 tenantId:拒绝或由服务端强制覆盖为 A。
- A 更新、删除、提交审核、重置密钥、变更状态时使用 B 的资源 ID:失败且 B 数据不变。
- 省略、伪造、重复或大小写变化的 `x-tenant-id`:不能扩大数据范围。
- 批量 ID 中混入 B 的资源:整个请求原子失败,或者只处理 A 且返回明确结果;不得静默操作 B。
- 文件上传、下载、导出和异步任务执行后仍保持租户隔离。
- 管理端经授权跨租户操作保持原有能力,客户端权限收紧不能误伤运营端。
### 5.5 出口标准
- 所有客户端路由使用服务端可信租户上下文。
- 资源 ID 操作均有租户复合条件。
- tenant-a/tenant-b 自动化测试覆盖查询、创建、更新、删除、密钥、文件、导入导出和异步任务。
- 安全测试失败时构建门禁失败。
## 6. R2:密码哈希升级与旧账号透明迁移
### 6.1 算法与存储格式
首选使用成熟库实现 Argon2id,并保存库生成的 PHC 字符串,例如 `$argon2id$...`,其中包含算法、版本、成本、salt 和哈希。不要自行拼接 salt 或自行实现密码学算法。
参数必须在目标 API 机器上基准测试后确定。目标是单次登录验证具备足够成本,但不造成登录接口不可接受的延迟或并发耗尽。参数写入配置及文档,不能散落为魔法数字。
如果目标运行环境无法稳定安装 Argon2 原生依赖,可以使用 Node 标准库 `scrypt` 作为备选;使用独立随机 salt、版本化格式、固定上限和恒定时间比较。不得继续新增 SHA-256 哈希。
### 6.2 兼容迁移流程
```text
用户提交密码
├─ passwordHash 是 Argon2id/新格式
│ └─ 使用库验证;参数过旧则成功后重新哈希
└─ passwordHash 是 64 位旧 SHA-256
├─ 旧算法验证失败:正常失败
└─ 旧算法验证成功:同一登录流程内写入新哈希并完成登录
```
实施要求:
1. 新建统一的 `PasswordHasher` 服务,集中负责 `hash``verify``needsRehash` 和旧格式识别。
2. 创建用户、管理员改密、用户自助改密、密码重置全部调用该服务。
3. 旧哈希透明升级使用条件更新或事务,避免并发登录覆盖更新后的哈希。
4. 密码修改后继续递增 `sessionVersion`,保证旧会话失效。
5. 日志中只记录迁移成功/失败及用户 ID,不记录密码或完整哈希。
6. 暂不批量强制重置全部账号;对长期不登录的旧账号可在后续治理中安排强制重置。
### 6.3 必测用例
- 新账号数据库中不再产生 64 位裸 SHA-256。
- 旧 SHA-256 正确密码能够登录,登录后立即变为新格式。
- 旧 SHA-256 错误密码不能触发迁移。
- 新格式正确/错误密码验证正确。
- 参数升级后 `needsRehash` 能透明更新。
- 管理员改密、自助改密、重置密码都使用新格式并撤销旧会话。
- 并发登录不会把新哈希覆盖回旧哈希。
- 算法验证异常不会回退到“直接字符串相等”。
### 6.4 回退策略
代码回退必须继续具备读取新哈希的能力。因此密码迁移上线后,不允许回退到只认识 SHA-256 的旧版本。推荐先部署“双读新旧、只写新格式”的兼容版本;验证稳定后再移除旧哈希写入代码。数据库字段通常无需迁移,但必须先确认长度足以保存 PHC 字符串。
### 6.5 出口标准
- 全部密码写路径只写新格式。
- 旧账号登录后可验证地完成透明迁移。
- 密码和会话相关自动化测试通过。
- 测试数据库扫描证明没有新产生的旧 SHA-256 哈希。
## 7. R3:输入校验、验证码和登录限流
### 7.1 DTO 运行时校验
不要直接一次性对全部 181 个左右的 `@Body()` 入口开启严格拒绝,否则可能造成大面积兼容性回归。分三步实施:
1. 先建立 DTO class 和统一异常格式,在认证、租户资源、密码、密钥、文件、发送、账务等高风险写接口启用。
2. 对已覆盖 DTO 的模块启用 `transform``whitelist``forbidNonWhitelisted`
3. 所有模块完成 DTO 转换和兼容验证后,再提升为全局 `ValidationPipe`
每个 DTO 至少考虑:字符串长度、trim 策略、枚举、手机号/日期格式、数组数量、单项长度、分页上限、文件大小、嵌套对象和额外字段。校验失败统一返回稳定的 400 错误码和字段列表,不返回 Internal server error。
### 7.2 验证码和失败计数
复用现有 `ioredis` 依赖,但应抽出共享 Redis 连接/服务,避免每个模块各自维护连接。建议 key 设计:
- `auth:captcha:<captchaId>`:TTL 2~5 分钟,验证使用原子读取并删除。
- `auth:fail:account:<normalizedAccount>`:滑动或固定窗口计数。
- `auth:fail:ip:<ip>`IP 维度计数。
- `auth:lock:<normalizedAccount>`:明确锁定 TTL。
要求:
1. 使用 Lua 或等价 Redis 原子命令完成“校验并消费”和计数/过期设置。
2. 对 key 长度和账号归一化进行限制,避免任意超长输入制造内存压力。
3. 登录接口结合可信代理配置获得真实来源 IP,不能盲目信任任意 `X-Forwarded-For`
4. Redis 不可用时采用明确的安全策略并报警;不能悄悄退回无限 Map。是否 fail closed 应结合运营可用性评审后固化。
5. 增加验证码请求频率和总容量保护。
### 7.3 出口标准
- 高风险写接口全部具有运行时 DTO 校验。
- 多 API 实例共享验证码和锁定状态。
- 过期 key 自动清理,随机账号/验证码压测下 Redis 内存增长受控。
- 错误请求稳定返回 4xx,不出现未处理 500。
## 8. R4:依赖、测试与 CI 门禁
### 8.1 依赖整改
1. 将 `react-router-dom`/`react-router` 升级到已修复的兼容版本,至少 `7.18.2`
2. 通过 PostCSS 或包管理器解析结果把 NanoID 升级到至少 `3.3.18`
3. 统一使用一种包管理器和唯一锁文件;在决定前不要直接删除现有锁文件。
4. 升级后执行前端构建、登录/路由浏览器回归、安全校验和生产依赖审计。
React Router 公告只影响不稳定 RSC 路径,NanoID 公告需要特定零长度自定义生成器调用;当前项目可利用面较低,但版本治理仍应关闭公告。
### 8.2 自动化测试优先级
新增测试优先级如下:
1. 租户隔离和密码迁移。
2. 账务余额、发送幂等、任务审核、回执关联和补发。
3. 登录、权限、加载/空/错误/刷新、筛选分页及高风险确认操作的前端测试。
4. PostgreSQL、Redis 和 MinIO 集成测试。
5. Gateway/CMPP 模拟器的 Submit、长短信、回执、上行和断线恢复。
覆盖率门槛应先以当前实测基线为下限,新增或修改文件要求更高的增量覆盖率,再逐批提高。不以补无意义断言换取百分比。
### 8.3 CI 建议门禁
每次合并至少执行:
- `git diff --check`
- 前端 TypeScript 和 Vite 正式构建
- API TypeScript 正式构建和 Jest
- Gateway `go test ./... -count=1``go vet ./...`
- Prisma validate 和迁移一致性检查
- tenant-a/tenant-b 安全回归
- lint、format check
- 生产依赖审计
- bundle budget
- 禁止 `src/apps/**` 导入 `src/mock/**`
- 禁止新增裸 SHA-256 密码写入代码
任何 P0 安全回归、构建、迁移验证或依赖阻断项失败都不得合并。
## 9. R5:前端路由分包和图表瘦身
### 9.1 实施内容
1. 在 `src/routes/AppRoutes.tsx` 按 admin/client 和页面路由使用 `React.lazy``Suspense`
2. 登录页、布局骨架和通用错误页保留轻量同步加载;大型报表、审计、监控和详情页异步加载。
3. 为异步路由提供统一加载、失败和重试界面,避免白屏。
4. ECharts 改为按需注册图表、组件和渲染器;确认所有现有图表类型仍正常。
5. 在 Vite 构建产物中记录初始 JS、CSS、最大异步 chunk 和 gzip 大小。
### 9.2 建议预算
- 初始 JavaScript gzip:首轮目标不高于 250 KB;如果受框架公共依赖限制,可基于首轮拆包结果书面校准。
- 单个异步 chunk gzip:不高于 180 KB。
- 不允许新增页面使初始包超过已验收基线。
### 9.3 浏览器验收
覆盖客户端和运营端:首次访问、直接打开深层 URL、登录后跳转、刷新、后退/前进、异步加载失败、权限不足、移动端宽度及弱网。浏览器控制台不得出现 chunk 加载、路由和图表初始化错误。
## 10. R6:可维护性、配置、mock 和仓库治理
### 10.1 超大模块拆分
拆分遵循“先测试锁定行为,再机械移动,最后优化结构”:
- `send-inbound-entry.service.ts`:按接入解析、持久化、长短信聚合、业务工作流、频控/预留拆分。
- `send-gateway-submit.service.ts`:按队列 claim、路由选择、Gateway 提交、Outbox/Redis、重试恢复拆分。
- `AdminDownstreamDeliveriesPage.tsx`:按筛选条件、统计、表格、详情、恢复操作拆分。
每次只移动一个职责,提交中不同时改变业务规则。拆分后保持事务边界、锁顺序、幂等键和队列语义不变。
### 10.2 数据库配置
1. 生产、预生产和测试服务启动时,`DATABASE_URL` 缺失必须立即失败。
2. 开发默认连接只允许在显式 development/test 模式下使用,并在启动日志中标明非生产配置。
3. 对 `REDIS_URL`、MinIO、会话密钥、应用加密主密钥等关键配置建立同类启动校验。
4. 日志不得输出完整连接串和密码。
### 10.3 mock 解耦
1. 把 `auditColumns.tsx` 所需业务类型迁移到 `src/api/types` 或独立 domain 类型文件。
2. 生产组件禁止导入 `src/mock/**`
3. 在确认没有运行时引用后,再决定保留 mock 作为测试夹具还是删除;不能只为“目录干净”贸然删除可能仍被工具使用的内容。
### 10.4 仓库卫生
1. `.gitignore` 增加 `*.tsbuildinfo`、明确的本地产物和任务临时目录规则。
2. 对已经被 Git 跟踪的构建缓存使用 `git rm --cached` 停止跟踪,但保留本地文件;操作前确认没有业务用途。
3. 统一锁文件后再清理其他锁文件,并通过全新目录可重复安装验证。
4. 正式审计和发布使用固定 commit 或独立 worktree,报告记录 commit、依赖锁摘要和构建产物校验值。
5. 不自动删除当前工作区的 `=`, `outputs/`, `tmp_generate_ui_drafts.py` 等既有内容,必须先确认归属和可恢复性。
## 11. R7:测试环境验收与发布策略
### 11.1 测试环境部署前
- 重新建立并校验恢复资产,不复用上一次“已经备份”的口头结论。
- 核对部署目标为 `100.93.204.60` 测试环境,不连接预生产服务器。
- 记录旧 commit、新 commit、迁移计划、服务包校验值和回退步骤。
- 密码新格式上线后,回退包必须仍能读取新哈希。
### 11.2 测试环境真实验证
1. 双租户 API 隔离矩阵全部通过。
2. 旧密码登录透明迁移、新密码登录、改密和会话撤销通过。
3. PostgreSQL 数据实际落库且租户归属正确。
4. Redis 验证码、锁定、TTL、多实例一致性和故障策略通过。
5. MinIO 上传、下载、租户隔离和大文件边界通过。
6. 登录后的客户端/运营端页面完成加载、空、错误、刷新和权限状态检查。
7. Gateway/CMPP 模拟器完成 Submit、长短信、状态报告、上行和断线恢复。
8. 短信结果必须以最终平台状态、数据库/队列和下游证据闭环,不能以 SubmitResp 成功代替最终送达。
### 11.3 发布门禁
测试环境验收通过不自动授权预生产发布。预生产仍需单独明确授权,并在发布前重新核对:
- P0 为 0
- P1 已关闭或有书面风险接受;
- 数据库迁移可向前执行且回退边界明确;
- 新旧密码格式兼容;
- 全量自动化与真实链路证据齐全;
- 目标环境恢复资产已重新建立;
- `docs/testing-progress.md`、审计问题清单和部署记录同步。
## 12. 问题关闭标准
| 编号 | 关闭证据 |
|---|---|
| CQ-SEC-001 | 服务端可信租户上下文;客户端路由清单;双租户真实 API 自动化;资源 ID、文件、导入导出和异步任务均无越权 |
| CQ-SEC-002 | 新哈希写入证据;旧 SHA-256 透明迁移;并发和 sessionVersion 测试;回退版本兼容新格式 |
| CQ-API-001 | 高风险接口 DTO class;统一 400 格式;全局或分模块严格 ValidationPipe;异常输入回归 |
| CQ-AUTH-001 | Redis TTL/原子计数;多实例一致性;容量和故障策略测试 |
| CQ-FE-001 | 路由异步 chunkECharts 按需加载;包体预算和真实浏览器回归 |
| CQ-TEST-001 | 安全/业务集成测试;前端关键状态测试;覆盖率门槛不低于确认后的基线 |
| CQ-DEP-001 | 锁文件中版本已修复;生产依赖审计不再报告对应公告;构建和浏览器回归通过 |
| CQ-MAINT-001 | 超大模块按职责拆分;行为等价测试通过;lint/format 门禁启用 |
| CQ-CONFIG-001 | 非开发环境缺失关键配置时启动失败;开发回退显式且不泄露凭据 |
| CQ-ARCH-001 | 生产代码不再导入 `src/mock/**`;禁止规则进入 CI |
| CQ-REPO-001 | 唯一锁文件;构建缓存不再被跟踪;审计基线固定;工作区产物有明确治理规则 |
## 13. 建议提交拆分
为降低审查和回退风险,建议至少拆为以下独立提交:
1. `security: bind client tenant scope to authenticated session`
2. `test: add cross-tenant isolation regression matrix`
3. `security: migrate password hashing with legacy upgrade`
4. `test: cover password migration and session revocation`
5. `security: add runtime DTO validation to high-risk APIs`
6. `security: move captcha and login throttling to Redis`
7. `build: upgrade audited dependencies and add quality gates`
8. `perf: split routes and load charts on demand`
9. `refactor: split send-chain responsibilities without behavior change`
10. `chore: decouple mock types and normalize repository artifacts`
任何提交都不应同时包含测试环境部署资产、无关 UI 修改或其他会话的工作区文件。
## 14. 最终交付物
- 整改后的源代码和逐项可审查提交。
- 租户资源/路由清单及关闭状态。
- 自动化测试结果、覆盖率、依赖审计和 bundle 报告。
- 测试环境真实 API、数据库、Redis、MinIO、Gateway、CMPP 和浏览器证据。
- 密码迁移统计,只记录格式数量,不导出密码哈希。
- 恢复资产、部署记录和回退验证记录。
- 更新后的 `docs/testing-progress.md` 与代码质量问题关闭清单。
@@ -0,0 +1,121 @@
# CMPP 平台代码质量整改执行结果
- 执行日期:2026-08-28
- 对应方案:`docs/code-quality-remediation-plan-20260828.md`
- 本地分支:`main`
- 测试环境:`100.93.204.60`
- 测试环境最终业务代码:`fc4a6a7afcfc3c1c11d1e353f46d5b724f48a39e`
- 环境边界:本轮只修改和部署测试环境;未访问、覆盖、回退或部署预生产
## 1. 结论
本轮已经关闭两个 P0,并完成可信租户上下文、强密码哈希、旧哈希透明迁移、Redis 验证码/锁定、重点写接口 DTO 校验、依赖升级、覆盖率和包体门禁、路由分包、ECharts 按需加载、数据库配置 fail closed、mock 类型解耦及仓库缓存治理。最终代码已部署到测试环境,95 项迁移全部一致,11 项相关服务 activeAPI/Gateway 健康,Redis 回执结果 Stream `pending=0、lag=0`,发布后核心服务 error 级日志为 0。
本轮没有把“完整治理方案”中的长期重构和全业务链复验冒充已完成:两个超大 SendChain Service 未做高风险机械拆分;未重新发送短信、执行压力测试或改动 MinIO 业务对象;浏览器完成登录页真实渲染和控制台检查,但没有在本轮自动输入测试密码和验证码完成登录后页面验收。这些项目不影响本次 P0 关闭和已发布代码运行,但仍属于后续专项验收范围。
## 2. 已实施变更
### 2.1 租户隔离
- 会话校验从数据库读取用户 `tenantId`,客户端请求只使用服务端可信上下文。
- 客户端缺少租户时直接拒绝;`x-tenant-id` 仅做一致性检查,不再决定授权范围。
- 请求头伪造租户返回 `403 / CLIENT_TENANT_MISMATCH` 并写安全事件。
- 客户端认证、删除治理、Open API、风险审核、发送链、运营日志、短信配置、账务及用户接口均改用可信租户。
- 客户端创建请求中的 `tenantId``operatorId``createdById` 不再作为可信身份字段;服务端覆盖或严格拒绝。
- 应用参数、状态、密钥等按资源 ID 的操作补充租户复合范围。
测试环境双租户真实 API 结果:
| 场景 | 结果 |
|---|---|
| 当前租户且不带租户请求头 | 通过 |
| 当前租户且请求头一致 | 通过 |
| 请求头伪造其他租户 | `403 CLIENT_TENANT_MISMATCH` |
| 请求体提交其他租户 ID | 被服务端强制归属当前租户 |
| 访问其他租户应用资源 ID | `404` |
### 2.2 密码哈希
- 使用 Node 标准库 scrypt,格式包含版本、`N=32768/r=8/p=1`、独立 16 字节随机 salt 和摘要。
- 验证使用恒定时间比较,并限制可接受参数范围。
- 创建用户、改密、重置密码、生产管理员维护工具和真实环境 smoke 工具只写强哈希。
- 旧 64 位 SHA-256 继续只读兼容;正确密码登录后使用条件更新透明升级,错误密码不迁移。
- 首次发布使用最长 2 小时的受限兼容窗口,建立第二份恢复资产后移除兼容写入环境变量并重启 API;最终配置和 API 进程均无 `PASSWORD_HASH_LEGACY_*`
- 测试环境创建一次性旧 SHA-256 测试账号,经真实验证码和客户端登录接口成功迁移为 scrypt,随后硬删除;收尾 `@integration.invalid` 活跃账号为 0。
- 测试环境仍有 4 个未登录的历史 SHA-256 账号,按透明迁移策略保留;本次没有改写未知用户密码。
### 2.3 登录状态与输入校验
- 验证码迁移到 Redis TTL,并用 `GETDEL` 原子消费。
- 匿名失败计数使用哈希化 key 和 Lua 原子 `INCR/EXPIRE`,连续 5 次失败锁定 24 小时,成功登录清理计数。
- Redis 不可用时 fail closed,不再退回进程内无界 Map。
- 认证和高风险客户端写接口启用 `transform / whitelist / forbidNonWhitelisted` 严格校验;额外身份字段会被拒绝。
### 2.4 依赖、性能和工程门禁
- `react-router-dom/react-router` 升级到 `7.18.2`NanoID 解析固定到 `3.3.18`;根生产依赖审计为 0。
- 全部页面改为 `React.lazy` 路由级加载,新增统一加载/失败/重试边界。
- ECharts 改为 core、chart、component、canvas renderer 按需注册。
- 初始入口 JavaScript gzip 从审计基线约 626 KiB 降至约 107.38 KiB;图表异步块约 181.64 KiB,按首轮实测将单块预算校准为 190 KiB,入口预算 250 KiB。
- API 覆盖率门槛建立为 statements 59%、branches 50%、functions 60%、lines 62%;本轮实际分别为 69.77%、53.47%、71.08%、73.05%。
- 新增门禁禁止生产页面导入 mock、禁止客户端可信租户回退、禁止密码写工具使用裸 SHA-256、检查依赖版本和 bundle budget。
- 生产/测试环境缺少 `DATABASE_URL` 时 fail closed;开发和测试模式才允许显式本地默认值。
- `auditColumns` 业务类型移出 mock;停止跟踪 `api/tsconfig.build.tsbuildinfo` 并补充缓存/产物忽略规则。
- `AdminDownstreamDeliveriesPage` 的展示和详情辅助逻辑已拆出。两个 SendChain 超大 Service 本轮不做无业务收益的高风险拆分,保留到专门重构任务。
## 3. 自动化验证
| 门禁 | 结果 |
|---|---|
| API Jest | 47/47 套、542/542 项通过 |
| API 覆盖率 | 69.77 / 53.47 / 71.08 / 73.05,全部超过门槛 |
| API TypeScript 正式构建 | 通过 |
| 前端 TypeScript | 通过 |
| Vite 正式构建 | 通过 |
| bundle budget | 通过 |
| Gateway `go test ./... -count=1` | 通过 |
| Gateway `go vet ./...` | 通过 |
| Prisma validate / migration | 通过,测试环境 95/95 |
| Gateway 队列契约 | 5/5 通过 |
| 代码质量/安全部署门禁 | 通过 |
| 根生产依赖审计 | 0 vulnerabilities |
| `git diff --check` | 通过 |
API 子项目安装仍报告其依赖树中的 9 项公告(2 moderate、7 high),但原审计明确列出的 React Router 和 NanoID 两项已关闭,现有依赖缓解门禁通过。本轮没有为了追求审计数字盲目升级可能破坏 Prisma/Nest 兼容性的主版本;剩余公告应另行建立升级兼容矩阵。
## 4. 测试环境发布与恢复资产
密码格式具有单向迁移属性,因此采用两阶段发布:先发布能同时读取新旧格式且只允许受限兼容写入的版本,确认新格式写路径后重新建立恢复资产,再关闭兼容写入并发布最终版本。
| 恢复点 | 用途 |
|---|---|
| `/opt/cmpp-platform-backups/code-quality-before-d85ff859-20260828T035007Z` | 首次兼容发布前,旧部署 `0b843702...` |
| `/opt/cmpp-platform-backups/code-quality-compatible-d85ff859-20260828T035426Z` | 兼容版本稳定后 |
| `/opt/cmpp-platform-backups/code-quality-before-fc4a6a7-20260828T040150Z` | 最终强哈希发布前 |
三份恢复点均包含 PostgreSQL custom dump、运行目录、配置、Redis RDB、MinIO 数据、服务/监听/Stream 状态、回退说明和 `SHA256SUMS``pg_restore --list`、tar 目录读取、Redis RDB 和摘要校验通过。最终发布包:
- `outputs/cmpp-code-quality-fc4a6a7-20260828-120252.tar.gz`
- SHA-256`7b025b31d2964e290b20104f83532ef4613929574bc3d790d584e433273841be`
- 955 个归档条目
## 5. 发布后验收
- 部署标记:`fc4a6a7afcfc3c1c11d1e353f46d5b724f48a39e`
- PostgreSQL:源码迁移 95,数据库完成迁移 95。
- 服务:MinIO、Security Agent、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、API、Send Worker、Nginx、Redis、PostgreSQL 共 11 项 active。
- API 和 Gateway 健康响应均为 `status=ok`,测试环境入口 HTTP 200。
- Redis `PONG``gateway.submit.results` 消费者 1、`pending=0、lag=0`
- 发布后 API、Worker、Outbox、Callback、Protocol Log Worker、Gateway、Nginx 的 error 级日志均为 0。
- 恢复资产重新回读通过;配置与 API 进程均不存在旧哈希兼容环境变量。
- 临时管理员、双租户 API 账号、浏览器验收账号和旧哈希迁移账号均已删除;活跃 `@integration.invalid` 用户为 0。
- 浏览器真实打开客户端登录页,页面标题、账号/密码/验证码、验证码挑战、登录按钮和“返回官网”均可见,控制台 error/warning 为 0。
## 6. 保留项与边界
1. 登录后的客户端/运营端页面、深层路由刷新、异步加载失败和移动端状态仍需使用有效账号完成真实浏览器验收;本轮未把未登录页面或源码检查替代为登录后验收。
2. 本轮没有重新执行短信发送、压力测试、MinIO 上传下载或 CMPP 模拟器全链;Gateway、队列和 MinIO 只做健康、状态及自动化契约验证,没有产生业务短信、计费或文件写入。
3. 两个 SendChain 超大 Service 的职责拆分属于长期可维护性重构,应在独立提交中先补行为锁定测试后实施,不与本次安全发布混合。
4. 工作区既有未跟踪文件 `=``pnpm-lock.yaml` 未纳入、覆盖或删除;根 `package-lock.json` 为本轮依赖门禁的标准锁文件。
5. 测试环境通过不自动授权预生产。预生产仍必须在新的明确授权后重新建立恢复资产再发布。
@@ -37,6 +37,8 @@
".admin-sms-record-table-card",
".admin-sms-record-toolbar",
".admin-sms-record-list",
".admin-sms-record-list__header",
".admin-sms-record-group-title",
".admin-sms-record-card",
".admin-sms-record-status",
".admin-sms-record-detail-link",
@@ -66,10 +68,12 @@
"短信内容",
"通道名称",
"发送状态",
"是否含引流信息",
"查询",
"重置",
"导出CSV",
"查看发送详情",
"分片",
"发送详情",
"通道发送与回执",
"分片补偿审计"
+1 -1
View File
@@ -2,10 +2,10 @@
"schemaVersion": "v1",
"messageType": "UplinkEvent",
"traceId": "trace-20260701-uplink-000001",
"messageId": "uplink-20260701-000001",
"channelId": "sms-channel-cmpp-001",
"createdAt": "2026-07-01T09:01:00.000Z",
"sequenceId": 4096,
"gatewayMessageId": "8412634832294102675",
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "TD",
@@ -24,6 +24,18 @@
"createdAt": { "type": "string", "format": "date-time" }
}
},
"UplinkEnvelope": {
"type": "object",
"required": ["schemaVersion", "messageType", "traceId", "channelId", "createdAt"],
"properties": {
"schemaVersion": { "const": "v1" },
"messageType": { "const": "UplinkEvent" },
"traceId": { "type": "string", "minLength": 8 },
"messageId": { "type": "string", "minLength": 8 },
"channelId": { "type": "string", "minLength": 1 },
"createdAt": { "type": "string", "format": "date-time" }
}
},
"SubmitCommand": {
"allOf": [
{ "$ref": "#/$defs/Envelope" },
@@ -180,13 +192,14 @@
},
"UplinkEvent": {
"allOf": [
{ "$ref": "#/$defs/Envelope" },
{ "$ref": "#/$defs/UplinkEnvelope" },
{
"type": "object",
"required": ["messageType", "sequenceId", "phoneNumber", "destId", "content", "receivedAt"],
"required": ["messageType", "sequenceId", "gatewayMessageId", "phoneNumber", "destId", "content", "receivedAt"],
"properties": {
"messageType": { "const": "UplinkEvent" },
"sequenceId": { "type": "integer", "minimum": 0 },
"gatewayMessageId": { "type": "string", "minLength": 1 },
"phoneNumber": { "type": "string", "pattern": "^1[3-9][0-9]{9}$" },
"destId": { "type": "string", "minLength": 1 },
"content": { "type": "string", "minLength": 1 },
@@ -1702,6 +1702,7 @@
8. 长短信任一分片返回非成功终态时,系统必须通过该分片审计关联的提交记录识别当前发送尝试,不得仅以主记录保存的首片上游消息号判断;确认属于当前尝试后,整条短信立即进入失败/补发或退款终态,无需等待其余分片回执。
9. 回执和上行投递方式不得由运营人员选择。企业应用开通CMPP接口即按CMPP投递,开通HTTP接口且对应Webhook地址非空即按HTTP投递,两者同时满足时双投;任一地址为空时只跳过该类HTTP事件。运营端企业应用HTTP参数页必须始终可编辑回执和上行Webhook地址,不因HTTP接口开关关闭而隐藏。
10. Gateway向企业应用发送真实 `CMPP_DELIVER` 以及收到企业应用真实 `CMPP_DELIVER_RESP` 时,都必须各写一条通讯交互日志,分别使用“平台→企业应用”和“企业应用→平台”方向;下游投递记录继续承担排队、重试和ACK业务状态,不得以通讯日志替代。
11. 运营端“系统与操作日志”和“通讯交互日志”以及客户端“系统日志”必须提供可选起止日期的区间控件,首次进入和重置后默认北京时间近7天;查询、分页和导出必须使用同一真实后端日期边界,结束日期包含当日23:59:59.999,不得只在前端裁剪列表。
## 2026-07-26 依赖安全治理补充
@@ -2169,3 +2170,12 @@
- 验收必须保持正价,分别执行单企业100/150 TPS和至少两个独立企业合计200 TPS,按非补发首次供应商Submit、回执、上行、计费、主备补发、业务拦截及全队列排空对账;触发拒绝、连接错误、持续积压、数据库异常或账务不一致立即停止。多Gateway P2不在本阶段范围。
- Gateway到主API的回环HTTP连接池空闲时长不得超过API服务端keep-alive生命周期;API默认keep-alive 120秒、headers timeout 125秒,并由发布环境显式设置和校验,防止负载期复用已被Node关闭的连接而把本可受理的Submit误回Result 9。
- 同一Gateway下游会话的connected、heartbeat、submit和deliver状态回调允许并发到达;连接上限按不同`connectionId`计数,同一`connectionId`的首次状态必须幂等upsert,不得因查询/创建竞态误报超过连接数并主动断开客户连接。不同连接超过应用上限时仍返回403并由Gateway断开。
## 2026-08-27 客户端展示、三网报备与人工审核状态优化
- 客户端首页和账户余额页的金额使用继承系统字体的普通大号黑色字;客户端登录页提供“返回官网”。
- 批量任务、发送详情、上行短信默认查询北京时间近 7 天;客户端右上角不展示运营审核任务入口。
- 发送详情的发送状态和回执状态统一显示中文,未识别值显示“状态未知”;应用 CMPP 状态与运营端统一为已连接、已断开、未开通。
- 客户端签名与引流列表不展示使用场景、已提交资料或审核状态,只展示移动、联通、电信三网可用性;全部通过和部分通过均显示“报备通过”,数据来自当前应用路由和真实通道报备任务汇总。
- 人工审核批量任务把`pending_review`独立显示为“待人工审核”,审核期间进度为0;客户端仅允许取消`scheduled`任务,并展示审核原因和驳回原因。运营审核页展示审核任务号与关联批量任务号。
- 运营端签名质量的通道活跃度热力图支持通道名搜索;短信任务进度支持后端任务状态查询。
+68 -6
View File
@@ -234,9 +234,10 @@
2. 按手机号和时间查询。
3. 查看上行关联下发记录。
- 预期结果:
- 展示上行内容、接入号、接收时间。
- 可展示匹配到的下发 messageId。
- 未匹配上行仍可查询,状态或关联为空
- 展示上行内容、接入号、接收时间、上行网关消息 ID、匹配状态和匹配说明
- 后端已通过接入号或手机号时间窗匹配时,即使上行事件没有关联平台 `messageId`,详情仍直接展示响应中嵌入的真实下发记录及其平台 `messageId`
- 上行网关消息 ID 与关联平台消息 ID 分栏展示,不把供应商 MO `Msg_Id` 误作历史 MT Submit 消息 ID
- 未匹配或多候选上行仍可查询,并显示真实状态;多候选提示联系运营人员认领。
### TC-CLIENT-010 用户管理与企业管理员唯一性
@@ -1139,11 +1140,16 @@
### TC-SEND-008 上行短信匹配
- 优先级:P1
- 步骤:模拟带 messageId 的上行事件。
- 步骤:
1. 模拟带平台 `messageId` 的兼容上行事件。
2. 模拟真实供应商 MO:仅带独立 `gatewayMessageId`,不带历史平台 `messageId`,并分别制造接入号唯一匹配、手机号 72 小时唯一匹配和多候选场景。
3. 打开客户端和运营端详情。
- 预期结果:
- 创建 SmsUplinkMessage。
- tenantId 可通过 messageId 关联。
- 客户端和运营端均可查询
- `gatewayMessageId` 原样持久化;兼容事件仍可通过平台 `messageId` 精确关联。
- 不带平台 `messageId` 时按接入号、手机号 72 小时窗口执行匹配;唯一结果写入 `tenantId/applicationId/messageRecordId`,多候选保留为 `ambiguous`
- 客户端和运营端均可查询;详情优先使用 API 响应内嵌 `messageRecord`,不因 `messageId` 为空误报“无法匹配”。
- 运营端对已匹配或已认领应用显示“加入应用黑名单”,确认后调用真实企业应用黑名单 API;客户端保持只读。
### TC-SEND-009 未匹配上行短信入库
@@ -1151,6 +1157,7 @@
- 步骤:模拟不带 messageId 或匹配不到下发记录的上行事件。
- 预期结果:
- 上行短信仍入库。
- 供应商 MO `gatewayMessageId` 与平台 `messageId` 分别保存,不能用前者伪造后者的关联。
- tenantId 可为空。
- 运营端可查询并人工判断。
@@ -2804,6 +2811,20 @@
- 到期日志按 `archiveMonth=YYYY-MM` 进入归档表,在线表只删除已成功归档的记录,原始 id 和详情不丢失。
- 归档使用有界小批量和 `SKIP LOCKED`;归档失败时源日志仍保留,不阻塞正常日志写入。
### TC-LOG-012 系统日志日期区间默认值与后端过滤
- 优先级:P1
- 前置条件:运营端和客户端均存在跨越7天以上的系统日志,通讯交互日志也存在跨日数据。
- 步骤:
1. 分别进入运营端“系统与操作日志”“通讯交互日志”和客户端“系统日志”。
2. 核对日期区间默认值,查询并翻页。
3. 改为自定义起止日期后再次查询;运营端同时导出系统与操作日志。
4. 点击重置。
- 预期结果:
- 三处日期区间初始和重置后均为北京时间近7天,允许通过日期控件选择任意合法区间。
- 请求真实携带`createdAtFrom/createdAtTo`,后端按北京时间当日00:00:00.000至结束日23:59:59.999过滤。
- 列表总数、分页和导出使用同一日期条件,不返回区间外数据。
### TC-CUSTOMER-001 运营端创建客户并初始化租户
- 优先级:P0
@@ -4890,3 +4911,44 @@ npm run verify:phase8
| TC-CMPP-PHASE5-018 | 同连接并发状态回调 | 同一connectionId的connected与submit并发时幂等upsert且保持在线;不同connectionId超过cmppMaxConnections仍403,不能误断当前连接或漏SubmitResp |
执行记录:按企业微批发布后,单企业100 TPS为999/999响应、P95/P99=`243/466ms`、首次供应商Submit=`95.85 TPS`;单企业150 TPS冲击为1498/1498、`83/117ms`、首次Submit=`122.87 TPS`;双企业200 TPS冲击为1999/1999、`148/287ms`、首次Submit=`129.39 TPS`。三档最终有效运行均零拒绝、零节流、零连接错误,价格均325。双企业档账务1988 charged/646100、11 refunded/35752145个Submit/Outbox唯一,1950条终态回执投递1950次、重复0,973条离线pending通过零发送客户端排空。多Gateway P2未实施。
## TC-ADMIN-SMS-RECORD-DENSITY 运营端短信记录高密度列表(2026-08-27)
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-ADMIN-SMS-RECORD-DENSITY-001 | 打开短信记录搜索区 | 企业、应用、提交日期、手机号码、运营商、短信内容、通道、发送状态、是否含引流信息9项条件均保留,不能新增、删减或合并 |
| TC-ADMIN-SMS-RECORD-DENSITY-002 | 在桌面与窄视口查看搜索区 | 仅控件宽度和布局响应式变化,9项条件、查询和重置行为不变,无控件覆盖或截断 |
| TC-ADMIN-SMS-RECORD-DENSITY-003 | 查看包含单分片和多分片的短信记录 | 列表按日期分组;提交与回执分别显示日期和时分秒;计费列同时显示真实金额、分片数和字数;列表不显示“已补发”标签 |
| TC-ADMIN-SMS-RECORD-DENSITY-004 | 点击任一行最右侧箭头 | 继续打开既有发送详情弹窗,原有短信内容、通道发送与回执、状态信息及分片补偿审计保持不变 |
| TC-ADMIN-SMS-RECORD-DENSITY-005 | 使用真实本地API数据加载、查询、翻页和打开详情 | 页面非空、无异常遮罩,控制台无新增错误;数据仍来自原有真实API,不引入mock或localStorage业务数据 |
执行记录:本地真实API/PostgreSQL渲染通过,9项条件全部存在,1/2分片均显示在计费列;右箭头成功打开原有详情弹窗,干净页面控制台日志为空。R11契约、前后端构建、159项API专项测试及Gateway全包测试/vet通过。
## TC-LG-STEP46-47 客户端发送与 Gateway 回调回归(2026-08-27
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-LG-STEP46-47-001 | Gateway 批量结果消费时 Redis 短暂 I/O timeout | 批量消费协程不永久退出;重试并重建 consumer group 后继续消费 SubmitResult、ReceiptEvent 和 UplinkEvent |
| TC-LG-STEP46-47-002 | 普通 MO 上行没有可关联的平台 messageId | Outbox 允许事件入流和回调;API 按接入号/手机号窗口匹配,未匹配也必须落库 |
| TC-LG-STEP46-47-003 | `templateMismatchMode=direct_send` 应用选择已审核签名、输入自由正文和号码 | 不要求模板,提交按钮可用;仍进入后端签名、风控、余额、路由和发送策略 |
| TC-LG-STEP46-47-004 | 任意必填项未完成时查看提交区 | 禁用按钮旁明确显示当前第一个可操作原因,不出现无说明禁用 |
| TC-LG-STEP46-47-005 | 绕过系统文件选择器注入 XLSX | 前端同时核对扩展名和非空 MIME,明确提示“仅支持 CSV、TSV 或 TXT 文本文件”,不调用导入预览 API |
| TC-LG-STEP46-47-006 | 390×844 首次打开定时选择器 | 弹层限制在视口内,清空/今天/确定操作区首屏可见可点,内容过高时仅弹层内滚动 |
| TC-LG-STEP46-47-007 | 客户端批量任务显示 UTC ISO 时间 | 提交时间和定时时间统一转为 Asia/Shanghai `YYYY-MM-DD HH:mm:ss`,不显示原始 `T...Z` 字符串 |
执行记录:Gateway `resultoutbox` 定向测试及全包测试通过;API 45套528项通过;API构建、前端TypeScript与Vite生产构建通过。390×844 Chromium 渲染回归中,无模板直发按钮可用,日期弹层为366×476且操作区Y=431~471,XLSX明确拒绝,批次时间显示为北京时间,控制台无错误。
## TC-UI-REVIEW-20260827 客户端展示、三网报备与人工审核状态
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-UI-REVIEW-001 | 查看客户端登录、工作台和账户余额 | 登录页存在“返回官网”;工作台四张指标卡的数字均为`.metric-card > strong`直接文本,不使用金额专用类或`MoneyText`包装,并使用同一字体、字号、字重和颜色;账户状态不显示“余额水位”;账户余额页金额为继承系统字体、常规字重的普通大号黑字,顶部仅显示左侧图标和“账户余额”标题;客户端右上角无待审核任务按钮 |
| TC-UI-REVIEW-002 | 首次打开批量任务、发送详情和上行短信 | 三个日期区间均为北京时间近7天(含当天) |
| TC-UI-REVIEW-003 | 查看不同状态的短信发送记录和回执 | 用户态状态为中文;未知协议值显示“状态未知” |
| TC-UI-REVIEW-004 | 对比两端同一应用的CMPP状态 | 两端均按已连接、已断开、未开通显示 |
| TC-UI-REVIEW-005 | 查看签名及引流三网状态 | 不显示使用场景、已提交资料、审核状态;三网状态来自真实路由和报备任务,部分通过与全部通过均映射为“报备通过” |
| TC-UI-REVIEW-006 | 在通道签名活跃度热力图输入通道名 | 仅保留匹配通道的维度行;企业、应用、签名搜索仍有效 |
| TC-UI-REVIEW-007 | 在运营端短信任务进度选择任务状态 | 请求携带精确状态且结果仅含该状态;重置恢复全部状态 |
| TC-UI-REVIEW-008 | 创建命中人工审核规则的批量任务 | 两端显示“待人工审核”、进度为0;客户端无终止按钮,详情显示审核原因,运营审核页可见关联批量任务号 |
| TC-UI-REVIEW-009 | 审核通过或驳回批量任务 | 状态按真实后端刷新;驳回时展示原因,不重复入队或发送 |
| TC-UI-REVIEW-010 | 接口对接某个日志子接口失败 | 接口概览仍可用且提示中文,不直接显示`Internal server error` |
+83
View File
@@ -4021,3 +4021,86 @@ git diff --check
- 预生产最终监控标记和部署标记均为`523481299028d9ded470d7738add145ee2070bd1`。安装版本:Prometheus 3.14.0、Node Exporter 1.12.1、PostgreSQL Exporter 0.20.1、Redis Exporter 1.89.0、Nginx Exporter 1.5.3Prometheus加载9个规则组共83条规则。
- 发布后 Prometheus 9个 target 全部`up``pg_up=1``redis_up=1``nginx_up=1`9090/9100/9187/9121/9113及API/Worker metrics均只监听回环。Prometheus、5个 Exporter、MinIO、Nginx、API、Worker、Gateway、PostgreSQL和Redis均activeAPI/Gateway健康;供应商连接`desired=9/connected=9`,命令/结果 Stream 最终均`pending=0/lag=0`
- 浏览器只读验收已到达预生产运营端登录页,因当前浏览器没有运营端登录会话,没有输入凭据或验证码,故本轮未把登录后的页面截图作为验收证据;监控可用性以 Prometheus targets、PromQL和API进程实际环境变量为当前证据。未发送短信、未压测、未修改余额/应用/企业/白名单/临时号段/通道配置,未操作正式生产,多 Gateway P2 未实施。
## 2026-08-26 上行匹配展示与应用黑名单入口本地修复及提交前验证
- 只读回查预生产数据库时共有84条上行记录,其中61条已匹配、23条为多候选;82条没有平台`messageId`,但59条已通过接入号或手机号72小时窗口写入`messageRecordId`。因此“全部匹配不到”的直接原因是客户端和运营端详情在`messageId`为空时提前返回,忽略API已返回的`messageRecord`;这不代表后端未匹配。
- CMPP普通MO的Deliver `Msg_Id`是供应商为该上行分配的独立标识,不是历史MT Submit的消息ID。Gateway此前仅尝试用它查询本地Submit跟踪器,正常MO通常得不到平台`messageId`,且原始MO `Msg_Id`只写日志未持久化。本轮新增独立可空字段`SmsUplinkMessage.gatewayMessageId`及迁移,Gateway将MO `Msg_Id`原样随事件上送,API持久化并在两端详情与关联平台消息ID分栏展示;平台`messageId`继续只表示真实关联,不伪造关联。
- 客户端和运营端详情优先使用上行列表响应内嵌的真实`messageRecord`;只有历史兼容记录在缺少内嵌记录且存在平台`messageId`时才回查消息接口。多候选记录给出认领提示。运营端对已匹配或已认领到企业应用的上行恢复“加入应用黑名单”按钮,确认后调用现有真实企业应用黑名单API;客户端保持只读。
- 自动化门禁:Gateway/API队列5份契约样例通过;SendChain与Operations专项2套159项通过;API正式TypeScript构建、前端TypeScript检查、Vite生产构建、Prisma schema校验、Gateway全包`go test ./... -count=1``go vet ./...`均通过。Gateway首次全包测试仅既有限速时序用例偶发一次`delay=0`,该包连续5轮及随后全包复跑均通过,本轮未修改限速实现。
- 本轮功能改造仅发生在本地工作区,没有在远端环境执行数据库迁移,没有发送短信、压测、push或部署。新增迁移必须随未来授权发布执行后,新上行才会保存`gatewayMessageId`;历史记录不会反填供应商MO ID。预生产和正式生产均未改动,多Gateway P2未实施。
## 2026-08-27 运营端短信记录高密度列表本地调整
- 搜索区保留原有9项条件:企业、应用、提交日期、手机号码、运营商、短信内容、通道、发送状态、是否含引流信息;未新增、删除或合并条件,仅改为12列响应式布局并压缩控件宽度。
- 短信记录由大卡片改为按提交日期分组的紧凑行式列表;提交时间和回执时间均按日期、时分秒两行展示,无回执时明确显示“暂无回执”。计费列集中展示金额、分片数和字数,不展示“已补发”标签;最右侧箭头继续调用原有`SendDetailModal`,弹窗实现未修改。
- 本地真实API和PostgreSQL数据渲染验证通过:9项搜索条件全部存在,列表可见1/2分片计费记录,点击右箭头可打开原有发送详情、通道发送与回执、状态信息和分片补偿审计;另开干净页面控制台日志为空。
- 门禁通过:R11页面契约、前端TypeScript检查与Vite生产构建、API正式构建与Prisma校验、队列契约、SendChain/Operations 2套159项、Gateway全包`go test ./... -count=1``go vet ./...`。pnpm包装器因既有`msgpackr-extract`构建脚本未批准而中止,未放宽依赖策略,改用已安装的TypeScript/Vite入口完成等价构建。
- 本轮仅使用本地隔离环境,没有访问或修改预生产/生产,没有发送短信或压测。为渲染验证启动的PostgreSQL、API、前端预览和临时Redis均已停止;多Gateway P2未实施。
## 2026-08-27 LG第46/47步确认缺陷本地修复
- 对测试机`100.93.204.60`做只读复核:`gateway.submit.results` consumer group 为`lag=1974/pending=0`Gateway日志明确记录`2026-08-26 05:48:18 ... worker stopped: ... i/o timeout`。批量回调消费在一次Redis短暂读超时后永久退出,解释了随后SubmitResult、回执和上行事件均入流但不回写的两个P0。
- Gateway批量结果消费增加持续重试外层,每次重试重新确保consumer group存在;一次Redis I/O错误不再结束唯一结果消费协程。Outbox校验同时改为仅Submit结果必须具有平台`messageId`,普通MO上行可在没有历史MT关联时继续回调并落库。
- 客户端发送页按应用真实`templateMismatchMode`放行`direct_send`,保留签名和正文必填,并在提交区显示禁用原因;导入在读文件前校验CSV/TSV/TXT扩展名及MIMEXLSX不再当文本解析。
- 移动端日期选择器使用视口内固定弹层和内部滚动,操作区置底;批量任务提交/定时时间统一为Asia/Shanghai格式。
- 门禁通过:API 45套528项,Gateway全包(含新增Outbox重试与无messageId上行用例),API正式构建,前端TypeScript检查和Vite生产构建。390×844 Chromium回归的直发、XLSX拦截、定时弹层和批次时间均通过,控制台错误/警告0。
- 本轮没有部署、重启服务、发送短信或处理测试机1974条积压;没有访问或修改预生产/生产。因此当前是本地代码与自动化/渲染回归通过,测试机真实CMPP闭环需在后续部署授权后再验收。
## 2026-08-27 客户端展示、三网报备与人工审核状态优化(本地门禁)
- 完成用户明确的10项页面优化,并按`batch-task-manual-review-status-optimization-20260827.md`第一阶段实现人工审核状态修复;第二阶段孤立审核任务事务/补偿不在本提交范围。
- 客户端签名接口按运营端同一应用路由、通道运营商范围和报备任务计算三网汇总,只输出汇总,不暴露通道或报备任务明细。
- 人工审核状态改用共享前端映射;未知状态不再默认“发送中”。客户端仅`scheduled`可取消,运营审核列表返回并展示关联批量任务号。
- 本地前端TypeScript、API TypeScript正式构建和Vite生产构建通过;API全量45套529项通过。Gateway全包测试仅既有限速时序用例偶发一次`delay=38.136ms`,该包随后连续3轮通过,`go vet ./...`通过。客户端登录页真实渲染确认“返回官网”存在。
- 首次发布提交`01cffa4758af4908a8d4110fb5b039c0151226c4`前建立并完整校验恢复点`/opt/cmpp-platform-backups/ui-review-20260827T101712Z`;95项迁移均已应用,发布后8项核心服务active,API/Gateway健康,命令和结果Stream均`pending=0/lag=0`,发布窗口核心服务error级日志为空。此前一次在`pg_dump`参数校验阶段中止,未覆盖代码、配置或数据库;目录`ui-review-20260827T101624Z`不作为有效恢复点。
- 测试环境真实服务核验定位到客户端接口对接页原500的根因:测试机使用HTTP源地址,而后端强制要求HTTPS。追加提交`070a951e7cffb467734a4486ac46a83605a7f218`,默认仍拒绝不安全源地址,仅允许隔离测试环境通过`HTTP_API_ALLOW_INSECURE_ORIGIN=true`显式放行HTTP;新增OpenAPI单测11/11和API正式构建通过。
- 第二次发布前重新建立并校验恢复点`/opt/cmpp-platform-backups/ui-http-origin-20260827T104430Z`。最终部署标记为`070a951e7cffb467734a4486ac46a83605a7f218`;以`screenshot_client`所属企业的真实应用直接调用配置、凭据、Webhook、请求日志、投递日志五个服务方法全部成功,配置接口不再抛出Internal server error。8项核心服务active、API/Gateway健康、结果Stream`pending=0/lag=0`、发布后error级日志为空,运营端/客户端登录页及API健康地址从工作站访问均HTTP 200。
- 两次发布均仅操作测试环境`100.93.204.60`;未访问、回退、覆盖或部署预生产`8.160.169.106`,未发送短信或执行压力测试。第二阶段孤立审核任务事务/补偿仍未实施。
## 2026-08-27 客户端金额样式复核与系统日志日期区间(本地门禁)
- 用户在另一台电脑复核后指出首页金额视觉没有变化。重新比对最终CSS确认首次改动仅把原有约30px深色粗体重复声明为30px深色粗体,视觉差异不足;同时`MoneyText`组件不生成`.money-text`类,首次样式中的后代选择器不会命中。现改为明确的Arial/微软雅黑普通系统字体、38px、500字重、纯黑`#111111`,并显式清除背景、文字填充、阴影及特殊裁剪效果;最近充值使用28px紧凑规格。
- 运营端系统与操作日志、运营端通讯交互日志、客户端系统日志均改为可选起止日期的真实日期区间,初始及重置默认北京时间近7天。查询、分页和导出统一向后端传递`createdAtFrom/createdAtTo`,后端按开始日00:00:00.000至结束日23:59:59.999Asia/Shanghai)过滤。
- Chrome视觉夹具计算样式确认三个关键金额均为`rgb(17,17,17)`、38px、500字重、Arial/微软雅黑且控制台无error/warn;前端TypeScript与Vite生产构建通过。API正式TypeScript构建、操作日志/通讯日志专项34项、API全量45套532项通过。
- 本轮代码提交`33fa3709d9ba8238f4ae8fb6087d49a70363c0ed`。发布前新建并完整校验恢复点`/opt/cmpp-platform-backups/amount-log-date-20260827T120716Z`,随后仅部署测试环境`100.93.204.60`;最终部署标记与提交一致,95项迁移均已应用且无待执行迁移。
- 测试环境前端已切换为新资源`index-CbwHlDzn.css``index-BftfsVsS.js`;远端CSS明确包含38px纯黑金额规则,JavaScript包含日志日期区间。Chrome无缓存参数重新打开测试环境客户端登录页后加载同一新资源且控制台无error/warn。
- 真实测试数据库按`2026-08-21``2026-08-27`核验:操作日志服务返回总量44714,与数据库同一北京时间边界直接计数44714一致;通讯日志服务返回425843,与数据库直接计数425843一致。8项核心服务active、API/Gateway健康、Gateway结果Stream`pending=0/lag=0`,发布后核心服务error级日志为空。
- 预生产未访问或修改,未发送短信、未压测。登录后金额与日志控件最终人工视觉复核仍需使用现有客户端/运营端账号完成验证码登录;验证码未被自动绕过。
## 2026-08-28 客户端账户余额字体与标题复核发布
- 重新核对确认账户余额页此前复用了首页`.client-amount-value`,该类强制使用Arial/微软雅黑、38px和500字重,不符合账户余额页“继承系统字体、普通字重”的要求。余额页现改用独立`.client-billing-amount`:继承平台字体、32px、400字重和`--color-text-strong`纯深色,并移除摘要金额内的`MoneyText`拆分包装;首页金额类仍被首页4处金额使用,予以保留且不再影响余额页。
- 账户余额页顶部删除“账户”眉题,改为与其他客户端页面一致的左侧`WalletCards`图标加“账户余额”标题;`TC-UI-REVIEW-001`同步补充字体、标题结构验收口径。
- 本地前端TypeScript检查和Vite生产构建通过,代码提交为`5bd347e0105b6344b0cdf64592cf8f25c1496668`。首次远端续部署在恢复资产校验完成后因入口脚本无可执行位而停止,服务尚未重启;随后以`bash`显式调用同一脚本完成部署,没有跳过或重建恢复基线。
- 发布前恢复点`/opt/cmpp-platform-backups/billing-presentation-20260828T014051Z`包含PostgreSQL custom dump、原运行目录、环境/systemd/Nginx配置、原部署标记和SHA-256清单;`pg_restore --list`、两份tar目录及全部摘要校验通过。测试环境最终部署标记与代码提交一致,95项migration无待执行项。
- 发布后前端资源为`index-X8XfFWLh.css``index-DTiS-tkY.js`;服务器产物包含余额页专用类和标题文本,工作站全新浏览器标签加载同一资源且控制台无error/warn。客户端未登录访问按预期重定向登录页,因图形验证码未自动绕过,本轮没有把登录后的余额页渲染冒充浏览器验收证据。
- API、Send Worker、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、Security Agent、MinIO、Nginx、PostgreSQL和Redis均activeAPI/Gateway健康,Gateway结果Stream为`pending=0/lag=0`,发布窗口核心服务error级日志为空。仅部署测试环境`100.93.204.60`;预生产未访问或修改,未发送短信、未压测、未改余额或通道配置。
## 2026-08-28 客户端工作台金额统一与余额水位移除
- 工作台“可用发送额度”“今日消费金额”“今日返还金额”移除旧`.client-amount-value`覆盖,和“今日发送”共同使用`.metric-card strong`;“最近充值”恢复账户状态列表的普通强调文字。旧金额类及紧凑变体已无引用并从全局CSS删除。
- 账户状态中的“余额水位”标题、百分比、进度条及其专用派生计算全部删除;余额、消费和返还的真实API数据及金额格式化逻辑没有改变。`TC-UI-REVIEW-001`同步更新验收口径。
- 本地TypeScript检查、Vite生产构建和浏览器渲染验证通过。四张指标卡计算样式均为同一平台字体栈、30px、700字重、`rgb(17,24,39)`,页面中“余额水位”计数为0,控制台无error/warn。代码提交为`a70e9e2c078a6109dd87cf8e45aeff956faf24e8`
- 发布前建立并校验恢复点`/opt/cmpp-platform-backups/dashboard-metrics-20260828T021652Z`,包含PostgreSQL custom dump、原运行目录、环境/systemd/Nginx配置、原部署标记和SHA-256清单;`pg_restore --list`、两份tar目录和全部摘要校验通过。95项migration无待执行项。
- 测试环境最终部署标记与代码提交一致,新资源为`index-DObeLiN5.css``index-BrcWR_qc.js`。服务器与工作站HTTP回读确认CSS包含统一30px指标规则且不含旧金额类,JavaScript不含“余额水位”。应用内浏览器的测试机导航被本机客户端拦截,未冒充登录后真实页面验收;本地同产物渲染截图作为视觉证据。
- 11项相关服务均activeAPI/Gateway健康,Gateway结果Stream为`pending=0/lag=0`,发布窗口核心服务error级日志为空。仅部署测试环境`100.93.204.60`;预生产未访问或修改,未发送短信、未压测、未改账户、应用或通道配置。
## 2026-08-28 客户端工作台金额旧资源现场复核与直接文本发布
- Chrome真实已登录工作台现场确认用户看到的异常来自旧资源:页面仍加载`index-CbwHlDzn.css``index-BftfsVsS.js`,三个金额的真实DOM仍带`.client-amount-value`,计算样式为Arial/微软雅黑、38px、500;同时仍显示“余额水位”。这证明截图所见是旧标签页状态,不是上一轮新产物的计算结果。
- 为彻底消除首页金额专用路径,工作台移除全部`MoneyText`包装及其导入,四张指标卡金额/数量均改为无class、无子元素的`strong`直接文本;删除冗余`.metric-card--featured strong`规则。账户状态“最近充值”同样改为直接文本。全局`MoneyText`组件因其他业务页面仍使用而保留,但首页不再引用。
- 本地TypeScript检查和Vite生产构建通过;同正式CSS的浏览器渲染验证显示四个`strong`均无class、子元素数0,并全部为同一平台字体栈、30px、700字重、`rgb(17,24,39)`,控制台无error/warn。代码提交为`0b84370270c681dff3b15363f91b73566c6954b5`
- Chrome真实标签尝试刷新时被本机客户端策略以`ERR_BLOCKED_BY_CLIENT`拦截私网地址,因此无法把刷新后的登录态页面冒充验收证据;服务器HTTP回读和远端产物门禁用于确认新发布资源。用户侧原标签需关闭后重新打开或强制刷新以脱离旧内存资源。
- 发布前新建并校验恢复点`/opt/cmpp-platform-backups/dashboard-direct-text-20260828T023121Z`,包含PostgreSQL custom dump、原运行目录、环境/systemd/Nginx配置、原部署标记和SHA-256清单;`pg_restore --list`、两份tar目录及全部摘要校验通过。95项migration无待执行项。
- 测试环境最终部署标记与代码提交一致,新资源为`index-9yfCJZo4.css``index-piRXc72V.js`;远端门禁确认CSS不含旧金额类和首卡专属strong规则,JavaScript不含“余额水位”。11项相关服务activeAPI/Gateway健康,Gateway结果Stream`pending=0/lag=0`,发布窗口核心服务error级日志为空。仅部署测试环境`100.93.204.60`,预生产未访问或修改。
## 2026-08-28 代码质量 P0 整改与测试环境发布
- 按`docs/code-quality-remediation-plan-20260828.md`执行发布阻断整改,代码提交依次为`2744690`(可信租户、强哈希、Redis登录状态、DTO/依赖/分包/门禁)、`d85ff85`(最长2小时的回退兼容窗口)、`fc4a6a7`(管理员和smoke维护工具只写强哈希)。最终测试环境部署标记为`fc4a6a7afcfc3c1c11d1e353f46d5b724f48a39e`
- 真实双租户 API 矩阵通过:当前租户无header和一致header均成功;伪造其他租户header返回`403 CLIENT_TENANT_MISMATCH`;请求体tenantId被服务端覆盖为会话租户;跨租户应用资源ID返回404。一次性测试账号均清理,活跃`@integration.invalid`用户最终为0。
- 密码使用版本化scrypt格式,旧SHA-256只读兼容并在成功登录后条件更新。测试环境一次性旧哈希账号通过真实验证码和客户端登录接口迁移为scrypt后删除;4个未登录历史旧账号保持原值等待自然迁移。兼容环境变量已从配置和API进程移除,维护脚本真实创建的临时管理员也验证为scrypt后清理。
- API 47套542项、覆盖率69.77/53.47/71.08/73.05、前后端TypeScript/Vite构建、bundle budget、Gateway全包测试/go vet、Prisma、5份队列契约、代码质量和安全部署门禁全部通过。初始JS gzip约107.38 KiB;图表异步块约181.64 KiB,校准预算190 KiB。根生产依赖审计为0API子项目仍有2 moderate/7 high公告,既有兼容缓解门禁通过,需后续按兼容矩阵升级。
- 采用两阶段密码安全发布并在每次覆盖前重新建恢复资产:`code-quality-before-d85ff859-20260828T035007Z``code-quality-compatible-d85ff859-20260828T035426Z``code-quality-before-fc4a6a7-20260828T040150Z`。三份均含PostgreSQL custom dump、运行目录、配置、Redis RDB、MinIO数据、状态快照、回退说明和SHA-256;恢复清单、pg_restore、tar和Redis校验通过。
- 最终收尾为95/95迁移、11项服务active、入口HTTP 200、API/Gateway健康、Redis PONG、`gateway.submit.results pending=0/lag=0`,发布后7项核心服务error级日志均0。浏览器真实登录页完整且控制台error/warning为0;未自动输入账号密码/验证码,登录后深层页面仍保留为人工浏览器验收边界。
- 本轮没有发送短信、压测、修改余额/应用/通道或写入MinIO业务对象,也没有执行新的CMPP模拟器全链;两个SendChain超大Service未在安全发布中做高风险重构。完整证据和保留项见`docs/code-quality-remediation-result-20260828.md`。只操作测试环境`100.93.204.60`,预生产未访问或修改。
+7 -6
View File
@@ -19,7 +19,7 @@ type Envelope struct {
SchemaVersion string `json:"schemaVersion"`
MessageType MessageType `json:"messageType"`
TraceID string `json:"traceId"`
MessageID string `json:"messageId"`
MessageID string `json:"messageId,omitempty"`
ChannelID string `json:"channelId"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -118,11 +118,12 @@ type ReceiptEvent struct {
type UplinkEvent struct {
Envelope
SequenceID uint32 `json:"sequenceId"`
PhoneNumber string `json:"phoneNumber"`
DestID string `json:"destId"`
Content string `json:"content"`
ReceivedAt time.Time `json:"receivedAt"`
SequenceID uint32 `json:"sequenceId"`
GatewayMessageID string `json:"gatewayMessageId"`
PhoneNumber string `json:"phoneNumber"`
DestID string `json:"destId"`
Content string `json:"content"`
ReceivedAt time.Time `json:"receivedAt"`
}
type ConnectChannelCommand struct {
+4 -1
View File
@@ -232,9 +232,12 @@ func EventFromStreamValues(values map[string]interface{}) (Event, error) {
if err := json.Unmarshal([]byte(data), &event); err != nil {
return Event{}, err
}
if event.SchemaVersion != queue.SchemaVersion || event.EventID == "" || event.MessageID == "" {
if event.SchemaVersion != queue.SchemaVersion || event.EventID == "" || event.ChannelID == "" {
return Event{}, fmt.Errorf("invalid result Outbox envelope")
}
if (event.EventType == "submit_result" || event.EventType == "submit_segment_result") && event.MessageID == "" {
return Event{}, fmt.Errorf("submit result Outbox messageId is required")
}
if event.Path != "/gateway/events/submit-result" && event.Path != "/gateway/events/submit-segment-result" && event.Path != "/gateway/events/receipt/intake" && event.Path != "/gateway/events/uplink" && event.Path != "/gateway/events/dead-letter" {
return Event{}, fmt.Errorf("unsupported result Outbox path %q", event.Path)
}
@@ -3,6 +3,7 @@ package resultoutbox
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
@@ -33,6 +34,52 @@ func TestPublishSubmitSegmentIsIdempotent(t *testing.T) {
}
}
func TestUplinkWithoutPlatformMessageIDIsAValidOutboxEvent(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
outbox := New(client)
event := queue.UplinkEvent{
Envelope: queue.Envelope{
SchemaVersion: queue.SchemaVersion,
MessageType: queue.MessageTypeUplinkEvent,
ChannelID: "channel-1",
CreatedAt: time.Now().UTC(),
},
SequenceID: 1, PhoneNumber: "13800138000", DestID: "10690001",
Content: "UP", ReceivedAt: time.Now().UTC(),
}
if err := outbox.PublishUplink(context.Background(), event); err != nil {
t.Fatalf("publish uplink: %v", err)
}
streams, err := client.XRange(context.Background(), outbox.StreamName(), "-", "+").Result()
if err != nil || len(streams) != 1 {
t.Fatalf("read uplink stream: entries=%d err=%v", len(streams), err)
}
parsed, err := EventFromStreamValues(streams[0].Values)
if err != nil {
t.Fatalf("parse uplink without messageId: %v", err)
}
if parsed.EventType != "uplink" || parsed.MessageID != "" {
t.Fatalf("unexpected uplink envelope: %+v", parsed)
}
}
func TestRetryUntilCanceledRestartsAfterTransientFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
err := retryUntilCanceled(ctx, time.Millisecond, func() error {
calls++
if calls == 1 {
return errors.New("transient Redis read timeout")
}
cancel()
return ctx.Err()
})
if !errors.Is(err, context.Canceled) || calls != 2 {
t.Fatalf("retry result err=%v calls=%d, want context canceled after 2 calls", err, calls)
}
}
func TestBatchCallbackSendsMultipleEventsInOneRequest(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+24 -3
View File
@@ -43,12 +43,24 @@ func (o *Outbox) Run(ctx context.Context) error {
if strings.TrimSpace(o.APIBaseURL) == "" {
return fmt.Errorf("result Outbox API base URL is required")
}
if o.BatchEnabled {
return retryUntilCanceled(ctx, time.Second, func() error {
if err := o.ensureGroup(ctx); err != nil {
log.Printf("gateway result Outbox group initialization failed: %v", err)
return err
}
if err := o.runBatches(ctx); err != nil && ctx.Err() == nil {
// A transient Redis read error must not permanently stop the only
// consumer for submit results, receipts, and uplink events.
log.Printf("gateway result Outbox batch consume failed: %v", err)
return err
}
return ctx.Err()
})
}
if err := o.ensureGroup(ctx); err != nil {
return err
}
if o.BatchEnabled {
return o.runBatches(ctx)
}
pool := newCallbackPool(ctx, o, o.concurrency())
defer pool.wait()
for {
@@ -276,3 +288,12 @@ func sleep(ctx context.Context, duration time.Duration) {
case <-timer.C:
}
}
func retryUntilCanceled(ctx context.Context, delay time.Duration, operation func() error) error {
for ctx.Err() == nil {
if err := operation(); err != nil && ctx.Err() == nil {
sleep(ctx, delay)
}
}
return ctx.Err()
}
+7 -6
View File
@@ -124,14 +124,15 @@ func (c *connection) handleDeliver(pkt deliverPacket) error {
ChannelID: c.channelID,
CreatedAt: time.Now().UTC(),
},
SequenceID: pkt.seqID,
PhoneNumber: strings.TrimSpace(pkt.srcTerminalID),
DestID: strings.TrimSpace(pkt.destID),
Content: content,
ReceivedAt: time.Now().UTC(),
SequenceID: pkt.seqID,
GatewayMessageID: fmt.Sprint(pkt.msgID),
PhoneNumber: strings.TrimSpace(pkt.srcTerminalID),
DestID: strings.TrimSpace(pkt.destID),
Content: content,
ReceivedAt: time.Now().UTC(),
}
if c.protocolLogPublisher != nil {
c.emitProtocolLog(protocolLogEvent{Protocol: "cmpp", Direction: "channel_to_platform", EventType: "deliver_uplink", Status: "success", ChannelID: c.channelID, Account: c.config.Account, MessageID: cmd.MessageID, Phone: strings.TrimSpace(pkt.srcTerminalID), Detail: map[string]any{"sequenceId": pkt.seqID}})
c.emitProtocolLog(protocolLogEvent{Protocol: "cmpp", Direction: "channel_to_platform", EventType: "deliver_uplink", Status: "success", ChannelID: c.channelID, Account: c.config.Account, MessageID: cmd.MessageID, GatewayMessageID: fmt.Sprint(pkt.msgID), Phone: strings.TrimSpace(pkt.srcTerminalID), Detail: map[string]any{"sequenceId": pkt.seqID}})
}
var publishErr error
if c.eventPublisher != nil {
+48
View File
@@ -92,6 +92,54 @@ func TestHandleCMPP2DeliverReceiptPostsReceiptEvent(t *testing.T) {
}
}
func TestHandleCMPP2UplinkPreservesGatewayMessageIDWithoutPretendingItIsASubmitMessage(t *testing.T) {
events := make(chan queue.UplinkEvent, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gateway/events/uplink" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var event queue.UplinkEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
t.Fatalf("decode uplink event: %v", err)
}
events <- event
w.WriteHeader(http.StatusOK)
}))
defer api.Close()
conn := &connection{
channelID: "channel-1",
apiBaseURL: api.URL,
httpClient: api.Client(),
tracker: map[uint64]queue.SubmitCommand{},
}
if err := conn.handleDeliver(deliverPacketFromCMPP2(&cmpp.Cmpp2DeliverReqPkt{
SeqId: 8,
MsgId: 8412634832294102675,
DestId: "10690000",
SrcTerminalId: "13800000001",
RegisterDelivery: 0,
MsgContent: "TD",
})); err != nil {
t.Fatalf("handle uplink: %v", err)
}
select {
case event := <-events:
if event.GatewayMessageID != "8412634832294102675" {
t.Fatalf("GatewayMessageID = %q", event.GatewayMessageID)
}
if event.MessageID != "" {
t.Fatalf("MessageID = %q, want empty without a correlated submit", event.MessageID)
}
if event.PhoneNumber != "13800000001" || event.DestID != "10690000" || event.Content != "TD" {
t.Fatalf("unexpected uplink event: %+v", event)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for uplink event")
}
}
func TestReceiptStatusTreatsNonDeliveredFinalStatesAsUndelivered(t *testing.T) {
for _, stat := range []string{"UNKNOWN", "UNDELIV", "EXPIRED", "DELETED", "REJECTD"} {
if got := receiptStatus(stat); got != "undelivered" {
+17 -41
View File
@@ -16,7 +16,7 @@
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "7.18.1",
"react-router-dom": "7.18.2",
"vite": "^8.0.16",
"zustand": "^5.0.14"
},
@@ -277,9 +277,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -296,9 +293,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -315,9 +309,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -334,9 +325,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -353,9 +341,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -372,9 +357,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -479,6 +461,7 @@
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
@@ -489,6 +472,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -869,9 +853,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -892,9 +873,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -915,9 +893,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -938,9 +913,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1062,9 +1034,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -1111,6 +1083,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1151,6 +1124,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1160,6 +1134,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -1168,9 +1143,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -1190,12 +1165,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
@@ -1346,6 +1321,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
+8 -2
View File
@@ -18,6 +18,10 @@
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
"test:api": "npm --prefix api test",
"test:gateway": "npm run spike:gateway",
"lint": "npm run quality:verify && tsc --noEmit",
"format:check": "git diff --check",
"quality:verify": "node tools/quality/verify-code-quality.mjs",
"bundle:verify": "node tools/quality/verify-bundle-budget.mjs",
"verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build",
"verify:phase2": "npm run verify:phase1",
"verify:phase3": "npm run verify:phase2",
@@ -25,7 +29,8 @@
"verify:phase5": "npm run verify:phase4",
"verify:phase6": "npm run verify:phase5",
"verify:phase7": "npm run verify:phase6",
"verify:phase8": "npm run verify:phase7"
"verify:phase8": "npm run verify:phase7",
"verify:quality": "npm run lint && npm run format:check && npm run build && npm run bundle:verify && npm --prefix api run test:coverage"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.2",
@@ -36,7 +41,7 @@
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "7.18.1",
"react-router-dom": "7.18.2",
"vite": "^8.0.16",
"zustand": "^5.0.14"
},
@@ -47,6 +52,7 @@
"typescript": "^6.0.3"
},
"overrides": {
"nanoid": "3.3.18",
"postcss": "8.5.23"
}
}
+3 -3
View File
@@ -8,11 +8,11 @@ export const adminOperationsApi = {
getSendQuality: (date?: string) => request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
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; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) =>
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) =>
request<ProtocolInteractionLogResponse>(withQuery('/admin/system-logs/protocol-interactions', query)),
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyReconciliationReport> & { summary: ReconciliationReportSummary }>(withQuery('/admin/reports/reconciliation', query)),
+2 -2
View File
@@ -36,9 +36,9 @@ export const clientApi = {
tenantId,
body: JSON.stringify({ ...body, tenantId }),
}),
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; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string }) =>
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
+11
View File
@@ -0,0 +1,11 @@
export type AuditStatus = 'pending' | 'approved' | 'rejected';
export type AuditItem = {
id: string;
customer: string;
type: '模板' | '签名';
content: string;
risk: 'low' | 'medium' | 'high';
status: AuditStatus;
submittedAt: string;
};
+1
View File
@@ -29,6 +29,7 @@ export type RiskReviewTask = {
reviewedBy?: { id: string; username: string; displayName: string } | null;
riskHits?: Array<{ id: string; ruleName: string; reason: string }>;
_count?: { messageRecords: number };
batchTask?: { id: string; taskNo: string } | null;
};
export type RiskRuleItem = {
+3 -1
View File
@@ -211,6 +211,7 @@ export type ClientSmsApplication = {
scene?: string | null;
customerUnitPrice?: number | null;
queuePriority?: 'normal' | 'priority' | string | null;
templateMismatchMode?: 'reject' | 'manual_review' | 'direct_send' | string | null;
status: string;
dailyLimit?: number | null;
createdAt?: string;
@@ -246,7 +247,7 @@ export type ClientSmsSignature = {
};
export type ClientSmsSignatureView = Pick<ClientSmsSignature,
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'reportStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials'
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'reportStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials' | 'carrierReportSummary' | 'drainageCarrierReportSummary'
> & {
pendingReport?: boolean;
reportChangedAt?: string;
@@ -332,6 +333,7 @@ export type SmsBatchTask = {
auditStatus?: string | null;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
progressTotal: number;
progressSent?: number;
progressDelivered?: number;
+2 -1
View File
@@ -225,6 +225,7 @@ export type SmsUplinkMessage = {
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
gatewayMessageId?: string | null;
sequenceId?: number | null;
phoneNumber: string;
destId: string;
@@ -330,7 +331,7 @@ export type SystemLogExportResult = {
recordCount: number;
truncated: boolean;
content: string;
filters: { keyword?: string; level?: string; module?: string; range?: string };
filters: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string };
};
export type DailyReconciliationReport = {
+1
View File
@@ -100,6 +100,7 @@ export function LoginPage({ portal }: LoginPageProps) {
</div>
{error ? <p className="login-error">{error}</p> : null}
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
{!isAdmin ? <a className="login-return-link" href="https://www.lisglo.com"></a> : null}
</div>
</section>
<Modal
+3 -3
View File
@@ -295,7 +295,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
.filter((item) => !deferredKeyword || [item.channelName, item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
@@ -325,9 +325,9 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
</div>
<div className="signature-retirement-heatmap__actions">
<Input
aria-label={`${title}搜索企业、企业应用或签名`}
aria-label={`${title}搜索通道、企业、企业应用或签名`}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、企业应用或签名"
placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业应用或签名'}
value={keyword}
/>
<Tag tone="info">T-1 T-30</Tag>
@@ -3,164 +3,7 @@ import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerRe
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type DownstreamRequeueTaskItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Textarea, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
manual_requeueing: 'info',
awaiting_ack: 'info',
delivered: 'success',
failed: 'danger',
unconfirmed: 'warning',
rejected: 'danger',
};
const statusLabel: Record<string, string> = {
manual_requeueing: '人工重投处理中',
awaiting_ack: '等待客户端确认',
delivered: '客户端已确认',
failed: '投递失败',
unconfirmed: '客户端未确认',
rejected: '客户端拒绝',
};
function deliveryStatusLabel(record: DownstreamDeliveryRecord) {
if (record.status !== 'pending') return statusLabel[record.status] ?? record.status;
if (record.manualRetryCount > 0) return '人工重投排队中';
if (record.retryCount > 0) return '等待自动重试';
return '待首次投递';
}
const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
};
const attemptStatusLabel: Record<string, string> = {
awaiting_ack: '等待客户端确认',
acknowledged: '客户端已确认',
rejected: '客户端拒绝',
failed: '投递失败',
};
const requeueTaskStatusLabel: Record<string, string> = {
queued: '排队中', running: '执行中', paused: '已暂停', completed: '已完成',
partial_completed: '部分完成', terminated: '已终止',
};
const requeueItemStatusLabel: Record<string, string> = {
queued: '排队中', processing: '处理中', waiting_connection: '等待连接',
waiting_external_ack: '等待其他链路确认', waiting_ack: '等待客户确认', success: '成功',
failed: '失败', skipped: '跳过', unprocessed: '未处理',
};
function requeueTone(status: string) {
if (status === 'completed' || status === 'success') return 'success' as const;
if (status === 'partial_completed' || status === 'failed') return 'danger' as const;
if (status === 'paused' || status === 'skipped' || status === 'unprocessed') return 'warning' as const;
return 'info' as const;
}
type RequeueTarget =
| { kind: 'single'; record: DownstreamDeliveryRecord }
| { kind: 'batch'; ids: string[] };
type RequeueResult = {
status: 'success' | 'partial' | 'failed';
title: string;
message: string;
failures?: string[];
};
function formatLocalDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function recentSevenDays(): DateRangeValue {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
function attemptStatusTone(status: string) {
if (status === 'acknowledged') return 'success' as const;
if (status === 'rejected' || status === 'failed') return 'danger' as const;
if (status === 'awaiting_ack') return 'info' as const;
return 'neutral' as const;
}
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{deliveryStatusLabel(record)}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.manualRetryCount}</strong></div>
<div><span></span><strong>{record.lastRetriedAt ? formatDateTime(record.lastRetriedAt) : '-'}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
<div><span></span><strong>{record.sentAt ?? '-'}</strong></div>
<div><span></span><strong>{record.acknowledgedAt ?? '-'}</strong></div>
<div><span>ACK Result</span><strong>{record.ackResult ?? '-'}</strong></div>
<div><span>ACK Sequence_Id</span><strong>{record.ackSequenceId ?? '-'}</strong></div>
<div><span>ACK Msg_Id</span><strong>{record.ackMessageId ?? '-'}</strong></div>
<div><span> ID</span><strong>{record.connectionId ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3></h3>
<div className="downstream-attempt-timeline" aria-label="逐次投递记录">
{(record.attempts ?? []).map((attempt) => (
<article className="downstream-attempt-card" key={attempt.id}>
<div className={`downstream-attempt-marker downstream-attempt-marker--${attemptStatusTone(attempt.status)}`}>
{attempt.attemptNo}
</div>
<div className="downstream-attempt-card__body">
<header>
<strong> {attempt.attemptNo} </strong>
<Tag tone={attemptStatusTone(attempt.status)}>{attemptStatusLabel[attempt.status] ?? attempt.status}</Tag>
</header>
<dl className="downstream-attempt-card__times">
<div><dt></dt><dd>{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}</dd></div>
</dl>
<dl className="downstream-attempt-card__identifiers">
<div><dt> ID</dt><dd>{attempt.connectionId ?? '-'}</dd></div>
<div><dt>Sequence_Id</dt><dd>{attempt.sequenceId ?? '-'}</dd></div>
<div><dt>Msg_Id</dt><dd>{attempt.messageId ?? '-'}</dd></div>
</dl>
<div className={`downstream-attempt-result${attempt.errorMessage || attempt.failureType ? ' downstream-attempt-result--error' : ''}`}>
<span>ACK Result{attempt.ackResult ?? '-'}</span>
<strong>{attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'}</strong>
</div>
</div>
</article>
))}
{(record.attempts ?? []).length === 0 ? <p></p> : null}
</div>
</section>
<section className="report-history">
<h3>Payload</h3>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
</section>
</div>
</Modal>
);
}
import { DeliveryDetailModal, deliveryStatusLabel, deliveryTypeLabel, recentSevenDays, requeueItemStatusLabel, requeueTaskStatusLabel, requeueTone, statusLabel, statusTone, type RequeueResult, type RequeueTarget } from './downstreamDeliveryPresentation';
export function AdminDownstreamDeliveriesPage() {
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
+1 -1
View File
@@ -9,13 +9,13 @@ import { useNavigate } from 'react-router-dom';
import {
Breadcrumb,
Button,
Chart,
Modal,
MoneyText,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
+2
View File
@@ -143,6 +143,7 @@ export function AdminSmsAuditPage() {
<div className="sms-audit-cell-stack">
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<small>{record.application?.name ?? record.applicationId ?? '-'}</small>
<small>{record.batchTask?.taskNo ?? '-'}</small>
</div>
),
},
@@ -241,6 +242,7 @@ export function AdminSmsAuditPage() {
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="短信审核详情">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong></div>
<div><span></span><strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong></div>
<div><span></span><strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
@@ -17,6 +17,7 @@ export function AdminSmsTaskProgressPage() {
const [keyword, setKeyword] = useState('');
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [status, setStatus] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
@@ -35,6 +36,7 @@ export function AdminSmsTaskProgressPage() {
keyword: keyword || undefined,
enterpriseKeyword: enterprise === 'all' ? undefined : enterprise,
applicationKeyword: application === 'all' ? undefined : application,
status: status === 'all' ? undefined : status,
createdAtFrom: submittedDateRange.start || undefined,
createdAtTo: submittedDateRange.end || undefined,
page: targetPage,
@@ -84,6 +86,7 @@ export function AdminSmsTaskProgressPage() {
setKeyword('');
setEnterprise('all');
setApplication('all');
setStatus('all');
setSubmittedDateRange({});
}
@@ -112,6 +115,18 @@ export function AdminSmsTaskProgressPage() {
enterprise={enterprise}
enterpriseOptions={enterpriseOptions}
keyword={keyword}
status={status}
statusOptions={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '等待定时发送', value: 'scheduled' },
{ label: '排队中', value: 'queued' },
{ label: '发送中', value: 'sending' },
{ label: '已完成', value: 'finished' },
{ label: '失败', value: 'failed' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已终止', value: 'canceled' },
]}
submittedDateRange={submittedDateRange}
onApplicationChange={setApplication}
onEnterpriseChange={(value) => {
@@ -119,6 +134,7 @@ export function AdminSmsTaskProgressPage() {
setApplication('all');
}}
onKeywordChange={setKeyword}
onStatusChange={setStatus}
onQuery={() => {
if (page !== 1) setPage(1);
else loadTasks(1);
+69 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Search, Smartphone } from 'lucide-react';
import { Search, Smartphone, UserX } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
import {
Breadcrumb,
@@ -40,28 +40,41 @@ function candidateStatusText(status?: string | null) {
}
function UplinkDetailModal({
blacklistFeedback,
blacklisting,
claimError,
claimingId,
detailError,
matchedRecords,
matching,
message,
onAddBlacklist,
onClaim,
onClose,
}: {
blacklistFeedback: string;
blacklisting: boolean;
claimError: string;
claimingId: string;
detailError: string;
matchedRecords: SmsMessageRecord[];
matching: boolean;
message: SmsUplinkMessage;
onAddBlacklist: () => void;
onClaim: (candidate: SmsUplinkMatchCandidate) => void;
onClose: () => void;
}) {
const candidates = message.matchCandidates ?? [];
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
footer={<>
{message.tenantId && message.applicationId ? (
<Button disabled={blacklisting} icon={<UserX size={15} />} onClick={onAddBlacklist}>
{blacklisting ? '加入中...' : '加入应用黑名单'}
</Button>
) : null}
<Button onClick={onClose} variant="ghost"></Button>
</>}
onClose={onClose}
open
size="xl"
@@ -92,13 +105,21 @@ function UplinkDetailModal({
<strong>{message.destId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageId || '-'}</strong>
<span>ID</span>
<strong>{message.gatewayMessageId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageRecord?.messageId ?? message.messageId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{matchStatusText(message.matchStatus)}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.matchReason || '-'}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.content || '-'}</strong>
@@ -160,11 +181,13 @@ function UplinkDetailModal({
<section className="admin-uplink-match-section">
<h3></h3>
{blacklistFeedback ? <p className={blacklistFeedback.startsWith('已') ? 'form-success' : 'form-error'}>{blacklistFeedback}</p> : null}
{matching ? <p>...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !message.messageId ? <div className="admin-uplink-empty-match">ID</div> : null}
{!matching && message.messageId && matchedRecords.length === 0 && !detailError ? (
<div className="admin-uplink-empty-match"></div>
{!matching && matchedRecords.length === 0 && !detailError ? (
<div className="admin-uplink-empty-match">
{message.matchStatus === 'ambiguous' ? '存在多个候选,请先认领正确应用' : '暂无匹配发送记录'}
</div>
) : null}
{matchedRecords.map((record) => (
<article className="admin-uplink-match-card" key={record.id}>
@@ -211,6 +234,8 @@ export function AdminSmsUplinkRecordsPage() {
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
const [claimError, setClaimError] = useState('');
const [blacklisting, setBlacklisting] = useState(false);
const [blacklistFeedback, setBlacklistFeedback] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 10;
@@ -236,11 +261,12 @@ export function AdminSmsUplinkRecordsPage() {
function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message);
setMatchedRecords([]);
setMatchedRecords(message.messageRecord ? [message.messageRecord] : []);
setDetailError('');
setClaimError('');
setBlacklistFeedback('');
if (!message.messageId) {
if (message.messageRecord || !message.messageId) {
return;
}
@@ -251,6 +277,28 @@ export function AdminSmsUplinkRecordsPage() {
.finally(() => setMatching(false));
}
function handleAddBlacklist() {
if (!selectedMessage?.tenantId || !selectedMessage.applicationId) {
setBlacklistFeedback('请先匹配或认领企业应用');
return;
}
if (!window.confirm(`确认将 ${selectedMessage.phoneNumber} 加入当前应用黑名单?`)) {
return;
}
setBlacklisting(true);
setBlacklistFeedback('');
adminApi.createEnterpriseBlacklist({
tenantId: selectedMessage.tenantId,
applicationId: selectedMessage.applicationId,
phoneNumber: selectedMessage.phoneNumber,
reason: '上行短信人工加入',
status: 'active',
})
.then(() => setBlacklistFeedback('已加入当前应用黑名单'))
.catch((reason: Error) => setBlacklistFeedback(reason.message || '加入应用黑名单失败'))
.finally(() => setBlacklisting(false));
}
useEffect(() => {
loadData(page);
}, [page]);
@@ -273,6 +321,7 @@ export function AdminSmsUplinkRecordsPage() {
.then((updated) => {
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
setMatchedRecords(updated.messageRecord ? [updated.messageRecord] : []);
loadData(page);
})
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
@@ -283,20 +332,20 @@ export function AdminSmsUplinkRecordsPage() {
{
key: 'select',
title: '',
width: '72px',
width: '48px',
align: 'center',
render: () => <input aria-label="选择上行记录" className="admin-uplink-checkbox" type="checkbox" />,
},
{ key: 'phoneNumber', title: '手机号码', width: '170px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{getTime(record.receivedAt)}</strong> },
{ key: 'content', title: '上行内容', width: '360px', render: (record) => <span className="uplink-content" title={record.content}>{record.content}</span> },
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
{ key: 'matchStatus', title: '匹配状态', width: '140px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
{ key: 'phoneNumber', title: '手机号码', width: '120px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '156px', render: (record) => <strong>{getTime(record.receivedAt)}</strong> },
{ key: 'content', title: '上行内容', width: '260px', render: (record) => <span className="uplink-content" title={record.content}>{record.content}</span> },
{ key: 'channel', title: '上行通道', width: '160px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
{ key: 'accessNo', title: '上行接入号', width: '120px', render: (record) => <strong>{record.destId}</strong> },
{ key: 'matchStatus', title: '匹配状态', width: '100px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
{
key: 'actions',
title: '操作',
width: '140px',
width: '88px',
align: 'center',
render: (record) => (
<button className="admin-uplink-detail-link" onClick={() => openDetail(record)} type="button"></button>
@@ -332,12 +381,15 @@ export function AdminSmsUplinkRecordsPage() {
{selectedMessage ? (
<UplinkDetailModal
blacklistFeedback={blacklistFeedback}
blacklisting={blacklisting}
claimError={claimError}
claimingId={claimingId}
detailError={detailError}
matchedRecords={matchedRecords}
matching={matching}
message={selectedMessage}
onAddBlacklist={handleAddBlacklist}
onClaim={handleClaim}
onClose={() => setSelectedMessage(null)}
/>
+18 -25
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, FileText, Search } from 'lucide-react';
import { Button, Input, Modal, Pagination, Select, SystemLogExport, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { FileText, Search } from 'lucide-react';
import { Button, DateRangeInput, Input, Modal, Pagination, Select, SystemLogExport, Table, Tabs, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { adminApi, type OperationLogItem, type ProtocolInteractionLogItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
type LogLevel = 'info' | 'success' | 'warning' | 'error';
@@ -25,8 +25,11 @@ export function AdminSystemLogsPage() {
const [keyword, setKeyword] = useState('');
const [level, setLevel] = useState('all');
const [module, setModule] = useState('all');
const [range, setRange] = useState('today');
const [filters, setFilters] = useState({ keyword: '', level: 'all', module: 'all', range: 'today' });
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [filters, setFilters] = useState<{ keyword: string; level: string; module: string; createdAtFrom?: string; createdAtTo?: string }>(() => {
const initialRange = recentBeijingDateRange(7);
return { keyword: '', level: 'all', module: 'all', createdAtFrom: initialRange.start, createdAtTo: initialRange.end };
});
const [page, setPage] = useState(1);
const pageSize = 5;
const [logs, setLogs] = useState<OperationLogItem[]>([]);
@@ -59,16 +62,17 @@ export function AdminSystemLogsPage() {
function query() {
setPage(1);
setFilters({ keyword: keyword.trim(), level, module, range });
setFilters({ keyword: keyword.trim(), level, module, createdAtFrom: dateRange.start, createdAtTo: dateRange.end });
}
function reset() {
setKeyword('');
setLevel('all');
setModule('all');
setRange('today');
const nextDateRange = recentBeijingDateRange(7);
setDateRange(nextDateRange);
setPage(1);
setFilters({ keyword: '', level: 'all', module: 'all', range: 'today' });
setFilters({ keyword: '', level: 'all', module: 'all', createdAtFrom: nextDateRange.start, createdAtTo: nextDateRange.end });
}
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
@@ -118,14 +122,13 @@ export function AdminSystemLogsPage() {
value={level}
/>
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
<DateRangeInput label="日志时间" onChange={setDateRange} placeholder="选择日志时间区间" value={dateRange} />
<div className="system-log-filters__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button onClick={reset} variant="ghost"></Button>
</div>
</div>
<LogRange range={range} onChange={setRange} />
<div className="surface system-table-card">
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
<Pagination
@@ -222,7 +225,7 @@ function protocolStatusLabel(record: ProtocolInteractionLogItem) {
}
function ProtocolInteractionPanel({ active }: { active: boolean }) {
const [inputs, setInputs] = useState({ keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', range: 'today' });
const [inputs, setInputs] = useState(() => ({ keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', dateRange: recentBeijingDateRange(7) as DateRangeValue }));
const [filters, setFilters] = useState(inputs);
const [items, setItems] = useState<ProtocolInteractionLogItem[]>([]);
const [eventTypes, setEventTypes] = useState<string[]>([]);
@@ -235,7 +238,8 @@ function ProtocolInteractionPanel({ active }: { active: boolean }) {
useEffect(() => {
if (!active) return;
let cancelled = false;
adminApi.listProtocolInteractionLogs({ ...filters, page, pageSize })
const { dateRange, ...queryFilters } = filters;
adminApi.listProtocolInteractionLogs({ ...queryFilters, createdAtFrom: dateRange.start, createdAtTo: dateRange.end, page, pageSize })
.then((data) => {
if (cancelled) return;
setItems(data.items);
@@ -271,7 +275,7 @@ function ProtocolInteractionPanel({ active }: { active: boolean }) {
}
function reset() {
const next = { keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', range: 'today' };
const next = { keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', dateRange: recentBeijingDateRange(7) };
setInputs(next);
setFilters(next);
setPage(1);
@@ -286,9 +290,9 @@ function ProtocolInteractionPanel({ active }: { active: boolean }) {
<Select onChange={(event) => setInputs((value) => ({ ...value, direction: event.target.value }))} options={[{ label: '全部方向', value: 'all' }, ...Object.entries(directionLabels).map(([value, label]) => ({ value, label }))]} value={inputs.direction} />
<Select onChange={(event) => setInputs((value) => ({ ...value, eventType: event.target.value }))} options={[{ label: '全部事件', value: 'all' }, ...eventTypes.map((value) => ({ label: value, value }))]} value={inputs.eventType} />
<Select onChange={(event) => setInputs((value) => ({ ...value, status: event.target.value }))} options={[{ label: '全部结果', value: 'all' }, { label: '已接收', value: 'received' }, { label: '已受理', value: 'accepted' }, { label: '成功', value: 'success' }, { label: '重试中', value: 'retrying' }, { label: '失败', value: 'failed' }]} value={inputs.status} />
<DateRangeInput label="日志时间" onChange={(dateRange) => setInputs((value) => ({ ...value, dateRange }))} placeholder="选择日志时间区间" value={inputs.dateRange} />
<div className="system-log-filters__actions"><Button icon={<Search size={16} />} onClick={query}></Button><Button onClick={reset} variant="ghost"></Button></div>
</div>
<LogRange range={inputs.range} onChange={(range) => setInputs((value) => ({ ...value, range }))} />
<div className="surface system-table-card protocol-log-table">
<Table columns={columns} data={items} emptyText={error || '暂无通讯交互日志'} pagination={false} rowKey="id" />
<Pagination nextDisabled={page >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={Math.min(page, totalPages)} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} total={total} />
@@ -299,14 +303,3 @@ function ProtocolInteractionPanel({ active }: { active: boolean }) {
</div>
);
}
function LogRange({ range, onChange }: { range: string; onChange: (value: string) => void }) {
return (
<div className="system-log-range">
<span><CalendarDays size={18} /> </span>
{[{ label: '今天', value: 'today' }, { label: '近7天', value: '7d' }, { label: '近30天', value: '30d' }, { label: '全部', value: 'all' }].map((item) => (
<Button key={item.value} onClick={() => onChange(item.value)} size="sm" variant={range === item.value ? 'primary' : 'secondary'}>{item.label}</Button>
))}
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Check, X } from 'lucide-react';
import { Button, Tag, type TableColumn } from '@/components/ui';
import type { AuditItem, AuditStatus } from '@/mock';
import type { AuditItem, AuditStatus } from '@/api/types/audit';
const riskToneMap = {
low: 'success',
@@ -0,0 +1,162 @@
import { useMemo } from 'react';
import type { DownstreamDeliveryRecord } from '@/api/adminApi';
import { Button, Modal, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
export const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
manual_requeueing: 'info',
awaiting_ack: 'info',
delivered: 'success',
failed: 'danger',
unconfirmed: 'warning',
rejected: 'danger',
};
export const statusLabel: Record<string, string> = {
manual_requeueing: '人工重投处理中',
awaiting_ack: '等待客户端确认',
delivered: '客户端已确认',
failed: '投递失败',
unconfirmed: '客户端未确认',
rejected: '客户端拒绝',
};
export function deliveryStatusLabel(record: DownstreamDeliveryRecord) {
if (record.status !== 'pending') return statusLabel[record.status] ?? record.status;
if (record.manualRetryCount > 0) return '人工重投排队中';
if (record.retryCount > 0) return '等待自动重试';
return '待首次投递';
}
export const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
};
const attemptStatusLabel: Record<string, string> = {
awaiting_ack: '等待客户端确认',
acknowledged: '客户端已确认',
rejected: '客户端拒绝',
failed: '投递失败',
};
export const requeueTaskStatusLabel: Record<string, string> = {
queued: '排队中', running: '执行中', paused: '已暂停', completed: '已完成',
partial_completed: '部分完成', terminated: '已终止',
};
export const requeueItemStatusLabel: Record<string, string> = {
queued: '排队中', processing: '处理中', waiting_connection: '等待连接',
waiting_external_ack: '等待其他链路确认', waiting_ack: '等待客户确认', success: '成功',
failed: '失败', skipped: '跳过', unprocessed: '未处理',
};
export function requeueTone(status: string) {
if (status === 'completed' || status === 'success') return 'success' as const;
if (status === 'partial_completed' || status === 'failed') return 'danger' as const;
if (status === 'paused' || status === 'skipped' || status === 'unprocessed') return 'warning' as const;
return 'info' as const;
}
export type RequeueTarget =
| { kind: 'single'; record: DownstreamDeliveryRecord }
| { kind: 'batch'; ids: string[] };
export type RequeueResult = {
status: 'success' | 'partial' | 'failed';
title: string;
message: string;
failures?: string[];
};
function formatLocalDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
export function recentSevenDays(): DateRangeValue {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
function attemptStatusTone(status: string) {
if (status === 'acknowledged') return 'success' as const;
if (status === 'rejected' || status === 'failed') return 'danger' as const;
if (status === 'awaiting_ack') return 'info' as const;
return 'neutral' as const;
}
export function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{deliveryStatusLabel(record)}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.manualRetryCount}</strong></div>
<div><span></span><strong>{record.lastRetriedAt ? formatDateTime(record.lastRetriedAt) : '-'}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
<div><span></span><strong>{record.sentAt ?? '-'}</strong></div>
<div><span></span><strong>{record.acknowledgedAt ?? '-'}</strong></div>
<div><span>ACK Result</span><strong>{record.ackResult ?? '-'}</strong></div>
<div><span>ACK Sequence_Id</span><strong>{record.ackSequenceId ?? '-'}</strong></div>
<div><span>ACK Msg_Id</span><strong>{record.ackMessageId ?? '-'}</strong></div>
<div><span> ID</span><strong>{record.connectionId ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3></h3>
<div className="downstream-attempt-timeline" aria-label="逐次投递记录">
{(record.attempts ?? []).map((attempt) => (
<article className="downstream-attempt-card" key={attempt.id}>
<div className={`downstream-attempt-marker downstream-attempt-marker--${attemptStatusTone(attempt.status)}`}>
{attempt.attemptNo}
</div>
<div className="downstream-attempt-card__body">
<header>
<strong> {attempt.attemptNo} </strong>
<Tag tone={attemptStatusTone(attempt.status)}>{attemptStatusLabel[attempt.status] ?? attempt.status}</Tag>
</header>
<dl className="downstream-attempt-card__times">
<div><dt></dt><dd>{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}</dd></div>
</dl>
<dl className="downstream-attempt-card__identifiers">
<div><dt> ID</dt><dd>{attempt.connectionId ?? '-'}</dd></div>
<div><dt>Sequence_Id</dt><dd>{attempt.sequenceId ?? '-'}</dd></div>
<div><dt>Msg_Id</dt><dd>{attempt.messageId ?? '-'}</dd></div>
</dl>
<div className={`downstream-attempt-result${attempt.errorMessage || attempt.failureType ? ' downstream-attempt-result--error' : ''}`}>
<span>ACK Result{attempt.ackResult ?? '-'}</span>
<strong>{attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'}</strong>
</div>
</div>
</article>
))}
{(record.attempts ?? []).length === 0 ? <p></p> : null}
</div>
</section>
<section className="report-history">
<h3>Payload</h3>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
</section>
</div>
</Modal>
);
}
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState, type ComponentProps } from '
import type { EChartsOption } from 'echarts';
import { AlertTriangle, Ban, BellRing, RefreshCw, Settings2, ShieldCheck, ShieldOff } from 'lucide-react';
import { adminApi, type SecurityAlert, type SecurityBlock, type SecurityOverview, type SecurityProtectedNetwork, type SecurityRule } from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Modal as BaseModal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal as BaseModal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import './AdminSecurityDetectionPage.css';
const statusLabels: Record<string, string> = { open: '待处理', acknowledged: '已确认', blocked: '已封禁', block_failed: '封禁失败', ignored: '已忽略', requested: '执行中', failed: '失败', released: '已解封' };
@@ -5,14 +5,29 @@
.admin-sms-record-filter {
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: var(--space-4);
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.admin-sms-record-filter__field {
grid-column: span 2;
min-width: 0;
}
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content {
grid-column: span 3;
}
.admin-sms-record-filter__field.is-phone {
grid-column: span 2;
}
.admin-sms-record-filter__actions {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(120px, 1fr));
grid-column: span 3;
grid-template-columns: repeat(2, minmax(88px, 1fr));
}
.admin-sms-record-table-card {
@@ -25,8 +40,8 @@
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
min-height: 76px;
padding: var(--space-4) var(--space-6);
min-height: 56px;
padding: var(--space-2) var(--space-4);
}
.admin-sms-record-table-card .ui-table-wrap {
@@ -35,52 +50,64 @@
}
.admin-sms-record-list {
overflow-x: auto;
padding: 0 var(--space-4) var(--space-3);
}
.admin-sms-record-list__header,
.admin-sms-record-card {
align-items: center;
display: grid;
gap: var(--space-2);
padding: var(--space-3);
gap: var(--space-3);
grid-template-columns: 96px 96px minmax(360px, 1fr) 96px 112px 36px;
min-width: 960px;
}
.admin-sms-record-list__header {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
height: 38px;
padding: 0 var(--space-3);
}
.admin-sms-record-list__header span:last-child {
text-align: center;
}
.admin-sms-record-group-title {
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin: 0 calc(var(--space-4) * -1);
padding: var(--space-2) var(--space-7);
}
.admin-sms-record-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
min-height: 72px;
padding: var(--space-2) var(--space-3);
}
.admin-sms-record-card:hover {
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
box-shadow: var(--shadow-sm);
}
.admin-sms-record-card > header {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1fr) auto auto;
}
.admin-sms-record-card > header time {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
background: color-mix(in srgb, var(--color-selected) 4%, var(--color-surface));
}
.admin-sms-record-card .admin-sms-record-content {
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
display: -webkit-box;
line-height: 1.55;
line-height: 1.45;
max-width: none;
min-width: 0;
overflow: hidden;
padding: var(--space-2) var(--space-3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-sms-record-card .admin-sms-record-content.is-drainage {
background: color-mix(in srgb, #f59e0b 13%, var(--color-surface));
border: 1px solid color-mix(in srgb, #f59e0b 34%, var(--color-border));
color: #92400e;
}
.admin-sms-record-content mark {
@@ -95,7 +122,7 @@
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin-left: var(--space-3);
margin-left: var(--space-2);
padding: 1px var(--space-2);
vertical-align: middle;
}
@@ -111,32 +138,43 @@
color: var(--color-text-muted);
}
.admin-sms-record-card__meta {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-sms-record-card__meta > div {
.admin-sms-record-main {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.admin-sms-record-card__meta span,
.admin-sms-record-card__meta small {
.admin-sms-record-context {
align-items: center;
color: var(--color-text-muted);
display: flex;
font-size: var(--font-size-xs);
gap: var(--space-2) var(--space-4);
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.admin-sms-record-context span {
overflow: hidden;
text-overflow: ellipsis;
}
.admin-sms-record-time,
.admin-sms-record-billing {
display: grid;
font-size: var(--font-size-xs);
gap: 2px;
}
.admin-sms-record-time span,
.admin-sms-record-billing span {
color: var(--color-text-muted);
}
.admin-sms-record-card__meta strong {
.admin-sms-record-time strong,
.admin-sms-record-billing strong {
color: var(--color-text-strong);
overflow-wrap: anywhere;
}
.admin-sms-record-card > footer {
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
padding-top: var(--space-2);
}
.admin-sms-record-table {
@@ -221,11 +259,21 @@
}
.admin-sms-record-detail-link {
background: transparent;
border: 0;
align-items: center;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-selected);
font-weight: var(--font-weight-semibold);
display: inline-flex;
height: 32px;
justify-content: center;
padding: 0;
width: 32px;
}
.admin-sms-record-detail-link:hover {
background: var(--color-selected-soft);
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
}
.admin-sms-send-detail {
@@ -392,8 +440,6 @@
}
@media (max-width: 900px) {
.admin-sms-record-card > header,
.admin-sms-record-card__meta,
.admin-sms-detail-overview,
.admin-sms-detail-status-grid,
.admin-sms-route-list dl,
@@ -402,8 +448,11 @@
grid-template-columns: 1fr;
}
.admin-sms-record-card > header time {
justify-self: start;
.admin-sms-record-filter__field,
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content,
.admin-sms-record-filter__actions {
grid-column: span 6;
}
}
@@ -463,4 +512,11 @@
.admin-sms-route-list dl {
grid-template-columns: 1fr;
}
.admin-sms-record-filter__field,
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content,
.admin-sms-record-filter__actions {
grid-column: auto;
}
}
@@ -85,15 +85,15 @@ export function SmsRecordFilter({
}: SmsRecordFilterProps) {
return (
<div className="surface admin-sms-record-filter">
<Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} />
<Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} />
<Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
<Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} />
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} />
<Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} />
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} />
<div className="admin-sms-record-filter__field is-enterprise"><Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} /></div>
<div className="admin-sms-record-filter__field is-application"><Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} /></div>
<div className="admin-sms-record-filter__field is-date"><DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} /></div>
<div className="admin-sms-record-filter__field is-phone"><Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} /></div>
<div className="admin-sms-record-filter__field is-carrier"><Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} /></div>
<div className="admin-sms-record-filter__field is-content"><Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} /></div>
<div className="admin-sms-record-filter__field is-channel"><Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} /></div>
<div className="admin-sms-record-filter__field is-status"><Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /></div>
<div className="admin-sms-record-filter__field is-drainage"><Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /></div>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
<Button onClick={onReset} variant="ghost"></Button>
+46 -25
View File
@@ -1,4 +1,4 @@
import { Download } from 'lucide-react';
import { ChevronRight, Download } from 'lucide-react';
import type { ReactNode } from 'react';
import type { SmsMessageRecord } from '@/api/adminApi';
import { Button, CarrierTag, MoneyText, Pagination } from '@/components/ui';
@@ -8,7 +8,6 @@ import {
getDate,
getRecordStatus,
getStatusLabel,
getTime,
statusDotClassMap,
} from './smsRecordModel';
@@ -60,36 +59,58 @@ export function SmsRecordList({
onOpenDetail,
onPageChange,
}: SmsRecordListProps) {
let previousDate = '';
return (
<div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={onExport} variant="ghost">CSV</Button>
</div>
<div className="admin-sms-record-list">
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : records.map((record) => (
<article className="admin-sms-record-card" key={record.id}>
<header>
<div className="admin-sms-record-sender">
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
</div>
<StatusLine record={record} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</small></div>
<div><span></span><strong>{record.billingUnits} / <MoneyText>¥{formatCents(record.amountCents)}</MoneyText></strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : (
<>
<div aria-hidden="true" className="admin-sms-record-list__header">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<footer><button className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} type="button"></button></footer>
</article>
))}
{records.map((record) => {
const submitDate = getDate(record.queuedAt);
const showDateGroup = submitDate !== previousDate;
previousDate = submitDate;
return (
<div className="admin-sms-record-group" key={record.id}>
{showDateGroup ? <div className="admin-sms-record-group-title">{submitDate}</div> : null}
<article className="admin-sms-record-card">
<StatusLine record={record} />
<time className="admin-sms-record-time" dateTime={record.queuedAt}>
<span>{submitDate}</span><strong>{getClock(record.queuedAt)}</strong>
</time>
<div className="admin-sms-record-main">
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-context">
<span>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} / {record.application?.name ?? record.applicationId ?? '-'}</span>
<span>{record.phoneNumber} · {record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</span>
<span>{record.channel?.name ?? record.channelId ?? '-'}</span>
</div>
</div>
<time className="admin-sms-record-time" dateTime={record.deliveredAt ?? undefined}>
{record.deliveredAt ? <><span>{getDate(record.deliveredAt)}</span><strong>{getClock(record.deliveredAt)}</strong></> : <span></span>}
</time>
<div className="admin-sms-record-billing">
<MoneyText>¥{formatCents(record.amountCents)}</MoneyText>
<span>{record.billingUnits} · {record.content.length} </span>
</div>
<button aria-label="查看发送详情" className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} title="查看发送详情" type="button"><ChevronRight size={18} /></button>
</article>
</div>
);
})}
</>
)}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
@@ -7,10 +7,13 @@ type SmsTaskFilterProps = {
enterprise: string;
enterpriseOptions: Array<{ label: string; value: string }>;
keyword: string;
status: string;
statusOptions: Array<{ label: string; value: string }>;
submittedDateRange: DateRangeValue;
onApplicationChange: (value: string) => void;
onEnterpriseChange: (value: string) => void;
onKeywordChange: (value: string) => void;
onStatusChange: (value: string) => void;
onQuery: () => void;
onReset: () => void;
onSubmittedDateRangeChange: (value: DateRangeValue) => void;
@@ -22,10 +25,13 @@ export function SmsTaskFilter({
enterprise,
enterpriseOptions,
keyword,
status,
statusOptions,
submittedDateRange,
onApplicationChange,
onEnterpriseChange,
onKeywordChange,
onStatusChange,
onQuery,
onReset,
onSubmittedDateRangeChange,
@@ -40,6 +46,7 @@ export function SmsTaskFilter({
value={enterprise}
/>
<Select label="选择应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
<Select label="任务状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<DateRangeInput label="提交时间" onChange={onSubmittedDateRangeChange} value={submittedDateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
@@ -102,7 +102,7 @@ export function SmsTaskTable({
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'completed' ? 'completed' : ['failed', 'rejected', 'canceled'].includes(record.status) ? 'terminated' : 'sending'}`} style={{ width: `${progress}%` }} />
</div>
</div>
</td>
@@ -111,7 +111,7 @@ export function SmsTaskTable({
<div className="admin-task-actions">
<Button icon={<Eye size={15} />} onClick={() => onOpenDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
disabled={!['sending', 'submitted'].includes(record.rawStatus)}
icon={<StopCircle size={15} />}
onClick={() => onTerminate(record)}
size="sm"
@@ -88,9 +88,19 @@ export function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: ()
<span><strong>{formatNumber(task.submittedSuccess)}</strong></span>
<span><strong>{formatNumber(task.successCount)}</strong></span>
</div>
{task.status === 'pending_review' ? <p className="muted"></p> : null}
</div>
</section>
{(task.reviewReason || task.rejectReason || task.riskTaskId) ? <section className="admin-task-card">
<h3></h3>
<dl className="admin-task-info-list">
{task.riskTaskId ? <div><dt></dt><dd>{task.riskTaskId}</dd></div> : null}
{task.reviewReason ? <div><dt></dt><dd>{task.reviewReason}</dd></div> : null}
{task.rejectReason ? <div><dt></dt><dd>{task.rejectReason}</dd></div> : null}
</dl>
</section> : null}
<section className="admin-task-card">
<h3><BarChart3 size={18} /></h3>
<div className="admin-task-template-block">
+9 -21
View File
@@ -1,20 +1,11 @@
import type { SmsBatchTask, SmsMessageRecord } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import type { CarrierStat, RegionStat, SmsTask, TaskStatus } from './taskTypes';
import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus';
export const statusLabels: Record<TaskStatus, string> = {
sending: '发送中',
completed: '已完成',
terminated: '已终止',
failed: '失败',
};
export const statusLabels = Object.fromEntries(Object.entries(batchTaskStatusMeta).map(([key, value]) => [key, value.label])) as Record<TaskStatus, string>;
export const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
sending: 'info',
completed: 'success',
terminated: 'neutral',
failed: 'danger',
};
export const statusTones = Object.fromEntries(Object.entries(batchTaskStatusMeta).map(([key, value]) => [key, value.tone])) as Record<TaskStatus, 'warning' | 'info' | 'success' | 'neutral' | 'danger'>;
export const sendTypeLabels = {
immediate: '立即发送',
@@ -51,13 +42,6 @@ export function messageStatusLabel(status: string) {
}[status] ?? status;
}
function normalizeTaskStatus(status: string): TaskStatus {
if (['finished', 'completed', 'done'].includes(status)) return 'completed';
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated';
if (['failed', 'rejected'].includes(status)) return 'failed';
return 'sending';
}
function countMessages(messages: SmsMessageRecord[] | undefined, statuses: string[]) {
return (messages ?? []).filter((message) => statuses.includes(message.status)).length;
}
@@ -139,11 +123,15 @@ export function mapTask(task: SmsBatchTask): SmsTask {
scheduledAt: task.scheduledAt,
submittedCount,
submittedSuccess: submittedCount,
sentCount: Math.min(task.phoneTotal, processedCount),
sentCount: task.status === 'pending_review' ? 0 : Math.min(task.phoneTotal, processedCount),
successCount,
failedCount,
status: normalizeTaskStatus(task.status),
status: normalizeBatchTaskStatus(task.status),
rawStatus: task.status,
auditStatus: task.auditStatus,
reviewReason: task.reviewReason,
rejectReason: task.rejectReason,
riskTaskId: task.riskTaskId,
carriers: task.messageStats ? buildCarrierStatsFromAggregates(task.messageStats) : buildCarrierStats(messages),
regions: task.messageStats ? buildRegionStatsFromAggregates(task.messageStats) : buildRegionStats(messages),
};
@@ -1,4 +1,6 @@
export type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
import type { BatchTaskDisplayStatus } from '@/utils/batchTaskStatus';
export type TaskStatus = BatchTaskDisplayStatus;
export type SendType = 'immediate' | 'scheduled';
export type CarrierStat = {
@@ -33,6 +35,10 @@ export type SmsTask = {
failedCount: number;
status: TaskStatus;
rawStatus: string;
auditStatus?: string | null;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
carriers: CarrierStat[];
regions: RegionStat[];
};
@@ -24,7 +24,8 @@ import {
type InfrastructureMonitoringOverview,
type InfrastructureMonitoringRange,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import './AdminSystemMonitoringPage.css';
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
+2 -2
View File
@@ -29,10 +29,10 @@ const statusToneMap: Record<LinkStatus, 'success' | 'warning' | 'danger' | 'info
};
function normalizeStatus(application: ClientSmsApplication): LinkStatus {
if (application.status !== 'active') {
if (application.status !== 'active' || application.interfaceEnabled === false || application.cmppStatus === 'inactive') {
return 'inactive';
}
return application.cmppStatus ?? 'inactive';
return application.cmppStatus === 'connected' ? 'connected' : 'disconnected';
}
function mapParams(params: ApplicationCmppParams): ParamRow[] {
+24 -32
View File
@@ -19,8 +19,8 @@ import {
type TableColumn,
} from '@/components/ui';
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
import { batchTaskStatusMeta, canClientCancelBatchTask, normalizeBatchTaskStatus, type BatchTaskDisplayStatus } from '@/utils/batchTaskStatus';
type BatchTask = {
id: string;
@@ -36,19 +36,11 @@ type BatchTask = {
failedCount: number;
totalCount: number;
templateContent: string;
status: BatchTaskStatus;
};
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
completed: 'success',
sending: 'info',
terminated: 'danger',
};
const statusLabelMap: Record<BatchTaskStatus, string> = {
completed: '已完成',
sending: '发送中',
terminated: '已终止',
status: BatchTaskDisplayStatus;
rawStatus: string;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
};
function splitSignature(content: string) {
@@ -71,13 +63,8 @@ function getDeliveredCount(task: BatchTask) {
return task.deliveredCount;
}
function normalizeTaskStatus(status: string): BatchTaskStatus {
if (['completed', 'done'].includes(status)) return 'completed';
if (['cancelled', 'terminated', 'rejected', 'failed'].includes(status)) return 'terminated';
return 'sending';
}
function mapTask(task: SmsBatchTask): BatchTask {
const displayStatus = normalizeBatchTaskStatus(task.status);
return {
id: task.taskNo || task.id,
backendId: task.id,
@@ -87,12 +74,16 @@ function mapTask(task: SmsBatchTask): BatchTask {
wordCount: [...task.content].length,
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: task.scheduledAt,
sentCount: task.progressSent ?? task.submittedTotal ?? 0,
sentCount: displayStatus === 'pending_review' ? 0 : task.progressSent ?? task.submittedTotal ?? 0,
deliveredCount: task.progressDelivered ?? task.successTotal ?? 0,
failedCount: task.progressFailed ?? task.failedTotal ?? 0,
totalCount: task.progressTotal || task.phoneTotal,
templateContent: task.content,
status: normalizeTaskStatus(task.status),
status: displayStatus,
rawStatus: task.status,
reviewReason: task.reviewReason,
rejectReason: task.rejectReason,
riskTaskId: task.riskTaskId,
};
}
@@ -105,7 +96,7 @@ export function ClientBatchTasksPage() {
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
@@ -165,12 +156,12 @@ export function ClientBatchTasksPage() {
render: (record) => (
<div className="batch-task-id">
<strong>{record.id}</strong>
<Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
<Tag tone={batchTaskStatusMeta[record.status].tone}>{batchTaskStatusMeta[record.status].label}{record.status === 'unknown' ? `${record.rawStatus}` : ''}</Tag>
</div>
),
},
{ key: 'applicationName', title: '应用名称', width: '150px', render: (record) => record.applicationName },
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
{ key: 'submittedAt', title: '提交时间', width: '150px', render: (record) => formatDateTime(record.submittedAt) },
{ key: 'phoneCount', title: '发送号码数', width: '120px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
{ key: 'wordCount', title: '单号码字数', width: '120px', render: (record) => <strong>{record.wordCount} </strong> },
{
@@ -183,7 +174,7 @@ export function ClientBatchTasksPage() {
<Clock3 size={14} />
{record.sendType === 'immediate' ? '立即发送' : '定时发送'}
</span>
{record.scheduledAt ? <small>{record.scheduledAt}</small> : null}
{record.scheduledAt ? <small>{formatDateTime(record.scheduledAt)}</small> : null}
</div>
),
},
@@ -214,15 +205,14 @@ export function ClientBatchTasksPage() {
render: (record) => (
<div className="batch-actions">
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
{canClientCancelBatchTask(record.rawStatus) ? <Button
icon={<StopCircle size={14} />}
onClick={() => terminateTask(record.id)}
size="sm"
variant="ghost"
>
</Button>
</Button> : null}
</div>
),
},
@@ -324,7 +314,7 @@ export function ClientBatchTasksPage() {
>
{selectedTask ? (
<div className="task-detail">
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
<DetailSection title="基本信息" extra={<Tag tone={batchTaskStatusMeta[selectedTask.status].tone}>{batchTaskStatusMeta[selectedTask.status].label}{selectedTask.status === 'unknown' ? `${selectedTask.rawStatus}` : ''}</Tag>}>
<DetailInfoGrid
items={[
{ label: '发送批次号', value: selectedTask.id },
@@ -344,6 +334,8 @@ export function ClientBatchTasksPage() {
{ label: '批次总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '总计费条数', value: `${getBillingCount(selectedTask).toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '模板内容', value: selectedTask.templateContent, full: true },
...(selectedTask.reviewReason ? [{ label: '审核原因', value: selectedTask.reviewReason, full: true }] : []),
...(selectedTask.rejectReason ? [{ label: '驳回原因', value: selectedTask.rejectReason, full: true }] : []),
]}
/>
</DetailSection>
@@ -353,7 +345,7 @@ export function ClientBatchTasksPage() {
label="发送进度"
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
percent={getProgress(selectedTask)}
status={selectedTask.status}
status={selectedTask.status === 'completed' ? 'completed' : ['failed', 'rejected', 'canceled'].includes(selectedTask.status) ? 'terminated' : 'sending'}
stats={[
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
+6 -5
View File
@@ -33,18 +33,19 @@ export function ClientBillingPage() {
}, [page]);
return (
<section className="page-stack">
<div className="page-heading">
<div><p className="eyebrow"></p><h1></h1></div>
<section className="page-stack client-billing-page">
<div className="sms-send-title">
<span className="sms-send-title__icon"><WalletCards size={22} /></span>
<h1></h1>
</div>
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents)}</MoneyText></strong><small></small></div>
<div><span></span><strong className="client-billing-amount">¥{formatCents(balanceCents)}</strong><small></small></div>
</div>
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents + creditCents)}</MoneyText></strong><small> 0 </small></div>
<div><span></span><strong className="client-billing-amount">¥{formatCents(balanceCents + creditCents)}</strong><small> 0 </small></div>
</div>
</div>
<div className="surface section-stack">
+7 -19
View File
@@ -10,7 +10,8 @@ import {
WalletCards,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Chart, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
import { formatDateTime } from '@/utils/dateTime';
@@ -49,11 +50,7 @@ export function ClientHome() {
const account = dashboard?.accounts[0];
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0);
const todayRefund = moneyUnitsToYuan(todayRefundCents);
const balanceBaseline = Math.max(availableBalance + todaySpend - todayRefund, availableBalance, 1);
const balancePercent = Math.min(100, Math.round((availableBalance / balanceBaseline) * 100));
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
id: String(task.id ?? task.taskNo),
taskNo: String(task.taskNo ?? task.id),
@@ -101,8 +98,8 @@ export function ClientHome() {
<div className="dashboard-grid dashboard-grid--four">
<div className="surface metric-card metric-card--featured">
<span></span>
<strong><MoneyText>¥{formatAmount(availableBalance)}</MoneyText></strong>
<small> <MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></small>
<strong>¥{formatAmount(availableBalance)}</strong>
<small> ¥{formatCents(account?.balanceCents)}</small>
</div>
<div className="surface metric-card">
<span></span>
@@ -111,12 +108,12 @@ export function ClientHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong><MoneyText>¥{formatCents(dashboard?.today.spendCents)}</MoneyText></strong>
<strong>¥{formatCents(dashboard?.today.spendCents)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong><MoneyText>¥{formatCents(todayRefundCents)}</MoneyText></strong>
<strong>¥{formatCents(todayRefundCents)}</strong>
<small>退</small>
</div>
</div>
@@ -175,16 +172,7 @@ export function ClientHome() {
</div>
<div>
<span></span>
<strong>{latestRecharge ? <MoneyText>¥{formatCents(latestRecharge.amountCents)}</MoneyText> : '暂无充值'}</strong>
</div>
</div>
<div>
<div className="progress-heading">
<span></span>
<strong>{balancePercent}%</strong>
</div>
<div className="progress-track">
<span style={{ width: `${balancePercent}%` }} />
<strong>{latestRecharge ? `¥${formatCents(latestRecharge.amountCents)}` : '暂无充值'}</strong>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More