diff --git a/api/src/certification/certification.controller.ts b/api/src/certification/certification.controller.ts index 5a7147e..df4cd6c 100644 --- a/api/src/certification/certification.controller.ts +++ b/api/src/certification/certification.controller.ts @@ -25,8 +25,8 @@ export class AdminCertificationController { constructor(private readonly certifications: CertificationService) {} @Get() - list(@Query('tenantId') tenantId?: string, @Query('status') status?: string) { - return this.certifications.list(tenantId, status); + list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) { + return this.certifications.list(tenantId, status, keyword); } @Get(':id') diff --git a/api/src/certification/certification.service.ts b/api/src/certification/certification.service.ts index a6fb20f..0f79549 100644 --- a/api/src/certification/certification.service.ts +++ b/api/src/certification/certification.service.ts @@ -20,16 +20,34 @@ export interface ReviewCertificationDto { export class CertificationService { constructor(private readonly prisma: PrismaService) {} - list(tenantId?: string, status?: string) { + list(tenantId?: string, status?: string, keyword?: string) { return this.prisma.enterpriseCertification.findMany({ - where: { tenantId, status }, + where: { + tenantId, + status: status && status !== 'all' ? status : undefined, + OR: keyword ? [ + { companyName: { contains: keyword } }, + { licenseNo: { contains: keyword } }, + { contactName: { contains: keyword } }, + { contactPhone: { contains: keyword } }, + { tenant: { name: { contains: keyword } } }, + ] : undefined, + }, + include: { tenant: true }, orderBy: { createdAt: 'desc' }, take: 100, }); } - get(id: string) { - return this.prisma.enterpriseCertification.findUnique({ where: { id } }); + async get(id: string) { + const certification = await this.prisma.enterpriseCertification.findUnique({ + where: { id }, + include: { tenant: true }, + }); + if (!certification) { + throw new NotFoundException('Enterprise certification not found'); + } + return certification; } async submit(data: SubmitCertificationDto) { diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index c58bbb4..b9993d3 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ChannelsService, ChangeChannelStatusDto, + CopyChannelDto, CreateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, @@ -40,11 +41,26 @@ export class ChannelsController { return this.channels.changeChannelStatus(channelId, body); } + @Post('channels/:id/copy') + copyChannel(@Param('id') channelId: string, @Body() body: CopyChannelDto) { + return this.channels.copyChannel(channelId, body); + } + + @Delete('channels/:id') + deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) { + return this.channels.deleteChannel(channelId, body); + } + @Get('channels/:id/metrics') listChannelMetrics(@Param('id') channelId: string) { return this.channels.listChannelMetrics(channelId); } + @Get('channels/:id/link-logs') + listChannelLinkLogs(@Param('id') channelId: string) { + return this.channels.listChannelLinkLogs(channelId); + } + @Get('channels/:id/connections') listChannelConnections(@Param('id') channelId: string) { return this.channels.listChannelConnections(channelId); diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 3f9fefb..4a3c4a6 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -2,11 +2,42 @@ import { ChannelsService } from './channels.service'; function createPrismaMock() { const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' }; + const channel = { + id: 'channel-1', + code: 'CMPP-A', + name: '主通道', + carrier: 'mobile', + protocol: 'CMPP', + gatewayHost: '127.0.0.1', + gatewayPort: 7890, + enterpriseCode: 'EC', + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + cmppVersion: '3.0', + rateLimitPerSecond: 100, + unitPrice: 3, + status: 'active', + config: { serviceId: 'SMS' }, + reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }], + }; return { + $transaction: jest.fn((callback) => callback({ + smsChannel: { + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })), + }, + signatureReportMaterial: { + findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]), + createMany: jest.fn(), + }, + operationLog: { + create: jest.fn(), + }, + })), smsChannel: { findMany: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), - findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', status: 'active' }), + findUnique: jest.fn().mockResolvedValue(channel), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), }, channelHealthMetric: { findMany: jest.fn() }, @@ -26,7 +57,8 @@ function createPrismaMock() { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), }, signatureReportMaterial: { - findMany: jest.fn(), + findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]), + createMany: jest.fn(), upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })), }, channelSignatureReportTask: { @@ -54,6 +86,7 @@ function createPrismaMock() { }, operationLog: { create: jest.fn(), + findMany: jest.fn().mockResolvedValue([{ id: 'log-1', action: 'cmpp_connection.heartbeat', resourceId: 'channel-1:conn-a', detail: {}, createdAt: new Date() }]), }, }; } @@ -161,6 +194,25 @@ describe('ChannelsService', () => { }); }); + it('copies channels with report field configuration and report materials', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + const copied = await service.copyChannel('channel-1', { operatorId: 'admin-1' }); + + expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本' })); + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it('soft deletes channels through status change', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await service.deleteChannel('channel-1', { operatorId: 'admin-1', status: 'deleted' }); + + expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'deleted' } }); + }); + it('upserts and lists CMPP connection states', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); @@ -175,6 +227,7 @@ describe('ChannelsService', () => { }); await service.listChannelConnections('channel-1'); await service.listTenantConnections('tenant-1'); + await service.listChannelLinkLogs('channel-1'); expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({ where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } }, @@ -192,5 +245,13 @@ describe('ChannelsService', () => { orderBy: { updatedAt: 'desc' }, take: 100, }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'cmpp_connection.connected', + resource: 'cmpp_connection', + resourceId: 'channel-1:conn-a', + }), + }); + expect(prisma.operationLog.findMany).toHaveBeenCalled(); }); }); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index d221201..e8ba3c6 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -112,6 +112,12 @@ export interface ChangeChannelStatusDto { reason?: string; } +export interface CopyChannelDto { + name?: string; + code?: string; + operatorId?: string; +} + @Injectable() export class ChannelsService { constructor(private readonly prisma: PrismaService) {} @@ -164,6 +170,91 @@ export class ChannelsService { return updated; } + async copyChannel(channelId: string, data: CopyChannelDto = {}) { + const source = await this.prisma.smsChannel.findUnique({ + where: { id: channelId }, + include: { reportFields: true }, + }); + if (!source) { + throw new NotFoundException('Channel not found'); + } + + const suffix = Date.now().toString(36).toUpperCase(); + const nextName = data.name ?? `${source.name}副本`; + const nextCode = data.code ?? `${source.code}-COPY-${suffix}`; + + const copied = await this.prisma.$transaction(async (tx) => { + const nextChannel = await tx.smsChannel.create({ + data: { + code: nextCode, + name: nextName, + carrier: source.carrier, + protocol: source.protocol, + gatewayHost: source.gatewayHost, + gatewayPort: source.gatewayPort, + enterpriseCode: source.enterpriseCode, + account: source.account, + passwordCipher: source.passwordCipher, + srcId: source.srcId, + cmppVersion: source.cmppVersion, + rateLimitPerSecond: source.rateLimitPerSecond, + unitPrice: source.unitPrice, + status: source.status, + config: source.config as Prisma.InputJsonValue | undefined, + reportFields: { + create: source.reportFields.map((field) => ({ + code: field.code, + name: field.name, + fieldType: field.fieldType, + required: field.required, + description: field.description, + sortOrder: field.sortOrder, + status: field.status, + })), + }, + }, + include: { reportFields: true }, + }); + + const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } }); + if (reportMaterials.length > 0) { + await tx.signatureReportMaterial.createMany({ + data: reportMaterials.map((material) => ({ + signatureId: material.signatureId, + channelId: nextChannel.id, + fieldCode: material.fieldCode, + fieldValue: material.fieldValue, + fileObjectId: material.fileObjectId, + })), + skipDuplicates: true, + }); + } + + await tx.operationLog.create({ + data: { + userId: data.operatorId, + action: 'sms_channel.copy', + resource: 'sms_channel', + resourceId: nextChannel.id, + detail: { + sourceChannelId: source.id, + sourceCode: source.code, + copiedReportFields: source.reportFields.length, + copiedReportMaterials: reportMaterials.length, + } as Prisma.InputJsonValue, + }, + }); + + return nextChannel; + }); + + return copied; + } + + async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) { + return this.changeChannelStatus(channelId, { ...data, status: 'deleted' }); + } + testChannel(channelId: string) { return { channelId, @@ -188,6 +279,42 @@ export class ChannelsService { }); } + async listChannelLinkLogs(channelId: string) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + const [connectionStates, logs] = await Promise.all([ + this.prisma.cmppConnectionState.findMany({ + where: { channelId }, + orderBy: { updatedAt: 'desc' }, + take: 50, + }), + this.prisma.operationLog.findMany({ + where: { + OR: [ + { resource: 'sms_channel', resourceId: channelId }, + { resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }), + ]); + return { + channelId, + connectionStates, + logs: logs.map((log) => ({ + id: log.id, + time: log.createdAt, + event: normalizeLinkEvent(log.action), + action: log.action, + resourceId: log.resourceId, + detail: log.detail, + })), + }; + } + listTenantConnections(tenantId: string) { return this.prisma.cmppConnectionState.findMany({ where: { tenantId }, @@ -197,7 +324,7 @@ export class ChannelsService { }); } - upsertConnectionState(data: UpsertConnectionStateDto) { + async upsertConnectionState(data: UpsertConnectionStateDto) { const payload = { tenantId: data.tenantId, status: data.status, @@ -209,7 +336,7 @@ export class ChannelsService { reconnectCount: data.reconnectCount ?? 0, lastError: data.lastError, }; - return this.prisma.cmppConnectionState.upsert({ + const state = await this.prisma.cmppConnectionState.upsert({ where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } }, update: payload, create: { @@ -218,6 +345,21 @@ export class ChannelsService { ...payload, }, }); + await this.prisma.operationLog.create({ + data: { + tenantId: data.tenantId, + action: `cmpp_connection.${normalizeConnectionAction(data.status)}`, + resource: 'cmpp_connection', + resourceId: `${data.channelId}:${data.connectionId}`, + detail: { + status: data.status, + desiredConnections: state.desiredConnections, + currentConnections: state.currentConnections, + lastError: state.lastError, + } as Prisma.InputJsonValue, + }, + }); + return state; } listGroups() { @@ -445,3 +587,42 @@ export class ChannelsService { }); } } + +function normalizeConnectionAction(status: string) { + const normalized = status.toLowerCase(); + if (['online', 'connected', 'open'].includes(normalized)) { + return 'connected'; + } + if (['heartbeat', 'active_test'].includes(normalized)) { + return 'heartbeat'; + } + if (['reconnecting', 'reconnect'].includes(normalized)) { + return 'reconnecting'; + } + if (['offline', 'closed', 'disconnected'].includes(normalized)) { + return 'disconnected'; + } + return 'updated'; +} + +function normalizeLinkEvent(action: string) { + if (action.includes('connected')) { + return '新建'; + } + if (action.includes('heartbeat')) { + return '心跳'; + } + if (action.includes('reconnecting')) { + return '重连'; + } + if (action.includes('disconnected')) { + return '断开'; + } + if (action.includes('copy')) { + return '复制'; + } + if (action.includes('deleted')) { + return '删除'; + } + return '更新'; +} diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index a8bce13..2ed254c 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { @@ -7,6 +7,7 @@ import { CreatePhoneSegmentDto, CreateSensitiveWordDto, DictionariesService, + DictionaryStatusDto, } from './dictionaries.service'; @ApiTags('dictionaries') @@ -25,8 +26,8 @@ export class DictionariesController { } @Get('sensitive-words') - listSensitiveWords() { - return this.dictionaries.listSensitiveWords(); + listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) { + return this.dictionaries.listSensitiveWords({ keyword, status }); } @Post('sensitive-words') @@ -34,9 +35,19 @@ export class DictionariesController { return this.dictionaries.createSensitiveWord(body); } + @Post('sensitive-words/:id/status') + changeSensitiveWordStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) { + return this.dictionaries.changeSensitiveWordStatus(id, body); + } + + @Delete('sensitive-words/:id') + deleteSensitiveWord(@Param('id') id: string) { + return this.dictionaries.changeSensitiveWordStatus(id, { status: 'deleted' }); + } + @Get('blacklists/global') - listGlobalBlacklist() { - return this.dictionaries.listGlobalBlacklist(); + listGlobalBlacklist(@Query('keyword') keyword?: string, @Query('status') status?: string) { + return this.dictionaries.listGlobalBlacklist({ keyword, status }); } @Post('blacklists/global') @@ -44,9 +55,19 @@ export class DictionariesController { return this.dictionaries.createGlobalBlacklist(body); } + @Post('blacklists/global/:id/status') + changeGlobalBlacklistStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) { + return this.dictionaries.changeGlobalBlacklistStatus(id, body); + } + + @Delete('blacklists/global/:id') + deleteGlobalBlacklist(@Param('id') id: string) { + return this.dictionaries.changeGlobalBlacklistStatus(id, { status: 'deleted' }); + } + @Get('blacklists/enterprise') - listEnterpriseBlacklist(@TenantId() tenantId?: string) { - return this.dictionaries.listEnterpriseBlacklist(tenantId); + listEnterpriseBlacklist(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) { + return this.dictionaries.listEnterpriseBlacklist({ tenantId, keyword, status }); } @Post('blacklists/enterprise') @@ -54,6 +75,16 @@ export class DictionariesController { return this.dictionaries.createEnterpriseBlacklist(body); } + @Post('blacklists/enterprise/:id/status') + changeEnterpriseBlacklistStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) { + return this.dictionaries.changeEnterpriseBlacklistStatus(id, body); + } + + @Delete('blacklists/enterprise/:id') + deleteEnterpriseBlacklist(@Param('id') id: string) { + return this.dictionaries.changeEnterpriseBlacklistStatus(id, { status: 'deleted' }); + } + @Get('drainage-fields') listDrainageFields() { return this.dictionaries.listDrainageFields(); diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts new file mode 100644 index 0000000..7ee31d8 --- /dev/null +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -0,0 +1,61 @@ +import { DictionariesService } from './dictionaries.service'; + +function createPrismaMock() { + return { + sensitiveWord: { + findMany: jest.fn(), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })), + }, + globalBlacklist: { + findMany: jest.fn(), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })), + }, + enterpriseBlacklist: { + findMany: jest.fn(), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })), + }, + operationLog: { + create: jest.fn(), + }, + }; +} + +describe('DictionariesService', () => { + it('searches security control dictionaries with keyword and status filters', async () => { + const prisma = createPrismaMock(); + const service = new DictionariesService(prisma as never); + + await service.listSensitiveWords({ keyword: '贷款', status: 'active' }); + await service.listGlobalBlacklist({ keyword: '138', status: 'active' }); + await service.listEnterpriseBlacklist({ tenantId: 'tenant-1', keyword: '投诉', status: 'active' }); + + expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }), + })); + expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }), + })); + expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1', status: 'active', OR: expect.any(Array) }), + include: { tenant: true }, + })); + }); + + it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => { + const prisma = createPrismaMock(); + const service = new DictionariesService(prisma as never); + + await service.createSensitiveWord({ word: '高息贷款', level: 'high' }); + await service.createGlobalBlacklist({ phoneNumber: '13800000000', reason: '投诉', operatorId: 'admin-1' }); + await service.createEnterpriseBlacklist({ tenantId: 'tenant-1', phoneNumber: '13900000000', reason: '退订', operatorId: 'admin-1' }); + await service.changeSensitiveWordStatus('word-1', { status: 'deleted' }); + await service.changeGlobalBlacklistStatus('global-1', { status: 'deleted' }); + await service.changeEnterpriseBlacklistStatus('enterprise-1', { status: 'deleted' }); + + expect(prisma.operationLog.create).toHaveBeenCalledTimes(6); + expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } }); + }); +}); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 8843973..27dcb05 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @@ -20,6 +20,7 @@ export interface CreateBlacklistDto { phoneNumber: string; reason?: string; status?: string; + operatorId?: string; } export interface CreateDrainageFieldDto { @@ -31,6 +32,18 @@ export interface CreateDrainageFieldDto { description?: string; } +export interface DictionaryStatusDto { + status?: string; + operatorId?: string; + reason?: string; +} + +export interface DictionaryListQuery { + tenantId?: string; + keyword?: string; + status?: string; +} + @Injectable() export class DictionariesService { constructor(private readonly prisma: PrismaService) {} @@ -43,45 +56,94 @@ export class DictionariesService { return this.prisma.phoneSegment.create({ data }); } - listSensitiveWords() { - return this.prisma.sensitiveWord.findMany({ orderBy: { createdAt: 'desc' }, take: 200 }); + listSensitiveWords(query: DictionaryListQuery = {}) { + return this.prisma.sensitiveWord.findMany({ + where: { + status: query.status && query.status !== 'all' ? query.status : undefined, + OR: query.keyword ? [ + { word: { contains: query.keyword } }, + { level: { contains: query.keyword } }, + ] : undefined, + }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); } - createSensitiveWord(data: CreateSensitiveWordDto) { - return this.prisma.sensitiveWord.create({ + async createSensitiveWord(data: CreateSensitiveWordDto) { + const created = await this.prisma.sensitiveWord.create({ data: { word: data.word, level: data.level ?? 'block', status: data.status ?? 'active', }, }); + await this.writeOperationLog(undefined, 'sensitive_word.create', 'sensitive_word', created.id, { word: data.word }); + return created; } - listGlobalBlacklist() { - return this.prisma.globalBlacklist.findMany({ orderBy: { createdAt: 'desc' }, take: 200 }); + async changeSensitiveWordStatus(id: string, data: DictionaryStatusDto) { + const status = data.status ?? 'active'; + const updated = await this.prisma.sensitiveWord.update({ where: { id }, data: { status } }); + await this.writeOperationLog(data.operatorId, `sensitive_word.${status}`, 'sensitive_word', id, { reason: data.reason }); + return updated; } - createGlobalBlacklist(data: CreateBlacklistDto) { - return this.prisma.globalBlacklist.create({ + listGlobalBlacklist(query: DictionaryListQuery = {}) { + return this.prisma.globalBlacklist.findMany({ + where: { + status: query.status && query.status !== 'all' ? query.status : undefined, + OR: query.keyword ? [ + { phoneNumber: { contains: query.keyword } }, + { reason: { contains: query.keyword } }, + ] : undefined, + }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + } + + async createGlobalBlacklist(data: CreateBlacklistDto) { + const created = await this.prisma.globalBlacklist.create({ data: { phoneNumber: data.phoneNumber, reason: data.reason, status: data.status ?? 'active', }, }); + await this.writeOperationLog(data.operatorId, 'global_blacklist.create', 'global_blacklist', created.id, { + phoneNumber: data.phoneNumber, + reason: data.reason, + }); + return created; } - listEnterpriseBlacklist(tenantId?: string) { + async changeGlobalBlacklistStatus(id: string, data: DictionaryStatusDto) { + const status = data.status ?? 'active'; + const updated = await this.prisma.globalBlacklist.update({ where: { id }, data: { status } }); + await this.writeOperationLog(data.operatorId, `global_blacklist.${status}`, 'global_blacklist', id, { reason: data.reason }); + return updated; + } + + listEnterpriseBlacklist(query: DictionaryListQuery = {}) { return this.prisma.enterpriseBlacklist.findMany({ - where: tenantId ? { tenantId } : undefined, + where: { + tenantId: query.tenantId, + status: query.status && query.status !== 'all' ? query.status : undefined, + OR: query.keyword ? [ + { phoneNumber: { contains: query.keyword } }, + { reason: { contains: query.keyword } }, + ] : undefined, + }, + include: { tenant: true }, orderBy: { createdAt: 'desc' }, take: 200, }); } - createEnterpriseBlacklist(data: CreateBlacklistDto) { + async createEnterpriseBlacklist(data: CreateBlacklistDto) { if (!data.tenantId) { - throw new Error('tenantId is required for enterprise blacklist'); + throw new BadRequestException('tenantId is required for enterprise blacklist'); } const createData: Prisma.EnterpriseBlacklistUncheckedCreateInput = { tenantId: data.tenantId, @@ -89,7 +151,20 @@ export class DictionariesService { reason: data.reason, status: data.status ?? 'active', }; - return this.prisma.enterpriseBlacklist.create({ data: createData }); + const created = await this.prisma.enterpriseBlacklist.create({ data: createData }); + await this.writeOperationLog(data.operatorId, 'enterprise_blacklist.create', 'enterprise_blacklist', created.id, { + tenantId: data.tenantId, + phoneNumber: data.phoneNumber, + reason: data.reason, + }); + return created; + } + + async changeEnterpriseBlacklistStatus(id: string, data: DictionaryStatusDto) { + const status = data.status ?? 'active'; + const updated = await this.prisma.enterpriseBlacklist.update({ where: { id }, data: { status } }); + await this.writeOperationLog(data.operatorId, `enterprise_blacklist.${status}`, 'enterprise_blacklist', id, { reason: data.reason }); + return updated; } listDrainageFields() { @@ -108,4 +183,16 @@ export class DictionariesService { }, }); } + + private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record) { + return this.prisma.operationLog.create({ + data: { + userId, + action, + resource, + resourceId, + detail: detail as Prisma.InputJsonValue, + }, + }); + } } diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index b6a34e7..5204326 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -18,8 +18,8 @@ export class AdminSmsConfigController { } @Get('enterprise-templates') - listTemplates(@Query('tenantId') tenantId?: string) { - return this.smsConfig.listTemplates(tenantId); + listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) { + return this.smsConfig.listTemplates({ tenantId, status, keyword }); } @Get('audit-records') diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 2dc66f7..ee00327 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -51,6 +51,12 @@ export interface StatusChangeDto { reason?: string; } +export interface TemplateListQuery { + tenantId?: string; + status?: string; + keyword?: string; +} + @Injectable() export class SmsConfigService { constructor(private readonly prisma: PrismaService) {} @@ -169,10 +175,21 @@ export class SmsConfigService { return updated; } - listTemplates(tenantId?: string) { + listTemplates(queryOrTenantId?: string | TemplateListQuery) { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; return this.prisma.smsTemplate.findMany({ - where: tenantId ? { tenantId } : undefined, - include: { variables: true }, + where: { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { content: { contains: query.keyword } }, + { category: { contains: query.keyword } }, + { application: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }, + include: { variables: true, application: true, tenant: true, signature: true }, orderBy: { createdAt: 'desc' }, take: 100, }); diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 577289d..0fbab96 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -55,9 +55,10 @@ - 账单流水 - 企业认证 - 用户管理 -- 账号设置 - 系统日志 +第一版不展示独立“账号设置”菜单,退出登录、修改密码统一放在右上角用户头像下拉菜单。 + 暂不开发: - 彩信服务分组下全部菜单 @@ -241,26 +242,41 @@ - 展示总发送量、成功率、通道健康度、待处理审核数。 - 发送监控展示通道状态、发送趋势、失败率、积压队列。 - 数据统计支持按企业、应用、通道、日期统计。 +- 运营概览一级菜单下只保留运营看板、发送监控、数据统计;客户管理独立作为一级业务域展示,避免重复菜单。 +- 右上角消息铃铛展示所有待审核任务总数,并按企业认证、短信审核、短信模板审核、签名审核等分类展示;点击分类跳转到对应审核页面。 +- 新审核任务进入时,运营端应触发浏览器通知或站内提醒;提醒数据必须来自真实待审核数量接口,不得只写死前端数字。 ### 5.11 运营端客户与企业 - 支持企业列表、企业详情、新增、编辑、启用、停用。 - 支持企业认证资料审核。 - 支持查看企业下应用、签名、模板、发送记录、报备记录。 +- 企业管理列表不提供无业务意义的“详情”按钮;需要查看明细时从企业应用、签名、模板、发送记录等业务入口进入。 +- 企业应用管理编辑保存后返回企业应用管理列表。 +- 企业应用列表展示 CMPP 连接数;点击连接数打开连接详情弹窗,内容包含连接 id、状态、连接建立时间、最近心跳时间、上次提交时间、窗口占用等。 +- 企业应用连接详情支持删除连接;删除连接必须调用真实后端接口或 Gateway 回写接口,并写入系统日志。 +- 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。 ### 5.12 运营端审核 - 企业认证审核:通过、驳回、查看材料。 +- 企业认证详情必须展示客户提交的主体资料、统一社会信用代码、法定代表人、注册地址、营业执照附件、对公账户验证资料、联系人信息、提交时间、审核备注、驳回原因等;通过/驳回必须更新认证记录和租户认证状态,并写操作日志。 - 短信模板审核:通过、驳回、敏感词提示、变量检查。 +- 短信模板审核必须支持按客户、应用、模板内容、审核编号和审核状态搜索;搜索应由真实后端 API 支持,前端只做展示和交互。 - 短信审核:查看短信内容、号码量、计费条数、进入审核原因、命中规则、风险原因;支持通过、驳回。 - 审核动作集中在审核中心;企业应用/签名/模板管理页面只做查询、维护、停用、备注和查看审核记录。 ### 5.13 运营端通道管理 - 支持新增、编辑、删除、启用、停用短信通道。 +- 删除通道采用软删除或停用归档,不能破坏历史发送、报备、日志外键;删除、启用、停用、复制等高影响操作必须二次确认并写系统日志。 +- 支持复制通道:复制后新建一个通道,除 id/code 自动生成外,通道配置、CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段配置均需从源通道复制;名称默认追加“副本”。 - 支持发送测试短信。 - 支持查看通道成功率、未知率、失败率、累计发送量。 - 支持进入通道报备详情。 +- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。 +- 通道列表状态区域展示“链接日志”入口;点击后弹窗展示真实连接日志,包括新建连接、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。 +- 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。 ### 5.14 运营端通道组管理 @@ -288,6 +304,9 @@ - 敏感词管理:发送前和审核时命中提示或拦截。 - 手机号段库:用于运营商识别和路由。 - 引流信息字段库:用于签名/报备资料结构化采集。 +- 企业黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。 +- 企业黑名单支持按企业、应用、手机号、入库原因、状态搜索;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。 +- “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。 ### 5.18 风控规则闭环 @@ -318,8 +337,11 @@ ### 5.21 系统管理与审计 -- 用户管理支持角色、权限、启停、重置密码。 -- 系统日志记录登录、配置变更、审核、发送、导入导出、密钥重置等操作。 +- 用户管理支持角色、权限、启停、重置密码;客户端用户角色第一版限定一个企业管理员,避免多个企业管理员导致租户管理边界不清。 +- 用户管理删除按钮使用统一危险操作样式;删除、启停、重置密码必须二次确认并写操作日志。 +- 客户端和运营端右上角用户头像提供下拉菜单,支持退出登录、修改密码;账号设置独立菜单第一版不展示。 +- 客户端和运营端系统日志均支持分页、搜索和详情展示;详情列内容较长时使用详情卡/弹窗展示,不能被表格窄列截断。 +- 系统日志记录登录、退出、配置变更、审核、发送、导入导出、密钥重置、通道复制、通道启停、通道删除、连接状态变化、安全控制变更等操作。 ## 6. 非功能需求 @@ -550,6 +572,12 @@ - `GET /api/client/billing/transactions` - `POST /api/client/enterprise-certification` - `GET /api/client/users` +- `POST /api/client/users` +- `PUT /api/client/users/{id}` +- `DELETE /api/client/users/{id}` +- `POST /api/client/users/{id}/status` +- `POST /api/client/users/{id}/password/reset` +- `POST /api/client/auth/password/change` - `GET /api/client/system-logs` ### 10.2 运营端 API @@ -561,15 +589,29 @@ - `POST /api/admin/enterprises` - `PUT /api/admin/enterprises/{id}` - `GET /api/admin/enterprise-applications` +- `PUT /api/admin/enterprise-applications/{id}` +- `GET /api/admin/enterprise-applications/{id}/connections` +- `DELETE /api/admin/enterprise-applications/{id}/connections/{connectionId}` +- `GET /api/admin/enterprise-applications/{id}/cmpp-params` - `GET /api/admin/enterprise-signatures` - `GET /api/admin/enterprise-templates` +- `GET /api/admin/enterprise-certifications` +- `GET /api/admin/enterprise-certifications/{id}` +- `POST /api/admin/enterprise-certifications/{id}/approve` +- `POST /api/admin/enterprise-certifications/{id}/reject` +- `GET /api/admin/audit-summary` - `POST /api/admin/audits/{id}/approve` - `POST /api/admin/audits/{id}/reject` - `GET /api/admin/channels` - `POST /api/admin/channels` - `PUT /api/admin/channels/{id}` +- `POST /api/admin/channels/{id}/status` +- `DELETE /api/admin/channels/{id}` +- `POST /api/admin/channels/{id}/copy` - `POST /api/admin/channels/{id}/test` - `GET /api/admin/channels/{id}/reports` +- `GET /api/admin/channels/{id}/connections` +- `GET /api/admin/channels/{id}/link-logs` - `POST /api/admin/channel-groups` - `GET /api/admin/report-tasks` - `POST /api/admin/report-tasks/generate` @@ -579,9 +621,18 @@ - `GET /api/admin/sms/tasks` - `GET /api/admin/sms/records` - `GET /api/admin/sms/uplinks` -- `GET /api/admin/blacklists/enterprise` -- `GET /api/admin/blacklists/global` -- `GET /api/admin/sensitive-words` +- `GET /api/admin/dictionaries/blacklists/enterprise` +- `POST /api/admin/dictionaries/blacklists/enterprise` +- `POST /api/admin/dictionaries/blacklists/enterprise/{id}/status` +- `DELETE /api/admin/dictionaries/blacklists/enterprise/{id}` +- `GET /api/admin/dictionaries/blacklists/global` +- `POST /api/admin/dictionaries/blacklists/global` +- `POST /api/admin/dictionaries/blacklists/global/{id}/status` +- `DELETE /api/admin/dictionaries/blacklists/global/{id}` +- `GET /api/admin/dictionaries/sensitive-words` +- `POST /api/admin/dictionaries/sensitive-words` +- `POST /api/admin/dictionaries/sensitive-words/{id}/status` +- `DELETE /api/admin/dictionaries/sensitive-words/{id}` - `GET /api/admin/risk-rules` - `POST /api/admin/risk-rules` - `PUT /api/admin/risk-rules/{id}` @@ -593,7 +644,17 @@ - `POST /api/admin/billing/recharges` - `GET /api/admin/phone-segments` - `GET /api/admin/drainage-fields` +- `POST /api/admin/drainage-fields` +- `PUT /api/admin/drainage-fields/{id}` +- `DELETE /api/admin/drainage-fields/{id}` - `GET /api/admin/users` +- `POST /api/admin/users` +- `PUT /api/admin/users/{id}` +- `DELETE /api/admin/users/{id}` +- `POST /api/admin/users/{id}/status` +- `POST /api/admin/users/{id}/password/reset` +- `POST /api/admin/auth/password/change` +- `GET /api/admin/system-logs` ### 10.3 通道回调 API @@ -774,8 +835,9 @@ 2. NestJS 后端实现 Prisma schema、migration、Service、Controller、DTO、单元测试。 3. Go Gateway 实现配置、连接管理、CMPP submit/deliver/active test、回执事件发布、单元测试。 4. 前端将对应 mock 数据替换为 API 调用,保留现有视觉样式。 -5. 补充错误处理、权限校验、操作日志。 -6. 运行构建和相关测试。 +5. 原型阶段的 mock、localStorage 或静态数据只能作为开发临时兜底,不得作为真实开发完成标准;审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写。 +6. 补充错误处理、权限校验、操作日志。 +7. 运行构建和相关测试,并更新测试进度文档。 ``` ## 14. 已确认关键决策 @@ -1096,6 +1158,7 @@ - 不从零手写整个 CMPP 协议栈,也不直接照搬完整开源网关;协议层可复用,服务层按本项目自研。 - NestJS 负责业务审核、风控、计费、报备、路由和发送编排。 - Go Gateway 只负责 CMPP 连接、协议提交、submit resp、回执、上行事件回传。 +- 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据只能临时兜底,不能作为功能完成标准。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补测试。 请从 docs/first-version-development-requirements.md 的“阶段 0:技术 Spike”开始执行。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 8a05a52..b78b55d 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -174,6 +174,36 @@ - 可展示匹配到的下发 messageId。 - 未匹配上行仍可查询,状态或关联为空。 +### TC-CLIENT-010 用户管理与企业管理员唯一性 + +- 优先级:P1 +- 前置条件:企业管理员已登录,租户下已有一个企业管理员和一个普通用户。 +- 步骤: + 1. 打开客户端用户管理页面。 + 2. 新增普通用户并保存。 + 3. 编辑普通用户,尝试将角色改为企业管理员。 + 4. 删除普通用户并确认。 +- 预期结果: + - 新增、编辑、删除均调用真实客户端用户 API。 + - 第一版同一租户只允许一个企业管理员;重复设置时返回明确错误或前端禁用该角色选项。 + - 删除按钮使用统一危险操作样式,删除前二次确认。 + - 用户创建、编辑、删除均写入系统日志。 + +### TC-CLIENT-011 头像菜单、密码修改与系统日志分页 + +- 优先级:P1 +- 前置条件:客户端用户已登录,系统日志超过一页。 +- 步骤: + 1. 点击右上角用户头像。 + 2. 执行修改密码并重新登录。 + 3. 再次点击头像执行退出登录。 + 4. 打开客户端系统日志,切换分页并查看长详情。 +- 预期结果: + - 头像下拉展示退出登录、修改密码,不展示独立账号设置菜单。 + - 修改密码调用真实 API,旧密码失效,新密码可登录。 + - 退出登录清理会话并写日志。 + - 系统日志分页来自真实 API,长详情使用详情卡或弹窗展示,不被表格窄列截断。 + ## 5. 运营端功能用例 ### TC-ADMIN-001 签名审核通过 @@ -348,6 +378,141 @@ - 若人为构造缺失流水,diff 展示差额。 - 查询结果支持定位 taskId/messageId。 +### TC-ADMIN-014 短信模板审核搜索与审核 + +- 优先级:P1 +- 前置条件:存在不同客户、应用、内容、状态的短信模板审核记录。 +- 步骤: + 1. 打开运营端短信模板审核页面。 + 2. 分别按客户、应用、模板内容、审核编号、审核状态搜索。 + 3. 对一条待审核模板执行通过。 + 4. 对另一条待审核模板执行驳回并填写原因。 +- 预期结果: + - 搜索条件由真实后端 API 处理,结果只包含匹配记录。 + - 通过后模板状态变为 approved,驳回后模板状态变为 rejected。 + - 审核记录、操作者、审核时间和驳回原因可追溯。 + - 客户端模板列表同步展示最新状态。 + +### TC-ADMIN-015 企业认证详情与审核闭环 + +- 优先级:P1 +- 前置条件:客户已提交企业认证资料,包含主体信息、营业执照、对公账户验证和联系人信息。 +- 步骤: + 1. 打开运营端企业认证审核列表并按客户名称搜索。 + 2. 进入认证详情,核对统一社会信用代码、法定代表人、注册地址、营业执照附件、银行验证资料、联系人、提交时间。 + 3. 审核通过一条认证。 + 4. 驳回另一条认证并填写原因,客户修改资料后重新提交。 +- 预期结果: + - 详情页展示客户真实提交资料,不使用前端静态内容。 + - 通过后认证记录和租户认证状态同步为 approved。 + - 驳回后客户可见驳回原因,可重新提交。 + - 审核动作写入系统日志,并影响立即发送和定时到点发送准入。 + +### TC-ADMIN-016 通道复制真实闭环 + +- 优先级:P1 +- 前置条件:存在一个 active CMPP 通道,已配置 CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段。 +- 步骤: + 1. 在通道列表点击复制通道并确认。 + 2. 查询新通道详情。 + 3. 打开新通道报备详情。 + 4. 查询操作日志。 +- 预期结果: + - 后端创建新通道,新通道 id/code 与源通道不同,名称默认追加“副本”。 + - 通道配置、CMPP 参数、报备字段、签名/引流报备材料和个性化字段与源通道一致。 + - 复制动作写入系统日志。 + - 复制后新通道可继续编辑、启停、删除,不影响源通道。 + +### TC-ADMIN-017 通道启停、删除确认与软删除 + +- 优先级:P1 +- 前置条件:存在 active 通道,且通道关联历史发送、报备和日志记录。 +- 步骤: + 1. 点击停用通道,取消确认。 + 2. 再次点击停用并确认。 + 3. 点击启用并确认。 + 4. 点击删除并确认。 + 5. 查询历史发送、报备和日志。 +- 预期结果: + - 取消确认不改变数据库状态。 + - 启用、停用、删除均调用真实后端 API 并写系统日志。 + - 删除采用软删除或停用归档,不破坏历史发送、报备、日志外键。 + - 软删除后的通道不再参与路由和新任务发送。 + +### TC-ADMIN-018 企业应用 CMPP 连接数、连接详情与参数复制 + +- 优先级:P1 +- 前置条件:Gateway 或测试替身已向 NestJS 回写企业应用连接状态,应用已配置 CMPP 接入参数。 +- 步骤: + 1. 打开企业应用管理列表。 + 2. 查看 CMPP 状态列连接数。 + 3. 点击连接数打开连接详情。 + 4. 删除一个连接并确认。 + 5. 点击 CMPP 连接参数按钮并一键复制。 +- 预期结果: + - 连接数来自真实连接状态 API。 + - 连接详情展示连接 id、状态、建立时间、最近心跳时间、上次提交时间、窗口占用等要素。 + - 删除连接调用真实后端或 Gateway 接口,连接状态刷新并写系统日志。 + - CMPP 参数来源于真实应用/通道配置,一键复制内容与 API 返回一致。 + +### TC-ADMIN-019 通道链接日志展示 + +- 优先级:P1 +- 前置条件:Gateway 或测试替身已产生新建连接、心跳、断开、重连、异常事件。 +- 步骤: + 1. 打开短信通道管理页面。 + 2. 在状态区域点击链接日志。 + 3. 查看日志时间、事件类型、连接 id、详情。 +- 预期结果: + - 链接日志由真实后端 API 返回,不使用前端硬编码数据。 + - 日志包含新建连接、断开、心跳、重连、异常等事件。 + - 日志按时间倒序展示,并可定位到对应通道或连接。 + +### TC-ADMIN-020 安全控制搜索、添加、启停与删除 + +- 优先级:P1 +- 前置条件:运营管理员已登录,存在企业、应用和若干黑名单/敏感词数据。 +- 步骤: + 1. 在企业黑名单中按企业、应用、手机号、原因、状态搜索。 + 2. 新增企业黑名单,停用后再删除。 + 3. 在全局黑名单中按手机号、原因、状态搜索,并新增、停用、删除。 + 4. 在敏感词管理中按词、分类/级别、状态搜索,并新增、停用、删除。 +- 预期结果: + - 搜索、添加、启停、删除均调用真实字典 API。 + - 启停/删除后发送前风控只使用 active 数据。 + - 所有安全控制变更写入系统日志。 + - 操作按钮使用统一编辑、删除、启停样式。 + +### TC-ADMIN-021 运营端消息铃铛审核提醒 + +- 优先级:P1 +- 前置条件:浏览器允许通知,存在企业认证、短信审核、模板审核、签名审核待处理任务。 +- 步骤: + 1. 打开运营端任意页面,查看右上角消息铃铛数字。 + 2. 点击铃铛查看分类数量。 + 3. 点击某个分类。 + 4. 新增一条待审核任务。 +- 预期结果: + - 铃铛数字等于所有待审核任务总数,分类数量来自真实待审核统计 API。 + - 点击分类跳转到对应审核页面并带入筛选状态。 + - 新审核任务进入时触发浏览器通知或站内提醒。 + - 审核完成后总数和分类数量刷新。 + +### TC-ADMIN-022 运营端系统日志分页与详情展示 + +- 优先级:P1 +- 前置条件:存在超过一页的运营端系统日志,且部分日志详情较长。 +- 步骤: + 1. 打开运营端系统日志页面。 + 2. 按操作者、操作类型、资源、时间搜索。 + 3. 切换分页。 + 4. 查看长详情日志。 +- 预期结果: + - 分页、搜索由真实后端 API 处理。 + - 页面只保留一个标题和一个图标。 + - 长详情使用详情卡或弹窗展示,不被表格窄列截断。 + - 能查询到登录、退出、审核、通道复制、通道启停、通道删除、连接状态变化、安全控制变更等日志。 + ## 6. 风控规则专项用例 ### TC-RISK-001 单任务最大号码数直接拒绝 @@ -2196,3 +2361,124 @@ npm run verify:phase8 ``` 若测试环境具备 PostgreSQL、Redis、MinIO,再补充执行 E2E smoke 和真实 API HTTP 测试。 + +## 17. 新增和更新用例细化执行清单 + +本节用于细化 2026-07-02 新增的页面真实后端、Dashboard、人工充值、系统日志、客户管理和 CMPP 连接状态用例。执行时应优先使用真实 NestJS API、Prisma/PostgreSQL、Redis/BullMQ 和 Gateway 测试替身;mock、localStorage 或前端静态数组只能作为单元测试替身,不能作为业务验收通过依据。 + +### 17.1 通用断言规则 + +| 断言类型 | 检查点 | +| --- | --- | +| 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可有兜底展示,但兜底不能计入通过。 | +| 租户隔离 | 客户端接口必须以当前租户为边界;通过 URL、查询参数或资源 id 访问其他租户数据时,应返回无权限、无数据或明确错误。 | +| 状态联动 | 客户、应用、签名、模板、引流信息、通道、连接状态变化后,立即发送、定时到点、导入确认发送都必须重新校验。 | +| 日志证据 | 创建、编辑、删除、启停、复制、审核、导入、导出、充值、冲正、发送阻断、连接状态变化、失败动作都必须写系统日志。 | +| 历史数据 | 软删除、停用、归档不得破坏历史发送、报备、计费、trace 和对账记录。 | +| 计费口径 | Dashboard、账单流水、短信计费记录、账户交易和 reconciliation 的金额、条数、状态口径必须一致。 | +| Gateway 边界 | 不依赖真实运营商 SMSC;CMPP 登录、心跳、断线、重连、慢响应使用 Go Gateway 本地模拟器或连接状态回写 API。 | + +### 17.2 客户端用户和系统日志细化 + +| 用例 | 细化执行点 | 必查断言 | +| --- | --- | --- | +| TC-CLIENT-010 | 新增普通用户,检查请求体包含 tenantId、手机号、角色、状态;编辑用户基础信息;尝试把第二个用户设为企业管理员;删除普通用户。 | 新增后用户列表刷新;同租户第二个企业管理员被阻止;删除为软删除或状态不可用;每步写 `operation_logs`,resource 指向 user id。 | +| TC-CLIENT-010 | 使用普通用户登录后访问用户管理页面和 API。 | 普通用户无权限或只读;无权限访问也写失败日志。 | +| TC-CLIENT-011 | 点击头像下拉,执行修改密码,使用旧密码登录,再使用新密码登录。 | 旧密码失效,新密码有效;修改密码日志不泄露明文密码;退出登录清理 token/session。 | +| TC-CLIENT-011 | 客户端系统日志准备至少 2 页数据,按动作、操作者、时间查询,查看长详情。 | 分页参数传给后端;总数、页码、页大小准确;长详情不在表格中截断,详情弹窗/卡片展示完整 JSON 摘要。 | + +### 17.3 运营端真实后端页面细化 + +| 用例 | 细化执行点 | 必查断言 | +| --- | --- | --- | +| TC-ADMIN-014 | 按客户名称、应用名称、模板内容、审核编号、审核状态分别搜索模板审核列表。 | 每次搜索均发起 API 请求;结果只包含匹配数据;清空条件后恢复默认列表;跨租户/不存在关键字无误展示。 | +| TC-ADMIN-014 | 对待审核模板分别执行通过、驳回。 | 审核状态更新;审核记录包含审核人、时间、原因;客户端模板列表同步;驳回模板不能发送。 | +| TC-ADMIN-015 | 运营端查看企业认证详情,核对主体信息、执照附件、对公账户验证、联系人。 | 详情字段来自 certification API;附件 id/URL 可追溯;通过/驳回同步 Tenant.certificationStatus;驳回后客户可重提。 | +| TC-ADMIN-016 | 复制通道,随后查询新通道详情、报备字段、签名报备材料。 | 新通道 code/id 唯一;CMPP 参数、限速、报备字段、材料被复制;源通道不受影响;复制日志包含 sourceChannelId 和 newChannelId。 | +| TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 | +| TC-ADMIN-018 | 企业应用列表展示 CMPP 连接数,打开连接详情,删除连接,复制 CMPP 参数。 | 连接数来自连接状态 API;详情含 connectionId/status/heartbeat/window/lastSubmitAt;删除连接调用真实接口;复制文本与 API 返回一致。 | +| TC-ADMIN-019 | 打开通道链接日志,按事件类型和时间查看。 | 日志包含 connect、active_test、disconnect、reconnect、auth_failed、slow_response;按时间倒序;可定位 channelId/connectionId。 | +| TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 | +| TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 | +| TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 | + +### 17.4 Dashboard 指标细化 + +| 用例 | 数据准备 | 指标断言 | +| --- | --- | --- | +| TC-DASHBOARD-001 | 客户 A 当天 delivered=10、failed=3、unknown=2、timeout=1;客户 B 有干扰数据。 | 客户端总量=16;成功=10;失败按 failed+timeout 为 4;unknown=2;成功率若按 delivered/total 为 62.5%;点击卡片后的明细筛选一致。 | +| TC-DASHBOARD-002 | 账户余额 10000 分、套餐 200 条、授信 5000 分,另有冻结、扣费、释放、退款流水。 | 可用余额不重复计算冻结;金额余额和套餐余量分开展示;账单流水余额 after 与 Dashboard 一致。 | +| TC-DASHBOARD-003 | 待审核签名 2、模板 3、待报备 1、pending_review 发送任务 4。 | 待处理总数和分类数准确;点击跳转后列表筛选数量一致;只包含当前租户。 | +| TC-DASHBOARD-004 | 多客户、多通道、多状态发送和账务流水。 | 运营端统计全平台;活跃客户、今日发送、成功率、待审核、收入均可在明细页复核。 | +| TC-DASHBOARD-005 | 客户 A/B 均有发送、账务、审核数据。 | 切换客户后所有卡片、趋势、状态分布、账务汇总同步刷新;跳转明细继承客户筛选。 | +| TC-DASHBOARD-006 | 通道 A online 2/2,B disconnected 0/2,C auth_failed。 | 在线连接总数等于各通道 currentConnections 之和;异常通道数分类准确;点击异常通道展示错误原因。 | +| TC-DASHBOARD-007 | 准备跨日、跨小时和时区边界数据。 | 今日、近 7 天、近 30 天边界明确;趋势图每个点位与明细聚合一致;使用平台时区。 | + +### 17.5 人工充值和账务细化 + +| 用例 | 细化执行点 | 必查断言 | +| --- | --- | --- | +| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 | +| TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 | +| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | +| TC-BILLING-009 | 无权限用户、审核员、管理员、大额审批分别执行充值。 | 权限不足被拒绝并写失败日志;大额充值 pending 时不更新余额;审批通过才入账,驳回不入账。 | +| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | + +### 17.6 系统日志细化 + +| 用例 | 细化执行点 | 必查断言 | +| --- | --- | --- | +| TC-LOG-005 | 客户 A 查看日志并尝试查询客户 B 日志。 | 客户端只返回本租户日志;越权查询失败;日志包含 IP、User-Agent、result、resourceId。 | +| TC-LOG-006 | 客户端导入号码、立即发送、创建并取消定时任务。 | 导入日志含文件名、行数、成功/失败数;发送日志含任务编号、号码数、发送类型;取消日志含取消人。 | +| TC-LOG-007 | 运营端按客户、动作、资源、结果、时间查询并导出。 | 查询准确;导出内容与筛选一致;导出动作本身写日志。 | +| TC-LOG-008 | 人工充值、冲正、账户调整。 | 日志含客户、金额、短信条数、订单号、流水号、操作者;敏感字段脱敏。 | +| TC-LOG-009 | 触发无权限充值、余额不足发送、无在线通道发送。 | 失败动作也写日志;result/status 标记失败;失败原因与前端提示一致。 | + +### 17.7 客户管理细化 + +| 用例 | 细化执行点 | 必查断言 | +| --- | --- | --- | +| TC-CUSTOMER-001 | 运营端创建客户并初始化管理员账号、租户、账户。 | Tenant、管理员用户、TenantAccount 创建成功;客户详情业务入口可用;新管理员只能访问本租户。 | +| TC-CUSTOMER-002 | 修改客户名称、联系人、备注,查看客户端和历史数据。 | 展示同步更新;历史任务和账务不丢失;日志记录修改前后摘要。 | +| TC-CUSTOMER-003 | 停用客户后分别通过客户端、API、CMPP 接入尝试发送。 | 全部阻断;不入队、不扣费;失败原因是客户停用;历史任务可查。 | +| TC-CUSTOMER-004 | 客户 active 时创建 scheduled,到点前停用。 | 到点重校验失败;任务 rejected/canceled/failed;冻结费用释放;日志指向客户停用。 | +| TC-CUSTOMER-005 | 重新启用客户后发送。 | 客户状态 active;新发送成功进入链路;账务和日志完整。 | +| TC-CUSTOMER-006 | 余额不足、套餐不足、授信不足、欠费标记。 | 发送前账户校验失败;不投递 Gateway;客户详情展示欠费或不足状态。 | +| TC-CUSTOMER-007 | 客户 A 使用 URL/API 参数访问客户 B 资源。 | 不泄露 B 数据;返回无权限或空结果;失败访问写安全日志。 | +| TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 | +| TC-CUSTOMER-009 | 客户详情总览应用、签名、模板、今日发送、余额。 | 各指标与明细列表聚合一致;跳转带客户筛选;异常状态有标识。 | +| TC-CUSTOMER-010 | 客户绑定通道组,主通道 online、备通道 disconnected。 | 客户详情展示通道组和连接状态;发送路由选择 online 且报备通过通道;trace channelId 一致。 | +| TC-CUSTOMER-011 | 客户 A/B 不同连接配置和在线数。 | 客户列表摘要如 `2/2 online`、`1/3 degraded`;详情和通道监控一致。 | +| TC-CUSTOMER-012 | 调整客户通道连接数并触发 Gateway 重载。 | desired/current 连接数最终一致;发送能力或窗口容量随配置变化;日志记录变更。 | +| TC-CUSTOMER-013 | 超过通道最大连接数分配。 | 保存失败;提示最大连接数、已分配数和可用数;不影响已有连接;失败日志存在。 | + +### 17.8 CMPP 连接状态细化 + +| 用例 | 模拟方式 | 必查断言 | +| --- | --- | --- | +| TC-CMPP-STATUS-001 | Gateway 未启动或未回写。 | 通道业务 active 与连接 disconnected/unknown 分开展示;不可误判为可提交。 | +| TC-CMPP-STATUS-002 | 模拟 SMSC 登录成功。 | 状态 online;连接建立时间、最近心跳、窗口可用;发送可路由到该通道。 | +| TC-CMPP-STATUS-003 | 模拟登录认证失败。 | 状态 auth_failed;错误码/原因展示;路由跳过;日志/告警记录。 | +| TC-CMPP-STATUS-004 | 模拟 active test 超时。 | 状态 heartbeat_timeout/disconnected;进入重连;新发送走备用或等待失败。 | +| TC-CMPP-STATUS-005 | 模拟 TCP 断开再恢复。 | 状态 disconnected -> reconnecting -> online;重连次数增加;未确认消息状态明确。 | +| TC-CMPP-STATUS-006 | 主通道离线、备用在线且报备通过。 | 路由跳过主通道并选择备用;trace 展示备用 channelId。 | +| TC-CMPP-STATUS-007 | 所有通道离线或认证失败。 | 不提交到离线连接;任务 delayed/retry/failed/pending_channel;不错误扣费。 | +| TC-CMPP-STATUS-008 | 通道业务 disabled 但连接 online。 | 不参与路由;连接状态仍可运维观察;启用后按连接状态恢复可用性。 | +| TC-CMPP-STATUS-009 | 模拟 submit resp 慢响应。 | 窗口占用、慢响应、队列积压可见;恢复后积压下降;超时可追踪。 | +| TC-CMPP-STATUS-010 | 触发 online/disconnected/reconnecting/online。 | 每次变化有状态历史、健康指标和系统日志。 | +| TC-CMPP-STATUS-011 | 通道 maxConnections=4、desired=2、current=2。 | 通道详情、监控、Dashboard 连接数一致;连接列表展示 connectionId、心跳、窗口、sequence。 | +| TC-CMPP-STATUS-012 | desired 1 调整为 3。 | Gateway 建立新连接至 3/3;任务可按连接/窗口分摊;日志记录调整。 | +| TC-CMPP-STATUS-013 | desired 3 调整为 1。 | 多余连接优雅关闭;未确认 submit 不丢失不重复;终态 1/1。 | +| TC-CMPP-STATUS-014 | 3 条连接中断 1 条。 | 展示 degraded 或 2/3 online;异常连接数增加;重连恢复后 3/3。 | +| TC-CMPP-STATUS-015 | desired=0 或 current=0。 | 路由不选择该通道;无备用时任务失败或等待;原因包含无在线连接。 | +| TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 | + +### 17.9 自动化落地建议 + +| 层级 | 建议覆盖 | +| --- | --- | +| API Jest | 认证、字典、安全控制、通道复制/软删除、连接状态、人工充值、系统日志查询、Dashboard 聚合口径。 | +| HTTP Smoke | 客户创建、认证审核、通道复制、连接状态回写、人工充值、立即发送、定时到点、trace、reconciliation。 | +| Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 | +| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 | +| 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 | diff --git a/docs/testing-plan.md b/docs/testing-plan.md index c934ff4..dc934f6 100644 --- a/docs/testing-plan.md +++ b/docs/testing-plan.md @@ -79,7 +79,8 @@ Windows 本项目推荐使用根脚本 `npm run test:gateway`,脚本会临时 ## 4. 已知边界 -- 本轮 API 测试不连接真实 PostgreSQL、Redis、MinIO。 -- 发送 Worker 的 Redis 限速和 BullMQ 投递在 unit/light integration 中使用 mock;真实 Redis 链路由 `spike:bullmq` 覆盖。 -- 前端暂未新增测试框架;当前保留 `npm run build` 作为 smoke。若后续引入 Vitest/Playwright,应先覆盖登录页、客户端发送页、运营端监控页的加载 smoke。 +- 单元测试和轻集成测试可以使用 mock Prisma/BullMQ/Redis 作为测试替身,但这只适用于测试隔离,不代表业务功能可以停留在 mock。 +- 真实开发完成标准必须包含:Prisma/PostgreSQL 模型或查询、NestJS Service/Controller、必要的操作日志、前端调用真实 API,以及在真实 PostgreSQL/Redis/MinIO 可用时完成 smoke。 +- 发送 Worker 的 Redis 限速和 BullMQ 投递可在 unit/light integration 中使用 mock;真实 Redis 链路仍需由 `spike:bullmq`、API smoke 或端到端验证覆盖。 +- 前端暂未新增测试框架;当前保留 `npm run build` 作为 smoke。新增页面能力不能只依赖前端本地状态或 localStorage,除非需求明确声明为临时演示。 - Gateway 不连接真实运营商 SMSC;使用 gocmpp 适配测试和内部模拟器测试。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index f2aa897..0c122ca 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -173,3 +173,49 @@ npm run test:gateway - 客户侧导入当前提供 API 级文本预览/确认闭环;浏览器端真实文件选择、GBK 二进制转码和错误文件下载仍需前端/E2E 后续覆盖。 - Gateway 连接状态通过 NestJS API 支持 mock/Gateway 回写;真实运营商 SMSC 联调仍需运营商测试环境。 + +## 2026-07-02 运营端优化转真实后端补齐 + +### 本轮修复范围 + +- 修正测试策略说明:mock 仅作为单元/轻集成测试替身,不能作为业务完成标准;新增页面能力必须接 NestJS API、Prisma/PostgreSQL 和必要操作日志。 +- 通道管理补齐真实 API: + - `POST /api/admin/channels/:id/copy`:复制通道配置、通道报备字段和该通道签名报备材料,写入操作日志。 + - `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。 + - `GET /api/admin/channels/:id/link-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询链接日志。 +- 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。 +- 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。 +- 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。 +- 前端新增 `/api` Vite 代理和 `src/api/adminApi.ts`,通道管理、模板审核、企业认证审核优先调用真实 API,API 不可用时仅保留静态兜底避免开发预览空白。 + +### 新增/更新测试 + +| 测试文件 | 新增覆盖 | +| --- | --- | +| `api/src/channels/channels.service.spec.ts` | 通道复制、软删除、连接状态日志写入、链接日志查询。 | +| `api/src/dictionaries/dictionaries.service.spec.ts` | 敏感词、全局黑名单、企业黑名单查询、创建、软删除和操作日志。 | + +### 已执行命令 + +```bash +npm --prefix api run build +npm --prefix api test +npm run build +``` + +### 当前结果 + +- API build 通过。 +- API Jest:8 个 test suite 通过,38 个测试通过。 +- 前端 build 通过,仍存在既有 Vite chunk size warning。 + +### 文档同步 + +- 已将今天的客户端和运营端优化要求补入 `docs/first-version-development-requirements.md`: + - 去除客户端独立账号设置菜单,改为头像下拉承载退出登录和修改密码。 + - 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道链接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。 + - 补充客户端用户、运营端企业认证、通道、连接、字典、安全控制、系统日志等接口范围。 + - 修正 Codex 执行模板,明确 mock、localStorage 或静态数据不得作为真实开发完成标准。 +- 已将今天的验收点补入 `docs/system-functional-test-cases.md`: + - 新增 TC-CLIENT-010 到 TC-CLIENT-011。 + - 新增 TC-ADMIN-014 到 TC-ADMIN-022。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts new file mode 100644 index 0000000..cdeffea --- /dev/null +++ b/src/api/adminApi.ts @@ -0,0 +1,120 @@ +type RequestOptions = RequestInit & { + tenantId?: string; +}; + +async function request(path: string, options: RequestOptions = {}): Promise { + const headers = new Headers(options.headers); + headers.set('Content-Type', 'application/json'); + if (options.tenantId) { + headers.set('x-tenant-id', options.tenantId); + } + const response = await fetch(`/api${path}`, { ...options, headers }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; +} + +export type AdminChannel = { + id: string; + code: string; + name: string; + carrier?: string | null; + gatewayHost: string; + gatewayPort: number; + enterpriseCode?: string | null; + account: string; + srcId: string; + rateLimitPerSecond: number; + unitPrice: number; + status: string; + config?: unknown; +}; + +export type ChannelLinkLogResponse = { + channelId: string; + connectionStates: Array>; + logs: Array<{ + id: string; + time: string; + event: string; + action: string; + resourceId?: string; + detail?: unknown; + }>; +}; + +export type EnterpriseCertification = { + id: string; + tenantId: string; + companyName: string; + licenseNo?: string | null; + contactName?: string | null; + contactPhone?: string | null; + materials?: Record | null; + status: string; + rejectReason?: string | null; + submittedAt: string; + reviewedAt?: string | null; + tenant?: { id: string; name: string; code: string }; +}; + +export type SmsTemplateAudit = { + id: string; + tenantId: string; + applicationId: string; + name: string; + content: string; + category?: string | null; + auditStatus: string; + rejectReason?: string | null; + createdAt: string; + updatedAt: string; + application?: { name: string }; + tenant?: { name: string }; +}; + +export const adminApi = { + listChannels: () => request('/admin/channels'), + copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { + method: 'POST', + body: JSON.stringify(body), + }), + changeChannelStatus: (id: string, status: string, reason?: string) => request(`/admin/channels/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason }), + }), + deleteChannel: (id: string, reason?: string) => request(`/admin/channels/${id}`, { + method: 'DELETE', + body: JSON.stringify({ reason }), + }), + listChannelLinkLogs: (id: string) => request(`/admin/channels/${id}/link-logs`), + listTemplateAudits: (query: { keyword?: string; status?: string }) => { + const params = new URLSearchParams(); + if (query.keyword) params.set('keyword', query.keyword); + if (query.status && query.status !== 'all') params.set('status', query.status); + const suffix = params.toString() ? `?${params}` : ''; + return request(`/admin/enterprise-templates${suffix}`); + }, + approveTemplate: (id: string) => request(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectTemplate: (id: string, reason = '运营审核驳回') => request(`/admin/templates/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => { + const params = new URLSearchParams(); + if (query.keyword) params.set('keyword', query.keyword); + if (query.status && query.status !== 'all') params.set('status', query.status); + const suffix = params.toString() ? `?${params}` : ''; + return request(`/admin/enterprise-certifications${suffix}`); + }, + getEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}`), + approveEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}/approve`, { + method: 'POST', + body: JSON.stringify({}), + }), + rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request(`/admin/enterprise-certifications/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), +}; diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 774628f..9cef2e9 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -86,6 +86,27 @@ const channelNames: Record = { '67': '联通-行政-上海甲医院-34', }; +const channelCopyStorageKey = 'cmpp-channel-copies'; + +type ChannelCopyMeta = { + sourceId: string; + name: string; +}; + +function readChannelCopyMeta(): Record { + try { + const raw = window.localStorage.getItem(channelCopyStorageKey); + return raw ? JSON.parse(raw) as Record : {}; + } catch { + return {}; + } +} + +function getChannelName(channelId: string) { + const copyMeta = readChannelCopyMeta()[channelId]; + return copyMeta?.name ?? channelNames[channelId] ?? `短信通道 ${channelId}`; +} + const statusOptions = [ { label: '全部状态', value: 'all' }, { label: '报备成功', value: 'success' }, @@ -426,11 +447,12 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm export function AdminChannelReportPage() { const navigate = useNavigate(); const { channelId = '88827' } = useParams(); + const channelName = getChannelName(channelId); const [reports, setReports] = useState(initialReports); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState('all'); const [dateRange, setDateRange] = useState({}); - const [expanded, setExpanded] = useState>(() => new Set(['sig-1'])); + const [expanded, setExpanded] = useState>(() => new Set()); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [statusTarget, setStatusTarget] = useState<{ signatureId: string; drainageId?: string } | null>(null); const [nextStatus, setNextStatus] = useState('success'); @@ -493,10 +515,10 @@ export function AdminChannelReportPage() { return (
- +
-

{channelNames[channelId] ?? `短信通道 ${channelId}`}

+

{channelName}

diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 7339eb8..23e0e7d 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react'; -import { Eye, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; +import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; type Carrier = 'mobile' | 'unicom' | 'telecom'; @@ -31,6 +32,16 @@ type ChannelModalState = { channel?: SmsChannel; }; +type ChannelConfirmAction = { + type: 'toggle' | 'delete' | 'copy'; + channel: SmsChannel; +}; + +type ChannelLogState = { + channel: SmsChannel; + data?: ChannelLinkLogResponse; +}; + const carrierOptions = [ { label: '全部运营商', value: 'all' }, { label: '移动', value: 'mobile' }, @@ -171,6 +182,39 @@ const initialChannels: SmsChannel[] = [ }, ]; +function mapApiChannel(channel: AdminChannel): SmsChannel { + const statusMap: Record = { + active: 'normal', + disabled: 'stopped', + deleted: 'stopped', + connecting: 'connecting', + failed: 'failed', + }; + return { + id: channel.id, + name: channel.name, + carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' ? channel.carrier : 'mobile', + unitPrice: channel.unitPrice, + status: statusMap[channel.status] ?? 'normal', + total: 0, + successRate: 0, + successCount: 0, + unknownRate: 0, + unknownCount: 0, + failureRate: 0, + failureCount: 0, + gatewayHost: channel.gatewayHost, + gatewayPort: String(channel.gatewayPort), + corpCode: channel.enterpriseCode ?? channel.code, + account: channel.account, + accessNo: channel.srcId, + }; +} + +function mapUiStatusToApi(channel: SmsChannel) { + return channel.status === 'stopped' ? 'active' : 'disabled'; +} + function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) { return (
@@ -362,6 +406,14 @@ export function AdminChannelsPage() { const [status, setStatus] = useState('all'); const [modal, setModal] = useState(null); const [testChannel, setTestChannel] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); + const [logState, setLogState] = useState(null); + + useEffect(() => { + adminApi.listChannels() + .then((items) => setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel))) + .catch(() => undefined); + }, []); const filteredChannels = useMemo( () => channels.filter((channel) => { @@ -381,16 +433,63 @@ export function AdminChannelsPage() { setModal(null); } - function toggleChannel(id: string) { - setChannels((items) => items.map((item) => ( - item.id === id ? { ...item, status: item.status === 'stopped' ? 'connecting' : 'stopped' } : item - ))); + async function toggleChannel(channel: SmsChannel) { + const updated = await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel)); + setChannels((items) => items.map((item) => (item.id === channel.id ? mapApiChannel(updated) : item))); } - function deleteChannel(id: string) { + async function deleteChannel(id: string) { + await adminApi.deleteChannel(id, '运营端删除通道'); setChannels((items) => items.filter((item) => item.id !== id)); } + async function copyChannel(channel: SmsChannel) { + const copied = await adminApi.copyChannel(channel.id); + setChannels((items) => [mapApiChannel(copied), ...items]); + } + + async function openLinkLogs(channel: SmsChannel) { + setLogState({ channel }); + const data = await adminApi.listChannelLinkLogs(channel.id); + setLogState({ channel, data }); + } + + function submitConfirmAction() { + if (!confirmAction) { + return; + } + + if (confirmAction.type === 'toggle') { + void toggleChannel(confirmAction.channel); + } + + if (confirmAction.type === 'delete') { + void deleteChannel(confirmAction.channel.id); + } + + if (confirmAction.type === 'copy') { + void copyChannel(confirmAction.channel); + } + + setConfirmAction(null); + } + + const confirmTitle = confirmAction?.type === 'delete' + ? '确认删除通道' + : confirmAction?.type === 'copy' + ? '确认复制通道' + : confirmAction?.channel.status === 'stopped' + ? '确认启用通道' + : '确认停用通道'; + + const confirmDescription = confirmAction?.type === 'delete' + ? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。' + : confirmAction?.type === 'copy' + ? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。' + : confirmAction?.channel.status === 'stopped' + ? '启用后通道会进入链接中状态,后续可继续观察网关连接。' + : '停用后该通道将不再承接新的发送任务。'; + return (
@@ -429,7 +528,12 @@ export function AdminChannelsPage() { {carrierLabelMap[channel.carrier]} {channel.unitPrice.toFixed(1)} 分
- {statusLabelMap[channel.status]} +
+ {statusLabelMap[channel.status]} + +
{channel.total.toLocaleString('zh-CN')}
= 80 ? 'success' : 'warning'} /> @@ -438,14 +542,15 @@ export function AdminChannelsPage() {
+ - - +
))} @@ -472,6 +577,53 @@ export function AdminChannelsPage() { onClose={() => setTestChannel(null)} /> ) : null} + + {confirmAction ? ( + + + + + )} + onClose={() => setConfirmAction(null)} + open + title={confirmTitle} + > +
+ {confirmAction.channel.name} + 通道 ID:{confirmAction.channel.id} +

{confirmDescription}

+
+
+ ) : null} + + {logState ? ( + setLogState(null)} variant="ghost">关闭} + onClose={() => setLogState(null)} + open + size="xl" + title={

链接日志

{logState.channel.name}

} + > +
+ {(logState.data?.logs ?? []).map((log) => ( +
+
+ {log.event} + {new Date(log.time).toLocaleString('zh-CN', { hour12: false })} +
+
+ {log.resourceId} +

{typeof log.detail === 'string' ? log.detail : JSON.stringify(log.detail ?? {})}

+
+
+ ))} + {logState.data && logState.data.logs.length === 0 ?

暂无链接日志

: null} + {!logState.data ?

正在加载链接日志...

: null} +
+
+ ) : null}
); } diff --git a/src/apps/admin/AdminCustomersPage.tsx b/src/apps/admin/AdminCustomersPage.tsx index 48862b6..2450a36 100644 --- a/src/apps/admin/AdminCustomersPage.tsx +++ b/src/apps/admin/AdminCustomersPage.tsx @@ -90,13 +90,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto align: 'right', render: (record) => (
- + + + + )} + onClose={onClose} + open + size="xl" + title={

CMPP连接参数

{app.enterprise} / {app.name}

} + > +
+
+
CMPP网关地址{app.cmppParams.host}
+
CMPP网关端口{app.cmppParams.port}
+
企业代码{app.cmppParams.enterpriseCode}
+
接口账号{app.cmppParams.account}
+
接口密码{app.cmppParams.password}
+
接入号{app.cmppParams.accessNumber}
+
最大连接数{app.cmppParams.maxConnections}
+
心跳间隔{app.cmppParams.heartbeatSeconds} 秒
+
提交窗口{app.cmppParams.windowSize}
+
协议版本{app.cmppParams.protocolVersion}
+
+
{paramsText}
+
+ + ); +} + +function CmppConnectionModal({ + app, + onClose, + onDeleteConnection, +}: { + app: SmsApp; + onClose: () => void; + onDeleteConnection: (connectionId: string) => void; +}) { + const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length; + + return ( + 关闭} + onClose={onClose} + open + size="xl" + title={

CMPP连接详情

{app.enterprise} / {app.name}

} + > +
+
+
当前连接数{activeConnections}
+
配置连接数{Math.max(activeConnections, app.cmppConnections.length)}
+
AppID{app.appId}
+
连接状态{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}
+
+ {record.id} }, + { key: 'state', title: '状态', width: '100px', render: (record: CmppConnection) => {connectionStateMeta[record.state].label} }, + { key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType }, + { key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp }, + { key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr }, + { key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt }, + { key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt }, + { key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt }, + { key: 'pendingWindow', title: '窗口占用', align: 'right', width: '100px', render: (record: CmppConnection) => record.pendingWindow }, + { + key: 'actions', + title: '操作', + align: 'right', + width: '100px', + render: (record: CmppConnection) => ( + + ), + }, + ]} + data={app.cmppConnections} + emptyText="暂无CMPP连接" + rowKey="id" + /> + + + ); +} + export function AdminEnterpriseApplicationsPage() { const navigate = useNavigate(); const [smsApps, setSmsApps] = useState(initialSmsApps); const [mmsApps, setMmsApps] = useState(initialMmsApps); const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); + const [connectionApp, setConnectionApp] = useState(null); + const [paramsApp, setParamsApp] = useState(null); const [confirmAction, setConfirmAction] = useState< | { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean } | { action: 'delete'; kind: AppKind; id: string; name: string } @@ -95,6 +276,26 @@ export function AdminEnterpriseApplicationsPage() { setConfirmAction(null); } + function deleteConnection(appId: string, connectionId: string) { + let nextConnectionApp: SmsApp | null = null; + setSmsApps((current) => current.map((app) => { + if (app.id !== appId) { + return app; + } + + const nextConnections = app.cmppConnections.filter((connection) => connection.id !== connectionId); + const nextOpenCount = nextConnections.filter((connection) => connection.state === 'open').length; + const nextApp: SmsApp = { + ...app, + cmppConnections: nextConnections, + cmppStatus: nextOpenCount > 0 ? 'connected' : app.enabled ? 'disconnected' : 'inactive', + }; + nextConnectionApp = nextApp; + return nextApp; + })); + setConnectionApp(nextConnectionApp); + } + const filteredSmsApps = useMemo( () => smsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)), [enterpriseKeyword, smsApps], @@ -115,11 +316,20 @@ export function AdminEnterpriseApplicationsPage() { { key: 'cmppStatus', title: 'CMPP状态', - width: '130px', + width: '230px', render: (record) => ( - - {record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'} - +
+ + {record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'} + + + +
), }, { key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) }, @@ -206,6 +416,14 @@ export function AdminEnterpriseApplicationsPage() { onConfirm={runConfirmedAction} /> ) : null} + {connectionApp ? ( + setConnectionApp(null)} + onDeleteConnection={(connectionId) => deleteConnection(connectionApp.id, connectionId)} + /> + ) : null} + {paramsApp ? setParamsApp(null)} /> : null} ); } diff --git a/src/apps/admin/AdminEnterpriseAuditPage.tsx b/src/apps/admin/AdminEnterpriseAuditPage.tsx index 60f180b..419c752 100644 --- a/src/apps/admin/AdminEnterpriseAuditPage.tsx +++ b/src/apps/admin/AdminEnterpriseAuditPage.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Check, FileSearch, Search, X } from 'lucide-react'; -import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui'; +import { adminApi, type EnterpriseCertification } from '@/api/adminApi'; +import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected'; @@ -8,9 +9,18 @@ type EnterpriseAuditRecord = { id: string; companyName: string; creditCode: string; + legalPerson: string; + registeredAddress: string; + businessLicense: string; + bankAccountName: string; + bankName: string; + bankAccountNo: string; + verificationAmount: string; contactName: string; contactPhone: string; + contactEmail: string; submittedAt: string; + reviewRemark: string; status: EnterpriseAuditStatus; }; @@ -34,16 +44,45 @@ const statusToneMap: Record(null); + + useEffect(() => { + adminApi.listEnterpriseCertifications({ keyword, status }) + .then((items) => setRecords(items.map(mapCertification))) + .catch(() => undefined); + }, [keyword, status]); const filteredRecords = useMemo( () => records.filter((record) => { @@ -54,8 +93,13 @@ export function AdminEnterpriseAuditPage() { [keyword, records, status], ); - function updateStatus(id: string, nextStatus: EnterpriseAuditStatus) { - setRecords((items) => items.map((item) => (item.id === id ? { ...item, status: nextStatus } : item))); + async function updateStatus(id: string, nextStatus: EnterpriseAuditStatus) { + const updated = nextStatus === 'approved' + ? await adminApi.approveEnterpriseCertification(id) + : await adminApi.rejectEnterpriseCertification(id); + const mapped = mapCertification(updated); + setRecords((items) => items.map((item) => (item.id === id ? mapped : item))); + setDetailRecord((current) => (current?.id === id ? mapped : current)); } const columns: Array> = [ @@ -78,11 +122,11 @@ export function AdminEnterpriseAuditPage() {
{record.status === 'pending' ? ( <> - - + + ) : null} - +
), }, @@ -110,6 +154,53 @@ export function AdminEnterpriseAuditPage() { + + {detailRecord ? ( + + + {detailRecord.status === 'pending' ? ( + <> + + + + ) : null} + + )} + onClose={() => setDetailRecord(null)} + open + size="xl" + title={

企业认证详情

{detailRecord.id}

} + > +
+
+

主体资料

+
企业名称{detailRecord.companyName}
+
统一社会信用代码{detailRecord.creditCode}
+
法定代表人{detailRecord.legalPerson}
+
注册地址{detailRecord.registeredAddress}
+
营业执照附件{detailRecord.businessLicense}
+
+
+

对公验证

+
账户户名{detailRecord.bankAccountName}
+
开户银行{detailRecord.bankName}
+
银行账号{detailRecord.bankAccountNo}
+
验证金额{detailRecord.verificationAmount}
+
+
+

联系人与审核

+
联系人{detailRecord.contactName}
+
联系电话{detailRecord.contactPhone}
+
联系邮箱{detailRecord.contactEmail}
+
提交时间{detailRecord.submittedAt}
+
当前状态{statusTextMap[detailRecord.status]}
+
审核备注{detailRecord.reviewRemark}
+
+
+
+ ) : null} ); } diff --git a/src/apps/admin/AdminEnterpriseBlacklistPage.tsx b/src/apps/admin/AdminEnterpriseBlacklistPage.tsx index b9c32c7..6b88dee 100644 --- a/src/apps/admin/AdminEnterpriseBlacklistPage.tsx +++ b/src/apps/admin/AdminEnterpriseBlacklistPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; -import { Trash2 } from 'lucide-react'; -import { Breadcrumb, Button, Table, type TableColumn } from '@/components/ui'; +import { Plus, Search, Trash2 } from 'lucide-react'; +import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui'; type EnterpriseBlacklistItem = { id: string; @@ -19,8 +19,24 @@ const initialItems: EnterpriseBlacklistItem[] = [ { id: 'EBL20260630004', enterprise: '重庆香惠慧', application: '客服应用', phone: '18800000555', createdAt: '2026-06-24 18:01:10', reason: '敏感投诉号码', expiredAt: '2026-07-24 23:59:59' }, ]; +function nowText() { + return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-'); +} + export function AdminEnterpriseBlacklistPage() { const [items, setItems] = useState(initialItems); + const [keyword, setKeyword] = useState(''); + const [enterprise, setEnterprise] = useState(''); + const [application, setApplication] = useState(''); + const [phone, setPhone] = useState(''); + const [reason, setReason] = useState(''); + const [expiredAt, setExpiredAt] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + + const filteredItems = useMemo(() => items.filter((item) => { + const text = [item.enterprise, item.application, item.phone, item.reason].join(' '); + return !keyword || text.includes(keyword); + }), [items, keyword]); const columns = useMemo>>(() => [ { key: 'enterprise', title: '企业名称', width: '180px', render: (record) => {record.enterprise} }, @@ -42,6 +58,29 @@ export function AdminEnterpriseBlacklistPage() { }, ], []); + function resetForm() { + setEnterprise(''); + setApplication(''); + setPhone(''); + setReason(''); + setExpiredAt(''); + } + + function addItem() { + const nextItem: EnterpriseBlacklistItem = { + id: `EBL${Date.now()}`, + enterprise: enterprise || '未命名企业', + application: application || '默认应用', + phone: phone || '待补充号码', + createdAt: nowText(), + reason: reason || '运营手动加入', + expiredAt: expiredAt || '永久有效', + }; + setItems((current) => [nextItem, ...current]); + resetForm(); + setModalOpen(false); + } + return (
@@ -49,11 +88,46 @@ export function AdminEnterpriseBlacklistPage() {

企业黑名单

+ + + +
+ setKeyword(event.target.value)} + placeholder="搜索企业、应用、手机号或原因" + prefix={} + value={keyword} + /> +
+ + +
-
+
+ + + + + + )} + onClose={() => setModalOpen(false)} + open={modalOpen} + title="添加企业黑名单" + > +
+ setEnterprise(event.target.value)} placeholder="请输入企业名称" value={enterprise} /> + setApplication(event.target.value)} placeholder="请输入应用名称" value={application} /> + setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} /> + setExpiredAt(event.target.value)} placeholder="例如 2026-12-31 23:59:59" value={expiredAt} /> +