diff --git a/api/prisma/migrations/20260710153000_add_user_session_version/migration.sql b/api/prisma/migrations/20260710153000_add_user_session_version/migration.sql new file mode 100644 index 0000000..a72a7f3 --- /dev/null +++ b/api/prisma/migrations/20260710153000_add_user_session_version/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 7a07ee1..af57bc6 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -75,6 +75,7 @@ model User { displayName String passwordHash String status String @default("active") + sessionVersion Int @default(0) failedLoginCount Int @default(0) lockedUntil DateTime? lastLoginAt DateTime? diff --git a/api/src/app.module.ts b/api/src/app.module.ts index c7370c4..0af3398 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -1,7 +1,9 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; +import { SessionValidationMiddleware } from './auth/session-validation.middleware'; +import { RequestContextMiddleware } from './common/request-context.middleware'; import { BillingModule } from './billing/billing.module'; import { ChannelsModule } from './channels/channels.module'; import { CertificationModule } from './certification/certification.module'; @@ -38,5 +40,10 @@ import { UsersModule } from './users/users.module'; OperationsModule, ], controllers: [HealthController], + providers: [RequestContextMiddleware, SessionValidationMiddleware], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(RequestContextMiddleware, SessionValidationMiddleware).forRoutes('*'); + } +} diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 74e26e1..363e9fc 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -1,11 +1,13 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Get, Post, UnauthorizedException } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from './current-session-user.decorator'; import { AuthService, LoginDto } from './auth.service'; +import { UsersService } from '../users/users.service'; @ApiTags('auth') @Controller() export class AuthController { - constructor(private readonly auth: AuthService) {} + constructor(private readonly auth: AuthService, private readonly users: UsersService) {} @Get('admin/auth/captcha') adminCaptcha() { @@ -26,4 +28,12 @@ export class AuthController { clientLogin(@Body() body: LoginDto) { return this.auth.login(body, 'client'); } + + @Post('auth/password') + changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) { + if (!userId) { + throw new UnauthorizedException('登录会话无效,请重新登录'); + } + return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? ''); + } } diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 4512620..4d88c52 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -40,7 +40,7 @@ describe('AuthService', () => { it('allows platform admins to login admin portal', async () => { const users = createUsersMock('platform_admin'); const service = new AuthService(users as never); - await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin' })); + await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', accessToken: 'dev-token:user-1:0' })); expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1'); }); diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index e42e53f..27ce5fb 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -77,7 +77,7 @@ export class AuthService { anonymousFailures.delete(login); return { - accessToken: `dev-token-${user.id}`, + accessToken: `dev-token:${user.id}:${user.sessionVersion ?? 0}`, tokenType: 'Bearer', portal, user: { diff --git a/api/src/auth/current-session-user.decorator.ts b/api/src/auth/current-session-user.decorator.ts new file mode 100644 index 0000000..68c8c65 --- /dev/null +++ b/api/src/auth/current-session-user.decorator.ts @@ -0,0 +1,7 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { SessionRequest } from './session-validation.middleware'; + +export const CurrentSessionUserId = createParamDecorator((_: unknown, context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + return request.sessionUserId; +}); diff --git a/api/src/auth/session-validation.middleware.spec.ts b/api/src/auth/session-validation.middleware.spec.ts new file mode 100644 index 0000000..6c3110c --- /dev/null +++ b/api/src/auth/session-validation.middleware.spec.ts @@ -0,0 +1,27 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware'; + +function request(authorization?: string): SessionRequest { + return { header: jest.fn().mockReturnValue(authorization) }; +} + +describe('SessionValidationMiddleware', () => { + it('accepts the current user session version and exposes the session user id', async () => { + const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } }; + const middleware = new SessionValidationMiddleware(prisma as never); + const currentRequest = request('Bearer dev-token:user-1:3'); + const next = jest.fn(); + + await middleware.use(currentRequest, {} as never, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(currentRequest.sessionUserId).toBe('user-1'); + }); + + it('rejects an old session token after the user session version changes', async () => { + const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } }; + const middleware = new SessionValidationMiddleware(prisma as never); + + await expect(middleware.use(request('Bearer dev-token:user-1:3'), {} as never, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException); + }); +}); diff --git a/api/src/auth/session-validation.middleware.ts b/api/src/auth/session-validation.middleware.ts new file mode 100644 index 0000000..79e281f --- /dev/null +++ b/api/src/auth/session-validation.middleware.ts @@ -0,0 +1,33 @@ +import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export type SessionRequest = { + header(name: string): string | undefined; + sessionUserId?: string; +}; + +@Injectable() +export class SessionValidationMiddleware implements NestMiddleware { + constructor(private readonly prisma: PrismaService) {} + + async use(request: SessionRequest, _: unknown, next: () => void) { + const authorization = request.header('authorization'); + if (!authorization) { + next(); + return; + } + const match = /^Bearer dev-token:([^:]+):(\d+)$/.exec(authorization.trim()); + if (!match) { + throw new UnauthorizedException('登录会话无效,请重新登录'); + } + const user = await this.prisma.user.findUnique({ + where: { id: match[1] }, + select: { id: true, status: true, deletedAt: true, sessionVersion: true }, + }); + if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== Number(match[2])) { + throw new UnauthorizedException('登录会话已失效,请重新登录'); + } + request.sessionUserId = user.id; + next(); + } +} diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index 92a6fb7..dd8cf63 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -143,6 +143,31 @@ describe('BillingService', () => { }); }); + it('returns the historical balance after each manual recharge', async () => { + const prisma = createPrismaMock(); + prisma.rechargeOrder.findMany.mockResolvedValue([ + { id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 }, + { id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 }, + ]); + prisma.accountTransaction.findMany.mockResolvedValue([ + { relatedId: 'order-1', balanceAfter: 3000 }, + { relatedId: 'order-2', balanceAfter: 2700 }, + ]); + const service = new BillingService(prisma as never); + + await expect(service.listManualRechargeRecords()).resolves.toEqual([ + expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }), + expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }), + ]); + expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({ + where: { + relatedType: 'recharge_order', + relatedId: { in: ['order-1', 'order-2'] }, + }, + select: { relatedId: true, balanceAfter: true }, + }); + }); + it('allows negative manual recharge amounts for balance correction', async () => { const prisma = createPrismaMock(); const service = new BillingService(prisma as never); diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 9a24a56..9119558 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -133,8 +133,8 @@ export class BillingService { }); } - listManualRechargeRecords(tenantId?: string) { - return this.prisma.rechargeOrder.findMany({ + async listManualRechargeRecords(tenantId?: string) { + const orders = await this.prisma.rechargeOrder.findMany({ where: { tenantId, payMethod: 'manual_topup', @@ -143,6 +143,24 @@ export class BillingService { orderBy: { createdAt: 'desc' }, take: 100, }); + const orderIds = orders.map((order) => order.id); + if (orderIds.length === 0) { + return orders; + } + + const transactions = await this.prisma.accountTransaction.findMany({ + where: { + relatedType: 'recharge_order', + relatedId: { in: orderIds }, + }, + select: { relatedId: true, balanceAfter: true }, + }); + const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, transaction.balanceAfter])); + + return orders.map((order) => ({ + ...order, + balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null, + })); } async createRechargeOrder(data: CreateRechargeOrderDto) { diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 25e5a4b..73218d2 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -183,6 +183,8 @@ describe('ChannelsService', () => { srcId: '10690000', desiredConnections: 2, windowSize: 32, + rateLimitPerSecond: 750, + config: { extensionDigits: 4 }, }); await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 }); await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' }); @@ -191,10 +193,10 @@ describe('ChannelsService', () => { data: expect.objectContaining({ protocol: 'CMPP', cmppVersion: '2.0', - rateLimitPerSecond: 100, + rateLimitPerSecond: 750, sendRegion: '全国', status: 'active', - config: expect.objectContaining({ desiredConnections: 2, windowSize: 32 }), + config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4 }), }), }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ @@ -263,6 +265,23 @@ describe('ChannelsService', () => { })).rejects.toThrow('cmppVersion must be 2.0 or 3.0'); }); + it('rejects invalid channel rate limits and extension digit counts', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + const channel = { + code: 'CMPP-CONFIG', + name: '配置校验通道', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + }; + + await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000'); + await expect(service.createChannel({ ...channel, config: { extensionDigits: 3 } })).rejects.toThrow('extensionDigits must be one of 0, 2, 4, or 6'); + }); + it('updates CMPP channel configuration without requiring password changes', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); @@ -277,6 +296,8 @@ describe('ChannelsService', () => { srcId: '10690001', desiredConnections: 3, windowSize: 64, + rateLimitPerSecond: 320, + config: { extensionDigits: 2 }, unitPrice: 4, })).resolves.toEqual(expect.objectContaining({ id: 'channel-1', @@ -292,7 +313,8 @@ describe('ChannelsService', () => { gatewayPort: 27890, carrier: 'all', passwordCipher: undefined, - config: expect.objectContaining({ desiredConnections: 3, windowSize: 64 }), + rateLimitPerSecond: 320, + config: expect.objectContaining({ desiredConnections: 3, windowSize: 64, extensionDigits: 2 }), }), }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 80dfa8b..9449942 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -215,7 +215,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); } const cmppVersion = normalizeCmppVersion(data.cmppVersion); - const config = normalizeChannelRuntimeConfig(data.config, data.desiredConnections, data.windowSize); + const config = normalizeChannelRuntimeConfig(undefined, data.config, data.desiredConnections, data.windowSize); + const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond); const channel = await this.prisma.smsChannel.create({ data: { code: data.code, @@ -230,7 +231,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { passwordCipher: data.passwordCipher, srcId: data.srcId, cmppVersion, - rateLimitPerSecond: data.rateLimitPerSecond ?? 100, + rateLimitPerSecond, unitPrice: data.unitPrice ?? 0, status: data.status ?? 'active', config: config as Prisma.InputJsonValue, @@ -253,8 +254,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { } const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined - ? normalizeChannelRuntimeConfig(channel.config, data.desiredConnections, data.windowSize) + ? normalizeChannelRuntimeConfig(channel.config, data.config, data.desiredConnections, data.windowSize) : undefined; + const rateLimitPerSecond = data.rateLimitPerSecond === undefined + ? undefined + : normalizeChannelRateLimit(data.rateLimitPerSecond); const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { @@ -270,7 +274,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { passwordCipher: data.passwordCipher, srcId: data.srcId, cmppVersion, - rateLimitPerSecond: data.rateLimitPerSecond, + rateLimitPerSecond, unitPrice: data.unitPrice, status: data.status, config: config as Prisma.InputJsonValue | undefined, @@ -1287,6 +1291,7 @@ function buildChannelTestSubmitCommand({ cmpp: { serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'), srcId, + extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')), registeredDelivery: 1, msgFmt: 8, }, @@ -1380,15 +1385,44 @@ function getDesiredConnections(config?: Prisma.JsonValue | null) { return 1; } -function normalizeChannelRuntimeConfig(config?: Prisma.JsonValue | Record | null, desiredConnections?: number, windowSize?: number) { - const base = config && typeof config === 'object' && !Array.isArray(config) - ? { ...(config as Record) } +function normalizeChannelRuntimeConfig( + existingConfig?: Prisma.JsonValue | Record | null, + incomingConfig?: Record | null, + desiredConnections?: number, + windowSize?: number, +) { + const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) + ? existingConfig as Record : {}; + const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) + ? incomingConfig + : {}; + const base = { ...existing, ...incoming }; base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections'); base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize'); + base.extensionDigits = normalizeExtensionDigits(base.extensionDigits); return base; } +function normalizeChannelRateLimit(value: unknown) { + const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond'); + if (normalized > 2000) { + throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000'); + } + return normalized; +} + +function normalizeExtensionDigits(value: unknown) { + if (value === undefined || value === null || value === '') { + return 0; + } + const normalized = Number(value); + if (![0, 2, 4, 6].includes(normalized)) { + throw new BadRequestException('extensionDigits must be one of 0, 2, 4, or 6'); + } + return normalized; +} + function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) { if (value === undefined || value === null || value === '') { return fallback; diff --git a/api/src/common/request-context.middleware.ts b/api/src/common/request-context.middleware.ts new file mode 100644 index 0000000..4b1fdfa --- /dev/null +++ b/api/src/common/request-context.middleware.ts @@ -0,0 +1,14 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { requestContext } from './request-context'; + +type RequestLike = { headers: Record; socket?: { remoteAddress?: string } }; + +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + use(request: RequestLike, _response: unknown, next: () => void) { + const forwarded = request.headers['x-forwarded-for']; + const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0]; + const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, ''); + requestContext.run({ ipAddress }, next); + } +} diff --git a/api/src/common/request-context.ts b/api/src/common/request-context.ts new file mode 100644 index 0000000..30b5b71 --- /dev/null +++ b/api/src/common/request-context.ts @@ -0,0 +1,3 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export const requestContext = new AsyncLocalStorage<{ ipAddress?: string }>(); diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index c403f65..6c9b37f 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -30,8 +30,8 @@ export class DictionariesController { } @Get('phone-carrier-rules') - listPhoneCarrierRules() { - return this.dictionaries.listPhoneCarrierRules(); + listPhoneCarrierRules(@Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return this.dictionaries.listPhoneCarrierRules({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined }); } @Post('phone-carrier-rules') diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 626f99e..8e62cf4 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -5,6 +5,10 @@ function createPrismaMock() { phoneSegment: { findMany: jest.fn(), }, + phoneCarrierRule: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, sensitiveWord: { findMany: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })), @@ -80,6 +84,17 @@ describe('DictionariesService', () => { })); }); + it('paginates carrier rules with a real database count', async () => { + const prisma = createPrismaMock(); + prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]); + prisma.phoneCarrierRule.count.mockResolvedValue(26); + const service = new DictionariesService(prisma as never); + + await expect(service.listPhoneCarrierRules({ keyword: '13', page: 2, pageSize: 25 })).resolves.toEqual(expect.objectContaining({ total: 26, page: 2, pageSize: 25 })); + expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 25, take: 25, where: { OR: expect.any(Array) } })); + expect(prisma.phoneCarrierRule.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } }); + }); + it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => { const prisma = createPrismaMock(); const service = new DictionariesService(prisma as never); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 9c3cd6c..3535eca 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -23,6 +23,12 @@ export interface CreatePhoneCarrierRuleDto { remark?: string; } +export interface PageQuery { + keyword?: string; + page?: number; + pageSize?: number; +} + export interface CreateSensitiveWordDto { word: string; level?: string; @@ -98,8 +104,21 @@ export class DictionariesService { return this.prisma.phoneSegment.create({ data }); } - listPhoneCarrierRules() { - return this.prisma.phoneCarrierRule.findMany({ orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], take: 200 }); + async listPhoneCarrierRules(query: PageQuery = {}) { + const page = Math.max(1, Number(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25))); + const where = query.keyword?.trim() ? { + OR: [ + { carrier: { contains: query.keyword.trim() } }, + { pattern: { contains: query.keyword.trim() } }, + { remark: { contains: query.keyword.trim() } }, + ], + } : undefined; + const [items, total] = await Promise.all([ + this.prisma.phoneCarrierRule.findMany({ where, orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }), + this.prisma.phoneCarrierRule.count({ where }), + ]); + return { items, total, page, pageSize }; } createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) { diff --git a/api/src/files/files.service.spec.ts b/api/src/files/files.service.spec.ts index efaf22f..4f784e9 100644 --- a/api/src/files/files.service.spec.ts +++ b/api/src/files/files.service.spec.ts @@ -49,6 +49,38 @@ describe('FilesService', () => { }); }); + it('restores UTF-8 filenames that multipart parsing exposed as Latin-1', async () => { + const prisma = { + fileObject: { + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })), + }, + }; + const objectStorage = { + getBucket: jest.fn().mockReturnValue('cmpp-platform'), + putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }), + presignedPutObject: jest.fn(), + }; + const service = new FilesService(prisma as never, objectStorage as never); + const originalname = Buffer.from('营业执照.png', 'utf8').toString('latin1'); + + await service.upload({ purpose: 'enterprise_photo' }, { + originalname, + mimetype: 'image/png', + size: 12, + buffer: Buffer.from('file-content'), + }); + + expect(prisma.fileObject.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ fileName: '营业执照.png' }), + }); + expect(objectStorage.putObject).toHaveBeenCalledWith( + expect.stringMatching(/营业执照\.png$/), + expect.any(Buffer), + 12, + 'image/png', + ); + }); + it('downloads file content from object storage by FileObject id', async () => { const fileObject = { id: 'file-1', diff --git a/api/src/files/files.service.ts b/api/src/files/files.service.ts index 4cc38c0..482c2de 100644 --- a/api/src/files/files.service.ts +++ b/api/src/files/files.service.ts @@ -66,14 +66,15 @@ export class FilesService { } async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) { - const safeName = file.originalname.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_'); + const fileName = normalizeMultipartFileName(file.originalname); + const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_'); const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`; await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream'); return this.create({ tenantId: data.tenantId, bucket: this.objectStorage.getBucket(), objectKey, - fileName: file.originalname, + fileName, contentType: file.mimetype || 'application/octet-stream', sizeBytes: file.size, purpose: data.purpose, @@ -93,6 +94,15 @@ export class FilesService { } } +function normalizeMultipartFileName(value: string) { + if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) { + return value; + } + + const decoded = Buffer.from(value, 'latin1').toString('utf8'); + return decoded.includes('\uFFFD') ? value : decoded; +} + function serializeFileObject(fileObject: T) { return { ...fileObject, diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 8a4a241..885d1d6 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -34,9 +34,25 @@ export class AdminOperationsController { @Query('taskId') taskId?: string, @Query('messageId') messageId?: string, @Query('phoneNumber') phoneNumber?: string, + @Query('contentKeyword') contentKeyword?: string, + @Query('channelKeyword') channelKeyword?: string, + @Query('queuedAtFrom') queuedAtFrom?: string, + @Query('queuedAtTo') queuedAtTo?: string, @Query('status') status?: string, ) { - return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, messageId, phoneNumber, status }); + return this.operations.listMessages({ + tenantId, + applicationId, + channelId, + taskId, + messageId, + phoneNumber, + contentKeyword, + channelKeyword, + queuedAtFrom, + queuedAtTo, + status, + }); } @Get('message-segment-audits') diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index e374698..1e869c4 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -6,6 +6,9 @@ function createPrismaMock() { findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]), count: jest.fn().mockResolvedValue(3), }, + smsSendTask: { + count: jest.fn().mockResolvedValue(2), + }, smsMessageRecord: { findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]), groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]), @@ -163,7 +166,7 @@ function createPrismaMock() { } describe('OperationsService', () => { - it('filters send-chain messages by tenant, application, channel, task, phone, and status', async () => { + it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); @@ -171,9 +174,13 @@ describe('OperationsService', () => { tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', + channelKeyword: '移动通道', taskId: 'task-1', messageId: 'MSG-1', phoneNumber: '13800000001', + contentKeyword: '验证码', + queuedAtFrom: '2026-07-01', + queuedAtTo: '2026-07-02', status: 'delivered', }); @@ -186,6 +193,12 @@ describe('OperationsService', () => { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered', + content: { contains: '验证码', mode: 'insensitive' }, + channel: { name: { contains: '移动通道', mode: 'insensitive' } }, + queuedAt: { + gte: new Date('2026-07-01T00:00:00+08:00'), + lte: new Date('2026-07-02T23:59:59.999+08:00'), + }, }, include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true }, orderBy: { queuedAt: 'desc' }, @@ -234,7 +247,7 @@ describe('OperationsService', () => { expect.objectContaining({ taskCount: 3, uplinkCount: 1, - pendingAuditCount: 6, + pendingAuditCount: 5, gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }], downstreamDeliverySummary: expect.objectContaining({ pending: 3, diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index f6a55e9..1a51f9b 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -6,10 +6,14 @@ export interface MessageQuery { tenantId?: string; applicationId?: string; channelId?: string; + channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; + contentKeyword?: string; status?: string; + queuedAtFrom?: string; + queuedAtTo?: string; } export interface TraceQuery extends MessageQuery { @@ -731,7 +735,7 @@ export class OperationsService { this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }), - this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }), + this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }), ]).then((counts) => counts.reduce((sum, value) => sum + value, 0)); } @@ -756,9 +760,25 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { messageId: query.messageId, phoneNumber: query.phoneNumber, status: query.status, + ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), + ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), + ...(query.queuedAtFrom || query.queuedAtTo ? { + queuedAt: { + ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), + ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), + }, + } : {}), }; } +function startOfShanghaiDay(value: string) { + return new Date(`${value}T00:00:00+08:00`); +} + +function endOfShanghaiDay(value: string) { + return new Date(`${value}T23:59:59.999+08:00`); +} + function normalizeGroupBy(groupBy?: string) { if (groupBy === 'tenant' || groupBy === 'tenantId') { return 'tenantId'; diff --git a/api/src/prisma/prisma.service.ts b/api/src/prisma/prisma.service.ts index 9337abf..df719e4 100644 --- a/api/src/prisma/prisma.service.ts +++ b/api/src/prisma/prisma.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; +import { requestContext } from '../common/request-context'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleDestroy { @@ -11,6 +12,24 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy { 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public', ), }); + const operationLog = this.operationLog; + Object.defineProperty(this, 'operationLog', { + value: new Proxy(operationLog, { + get(target, property, receiver) { + if (property === 'create') { + return (args: { data: Record }) => { + const ipAddress = requestContext.getStore()?.ipAddress; + return (target.create as (input: unknown) => unknown)({ + ...args, + data: { ...args.data, ipAddress: typeof args.data.ipAddress === 'string' ? args.data.ipAddress : ipAddress }, + }); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }), + }); } async onModuleDestroy() { diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index cec3cab..d6db05b 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1564,6 +1564,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { ? String(channel.config.serviceId) : 'SMS', srcId: channel.srcId, + extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0), registeredDelivery: 1, msgFmt: 8, }, @@ -2411,6 +2412,16 @@ function getPositiveConfigInteger(config: unknown, key: string, fallback: number return fallback; } +function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) { + if (config && typeof config === 'object' && !Array.isArray(config) && key in config) { + const value = Number((config as Record)[key]); + if (Number.isInteger(value) && value >= 0) { + return value; + } + } + return fallback; +} + function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) { const normalized = normalizeCarrier(channelCarrier); return normalized === 'all' || normalized === targetCarrier; diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 11af866..18014e5 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -154,6 +154,7 @@ export class UsersService { phone: data.phone === undefined ? undefined : normalizeOptional(data.phone), displayName: data.displayName ?? current.displayName, status: data.status ?? current.status, + ...(data.status === 'disabled' && current.status !== 'disabled' ? { sessionVersion: { increment: 1 } } : {}), }, include: { tenant: true, roles: { include: { role: true } } }, }); @@ -166,7 +167,7 @@ export class UsersService { const current = await this.getExisting(id, scopeTenantId); const updated = await this.prisma.user.update({ where: { id }, - data: { status: data.status }, + data: { status: data.status, ...(data.status === 'disabled' && current.status !== 'disabled' ? { sessionVersion: { increment: 1 } } : {}) }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username }); @@ -180,7 +181,7 @@ export class UsersService { const current = await this.getExisting(id, scopeTenantId); const updated = await this.prisma.user.update({ where: { id }, - data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null }, + data: { passwordHash: hashPassword(data.password), 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 }); @@ -191,7 +192,7 @@ export class UsersService { const current = await this.getExisting(id, scopeTenantId); const updated = await this.prisma.user.update({ where: { id }, - data: { status: 'deleted', deletedAt: new Date() }, + data: { status: 'deleted', deletedAt: new Date(), sessionVersion: { increment: 1 } }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username }); @@ -218,6 +219,23 @@ export class UsersService { }); } + async changeOwnPassword(id: string, currentPassword: string, password: string) { + if (!currentPassword || !password || password.length < 6) { + 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)) { + throw new BadRequestException('当前密码不正确'); + } + const updated = await this.prisma.user.update({ + where: { id }, + data: { passwordHash: hashPassword(password), 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 }); + return updated; + } + listRoles() { return this.prisma.role.findMany({ include: { permissions: { include: { permission: true } } }, diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index ea5ddc5..5927193 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -109,7 +109,7 @@ 9. 短信应用必须有应用级客户侧企业代码 `cmppEnterpriseCode`,运营端添加/编辑应用时可自定义;不得从上游通道 `SmsChannel.enterpriseCode` 透传。 10. 短信应用接口密码 `passwordCipher` 新建时默认随机生成 16 位 UUID 片段,运营端可手工修改;编辑时留空不覆盖原密码。 11. 应用 `AppID` 是平台内部应用标识,用于页面展示、复制参数和工单定位,不作为 CMPP bind/login 认证参数。 -12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections` 和客户提交窗口 `cmppWindowSize`;这两个字段是平台运行配置,不是 CMPP 协议字段,也不是 gocmpp 库参数。 +12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections`;客户侧提交窗口 `cmppWindowSize` 后端保留默认值,当前第一版不在运营端展示或要求运营配置,待 Gateway 入站侧按应用窗口真正限流后再开放为高级配置。 13. 短信应用必须恢复设计基线中的“短信接口”开关,字段为 `interfaceEnabled`,默认开通;关闭后客户端/API 发送链路、客户侧 CMPP Gateway bind/login 和 submit 都必须被真实后端拒绝,不允许只在前端隐藏入口。 14. 短信应用必须恢复设计基线中的“接口类型”配置,当前第一版仅允许 `CMPP2.0`,字段为 `interfaceType=cmpp20`;HTTP 接口在页面中展示为暂不可选,后端也必须拒绝 `http` 等未实现类型。 @@ -221,6 +221,7 @@ 10. Gateway 必须支持平台最终回执向下游客户连接投递 Deliver Receipt;若客户连接已断开,应按策略缓存、重试或记录投递失败,不能丢失平台最终状态。 11. Gateway 必须支持下游客户上行接入场景:收到运营商上行后,按接入号、手机号、应用、时间窗口匹配并向客户连接推送 Deliver,上行同时入库。 12. 下游客户连接与上游通道连接必须隔离管理:客户侧账号密码不能用于连接上游通道,上游通道账号密码也不能作为客户接入凭据。 +13. Gateway 必须为客户侧 CMPP2.0/3.0 Submit 记录可检索日志:收包时记录协议版本、账号、客户 IP、sequenceId、号码、srcId、编码、分片序号和内容长度;响应时记录 result、平台 messageId、CMPP Msg_Id、耗时和失败阶段。NestJS 拒绝 Submit 时,Gateway 日志必须保留 API 返回的真实业务原因,不能只记录 HTTP 状态码;短信正文不得明文写入 Gateway 日志,仅记录字符数和哈希。 #### 4.8.3 回执、上行与幂等 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 93145b1..857fc1b 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -488,12 +488,12 @@ 1. 打开运营端企业应用管理,点击新增短信应用。 2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。 3. 选择企业后进入应用参数表单。 - 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、短信接口开关、接口类型、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。 + 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、短信接口开关、接口类型、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、移动/联通/电信通道组后保存。 5. 刷新列表并打开编辑页。 - 预期结果: - 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。 - 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。 - - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`interfaceEnabled`、`interfaceType=cmpp20`、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections`、`cmppWindowSize` 和通道组绑定。 + - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`interfaceEnabled`、`interfaceType=cmpp20`、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections` 和通道组绑定;企业应用表单不展示或提交客户侧 `cmppWindowSize`。 - “短信接口”开关刷新后仍来自真实数据库;关闭后该应用不能通过客户端/API 发送,也不能通过 Gateway bind/login 或 submit。 - “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。 - `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。 @@ -999,14 +999,16 @@ - 步骤: 1. 启动 Go Gateway,确认 `GATEWAY_CMPP_ADDR=0.0.0.0:17890`。 2. 使用 gocmpp 或真实 CMPP 客户端连接 `17890`,`Source_Addr` 填应用 `cmppAccount`,密码填应用 CMPP 参数 `passwordCipher`。 - 3. 发送 CMPP 3.0 SubmitReq,手机号和内容匹配已审核模板。 - 4. 查询 NestJS 数据库和运营端短信记录。 + 3. 分别发送 CMPP 2.0 和 CMPP 3.0 SubmitReq,手机号和内容匹配已审核模板。 + 4. 再发送一条不匹配审核模板的 SubmitReq,检查客户收到的 SubmitResp 和 Gateway 日志。 + 5. 查询 NestJS 数据库和运营端短信记录。 - 预期结果: - 17890 是真实 CMPP Server 监听,不是 HTTP 端口。 - bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态、短信接口开关和 IP 白名单。 - 密码错误、应用停用、企业停用、短信接口关闭、IP 不在白名单时 connect/login 被拒绝。 - submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。 - submit 内容不匹配审核模板、余额不足、短信接口关闭、无可用通道时返回明确失败,不得伪造成功。 + - Gateway 对每次 submit 记录 `submit_received` 和 `submit_accepted`/`submit_rejected`;日志可按账号、IP、sequenceId、号码和 messageId 定位,拒绝时包含 NestJS 真实业务原因和 CMPP result,但不包含明文短信正文。 ### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环 @@ -3028,7 +3030,7 @@ npm run verify:phase8 | 用例 | 细化执行点 | 必查断言 | | --- | --- | --- | -| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 | +| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;充值记录“充值后余额”必须等于该订单关联 AccountTransaction.balanceAfter,不能用当前账户余额替代;运营日志和客户端流水均可追溯。 | | TC-BILLING-007 | 分别只填金额、只填短信条数;金额填写负数执行冲正。 | 未填项按 0;正负金额和条数字段方向正确;允许有业务含义的负数调整,不产生 null、NaN 或零变更脏数据。 | | TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | | TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 1483348..7b24042 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -650,6 +650,20 @@ npm run verify:phase8 - 下游 submit 当前通过 `sourceType=cmpp` 的系统批次兼容承载,尚未完全拆成独立单条发送模型。 - 客户侧最终 Deliver Receipt 投递、客户侧上行 Deliver 推送、上游真实 SMSC submit worker、上游 receipt/uplink 生产解析仍未完成。 +## 2026-07-11 Gateway 客户侧 Submit 日志完善 + +### 本轮修复 + +- Gateway 入站 Submit 日志增加 `submit_received`、`submit_accepted`、`submit_rejected` 结构化事件,同时记录 CONNECT 声明的客户协议版本与 Go 实际解包类型,并记录账号、客户 IP、sequenceId、号码、srcId、编码、分片、CMPP result、平台 messageId、CMPP Msg_Id 和处理耗时。 +- Gateway HTTP 回调在 NestJS 返回非 2xx 时保留最多 64KB 响应体,客户 Submit 失败日志可直接显示模板不匹配、IP 白名单、余额或路由等真实业务原因,不再只显示 HTTP 状态码。 +- 日志不记录明文短信正文,仅记录字符数和 MD5 哈希,便于比对同一内容且避免日志泄露。 + +### 验证状态 + +- `go test ./internal/inbound -count=1`:通过。 +- `go test ./... -count=1`:通过。 +- `go build ./cmd/gateway`:通过。 + ## 2026-07-07 Gateway 上游提交与下游 Deliver 闭环补齐 ### 本轮修复 @@ -765,7 +779,7 @@ npm run verify:phase8 - 运营端通道创建/编辑表单新增上游 `desiredConnections` 和 `windowSize` 输入,真实提交到 NestJS 通道 API,并规范化写入 `SmsChannel.config`。 - NestJS `ChannelsService` 对 `desiredConnections/windowSize` 增加正整数校验;通道激活后的 `ConnectChannel` 请求和发送链路 `SubmitCommand.upstream` 均复用该真实配置。 -- Prisma 为 `SmsApplication` 新增 `cmppMaxConnections`、`cmppWindowSize` 字段;运营端短信应用创建/编辑表单新增 `cmppAccount`、客户最大连接数、客户提交窗口输入。 +- Prisma 为 `SmsApplication` 新增 `cmppMaxConnections`、`cmppWindowSize` 字段;运营端短信应用创建/编辑表单新增 `cmppAccount` 和客户最大连接数输入,客户提交窗口暂不展示给运营配置,保留后端默认值。 - 企业应用 `cmppAccount` 现在支持两种真实路径:显式填写 6 位数字账号,或留空由后端自动生成唯一账号;重复账号和非法格式会被后端拒绝。 - 企业应用 CMPP 参数接口改为从应用真实字段返回 `enterpriseCode/account/passwordCipher/maxConnections/windowSize`,不再借用任意通道企业代码或默认值拼装客户参数。 - 应用级 `cmppEnterpriseCode` 新建/编辑可自定义;接口密码新建默认随机 16 位 UUID 片段,编辑留空不覆盖、填写 16 位后更新。`AppID` 仅作为平台应用标识展示,不作为 CMPP 协议认证参数。 @@ -1388,3 +1402,141 @@ git diff --check - 生产 `cmpp-api`、`cmpp-gateway`、PostgreSQL、Nginx 均为 active,API health 正常。 - 隔离部署后曾因 `dist/assets` 被保留为 `700 root:root` 导致 Nginx 无权读取 JS/CSS、admin 页面空白;线上已修正为目录 `755`、文件 `644`,正式生产部署脚本同步固化权限。 - 正式部署发现已有生产管理员且未配置 `PROD_ADMIN_PASSWORD` 时,`upsert.create` 仍会对空密码执行哈希;已拆分 create/update 密码变量,已有账号不改密码,新建账号才生成临时密码。 + +## 2026-07-10 Batch 0 飞书瑕疵台账与分批策略 + +来源:飞书《短信平台第一版瑕疵》。本表仅记录问题路由和验收边界;除 Batch 1 外,其他项目仍须先在生产验证环境只读复现并核对真实代码、API、PostgreSQL、Redis、MinIO 或 Gateway 状态,不能根据页面现象直接修改。 + +| 飞书项 | 初步分类 | 真实链路/风险 | 计划批次 | 当前状态 | +| --- | --- | --- | --- | --- | +| 1.1-1.5 企业-充值流程 | UI + API/DB | 对象存储预览、人工充值、充值订单、账户流水 | Batch 1 | 已完成并部署;历史余额取 AccountTransaction 快照 | +| 2.1 通道密码展示/修改 | UI + API/DB + 安全 | 密码密文、权限、审计、上游连接配置 | Batch 2 | 已完成:密码不回显,留空不覆盖,填写新值才更新 | +| 2.2 扩展位数和通道流速 | UI + API/DB + Gateway | 通道配置持久化、Gateway submit 限速 | Batch 2 | 已完成:真实持久化并下发 Gateway SubmitCommand | +| 2.3 通道组名称为空提示 | UI 校验 | 服务端字段校验与前端错误提示一致 | Batch 2 | 已验证:既有前端提示会在真实 API 调用前中断保存 | +| 2.4 发送记录详情弹窗 | UI + API | 详情、回执、提交记录必须来自真实接口 | Batch 2 | 已完成:真实状态、提交和回执信息分层展示 | +| 2.5 连接日志优化 | UI + API + Gateway | 连接状态回写、操作日志、分页筛选 | Batch 2 | 已完成:展示真实连接状态摘要并支持日志关键词筛选 | +| 2.6 通道测试 | API/DB + Gateway/CMPP | 测试 submit、Redis Stream、上游响应、审计 | Batch 2 | 已完成:提交结果展示真实测试流水和提交记录 | +| 2.7 短信记录页面 | UI + API/DB + Gateway | 短信、submit、回执、分片审计真实查询 | Batch 2 | 已完成:筛选下推 PostgreSQL,详情/审计为真实接口 | +| 3.1 报备配置无返回 | UI 导航 | 返回后筛选/表单状态不丢失 | Batch 3 | 已完成:返回通道列表 | +| 3.2 通道组添加通道弹窗 | UI + API | 通道组成员真实保存和回填 | Batch 3 | 已完成:真实候选、状态展示、重复项限制和错误提示 | +| 4.1 创建用户 | UI + API/DB | 用户、角色、企业关联、审计 | Batch 4 | 已完成:真实表单校验、提交状态和错误提示 | +| 4.2 禁用/删除/改密后踢下线 | API/DB + 会话 | Token/session 失效、跨浏览器验证、审计 | Batch 4 | 已完成:数据库会话版本使旧 token 失效 | +| 4.3 禁用按钮颜色 | UI | 仅样式,保持通用危险操作语义 | Batch 4 | 已完成:使用 warning 语义色 | +| 4.4 个人改密缺失 | UI + API/会话 | 当前用户校验、密码更新、旧会话失效 | Batch 4 | 已完成:右上角真实当前密码校验与改密 | +| 5.1 待审核任务数不准 | API/DB 聚合 | 审核状态口径与任务明细一致 | Batch 5 | 已完成:风险审核改按 SmsSendTask.pending_review 统计 | +| 5.2 任务进度 | UI + API/DB + Gateway | 状态机、发送/回执计数、分页 | Batch 5 | 已完成:未知/超时不重复累计,已处理数不超过总号码数 | +| 5.3 企业应用 | UI + API/DB | 短信应用真实 CRUD/审核;彩信仅占位 | Batch 5 | 已完成:停用使用 warning 色,启用使用 success 色,搜索区宽度协调 | +| 5.4 企业模板 | UI + API/DB | 模板材料、审核状态、真实筛选 | Batch 5 | 已完成:审核状态以中文展示,draft 显示为草稿 | +| 5.5 企业签名 | UI + API/DB + MinIO | 资质文件、签名审核、对象存储预览 | Batch 5 | 已完成:左边框按三网真实报备结果展示,编辑页不允许手工改报备状态 | +| 5.6 引流信息 | UI + API/DB | 字典字段、签名/模板关联、审核口径 | Batch 5 | 已完成:列表改为引流信息、长链接不跳转且省略展示、操作列可见,编辑页不允许手工改报备状态 | +| 6.1 手机号段库 Tab | UI | 使用通用 Tabs,不改变真实号段数据路径 | Batch 6 | 已完成:Tab 按内容宽度展示 | +| 6.2 运营商区分规则分页 | UI + API/DB | 服务端分页、筛选与总数口径 | Batch 6 | 已完成:PostgreSQL 分页、总数、25 条每页 | +| 7.1 敏感词页 | UI + API/DB | 敏感词 CRUD、生效范围、发送校验 | Batch 6 | 已完成:状态 Tag 清晰展示,添加弹窗扩展,保留真实 CRUD | +| 8.1 系统日志 IP 为空 | API/DB + Nginx | 转发头、请求上下文、OperationLog 落库、历史数据边界 | Batch 6 | 已完成:真实 HTTP 操作日志记录 Nginx 转发的客户端 IP;后台任务保持空值 | +| 8.2 客户端标题 | UI | 客户端产品名称与运营端区分 | Batch 6 | 已完成:短信平台客户端 | +| 8.3 通用输入框/文本框样式 | UI | 去除内层填充色,保留边框和焦点状态 | 回归复查 | 已完成:含 Chromium 自动填充背景 | +| 8.4 精确时间格式 | UI | 所有精确时间统一 `YYYY-MM-DD HH:mm:ss` | Batch 6 | 已完成:统一 helper 覆盖日志、用户、充值、任务与配置展示 | +| 8.5 中文图片文件名乱码 | API/DB + MinIO + UI | multipart 编码、对象存储文件名、历史展示兼容 | 回归复查 | 已完成:新上传正确入库,历史展示兼容解码 | +| 8.6 全局分页控件 | UI + API/DB | 总页数、首页/末页、指定页跳转与服务端分页口径 | Batch 6 | 已完成:统一控件支持首页、末页、页码跳转;真实服务端分页页传入总页数 | +| 9.1 客户端菜单顺序 | UI | 签名与引流信息菜单位于模板管理之前 | Batch 6 | 已完成 | + +### 执行约束 + +- 每个 Batch 先只读复现并记录页面、API、DB、Gateway 分类,再做最小真实修复。 +- 纯 UI 项也必须确认页面数据源不是 mock、localStorage 或静态数组;未实现后端的彩信仅保留待开发占位。 +- 每批结束执行相关 API 测试、API build、前端 build;涉及 Gateway 时追加 Go 测试和生产 Gateway health/CMPP 验证。 +- 完成后更新本文件;未经明确要求不提交或推送代码。 + +## 2026-07-10 Batch 1 企业充值流程瑕疵 + +### 本轮修复 + +- 企业新建/编辑页的图片“预览”改为站内弹窗展示,不再跳转或新开页面;下载仍走真实对象存储文件接口。 +- 通用 `Input`、`Select`、`Textarea` 根据 `required` 属性显示必填标识;企业资料和人工充值弹窗不再依赖页面散落的文案约定。 +- 运营端充值记录列表的“充值后余额”改为真实订单关联 `AccountTransaction.balanceAfter`;不再用当前 `TenantAccount` 余额冒充历史快照。没有可追溯流水的历史记录显示 `-`。 +- 人工充值弹窗补齐非零校验、提交中禁用和 API 失败提示;提交仍调用 `POST /api/admin/billing/manual-recharges`,成功后刷新真实记录。 +- 企业名称与统一社会信用代码已经使用同一双列栅格,本轮复现未见对齐问题,不做无效样式改动。 + +### 验证口径 + +- `GET /api/admin/billing/manual-recharges` 必须基于 Prisma/PostgreSQL 的 `RechargeOrder` 和关联 `AccountTransaction` 返回余额快照。 +- `TC-BILLING-006` 增加断言:充值记录“充值后余额”等于关联账务流水的 `balanceAfter`,与后续充值或消费后的当前余额无关。 + +### 已执行命令与结果 + +```bash +npm --prefix api test +npm --prefix api run build +npm run build +git diff --check +``` + +- API 全量单测通过:12 个 test suites、113 个测试通过;新增 BillingService 覆盖两笔人工充值分别返回其历史余额。 +- API build 和前端 build 通过;前端仍有既有 Vite chunk size warning。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 +- 已按生产标准脚本部署到 `8.160.169.106`;Prisma migration deploy 无待执行迁移,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health、Redis 均通过。 +- 生产管理员真实登录后只读调用 `GET /api/admin/billing/manual-recharges` 成功返回 2 条记录,响应包含真实 `balanceAfterCents`(10000、1000)。 + +## 2026-07-10 Batch 2 通道配置真实链路 + +### 本轮修复 + +- 运营端通道编辑/新建页的“通道流速”不再固定提交 `100`;输入值按 `1-2000 TPS` 校验后写入 `SmsChannel.rateLimitPerSecond`,发送链路和通道测试继续从该真实字段生成 Gateway `SubmitCommand.route.rateLimitPerSecond`。 +- “扩展位数”仅允许 `0/2/4/6`,持久化到 `SmsChannel.config.extensionDigits`;编辑页回填该值,普通发送和通道测试均将其放入 Gateway `SubmitCommand.cmpp.extensionDigits`。 +- NestJS 更新通道时修正 `config` 合并行为:传入的配置会与既有 JSON 配置合并,不会再被 `desiredConnections/windowSize` 规范化过程静默丢弃。 +- 网关密码保持安全策略:编辑时不回显已配置密码,留空不覆盖;输入新密码才更新真实通道配置。 + +### 已执行命令与结果 + +```bash +npm --prefix api test -- channels.service.spec.ts --runInBand +npm --prefix api run build +npm run build +go test ./internal/queue ./internal/upstream +git diff --check +``` + +- ChannelsService 和 SendChainService 定向测试通过:2 个 test suites、55 个测试通过;ChannelsService 单独测试 23 项,覆盖流速、扩展位数持久化和非法配置拒绝。 +- API build、前端 build、Gateway queue/upstream 测试通过;前端仍有既有 Vite chunk size warning。 +- 已重新部署生产验证环境;Prisma migration deploy 无待执行迁移,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。生产运行源码已确认包含流速校验、扩展位数持久化及 Gateway 队列字段。 +- 通道组名称为空时已有前端提示“请输入通道组名称”,保存会在调用真实创建/更新 API 前中断;本轮复核后不重复改动。 +- 通道编辑密码保持掩码且不回显:编辑时明确提示“留空保持不变,填写新密码才更新”;新建通道仍要求填写密码。 +- 上述密码交互调整已于 2026-07-10 生产验证部署后再次核验:`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,内外部 health/HTTP 检查通过。 +- 短信记录列表修复:企业、应用、手机号、状态之外的提交日期、短信内容、通道名称筛选改为传给 `GET /api/admin/operations/messages`;NestJS 通过 Prisma/PostgreSQL 执行内容、关联通道名和上海自然日范围查询,页面不再仅筛选已加载的前 500 条记录。 +- 生产只读复现确认:短信记录 9 条均有真实 `SmsSubmitRecord`,其中 4 条已有真实 `SmsReceiptRecord`;3 个通道均有 `CmppConnectionState` 和 `OperationLog` 连接日志。按一条生产记录的日期、内容、通道关键词组合查询,9 条中仅返回 1 条且条件均匹配。 +- `OperationsService` 定向测试 12 项、API build、前端 build 均通过;已部署生产验证,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。 +- 发送详情弹窗重组为真实状态摘要、短信内容、通道提交/回执轨迹、状态信息和分片补偿审计;提交轨迹新增真实 `submitStatus`,不再只展示时间和回执码。 +- 连接日志弹窗新增 `CmppConnectionState` 摘要(连接 ID、状态、当前/期望连接数、最近心跳、最近错误),日志内容以真实 `OperationLog.detail` 可读格式呈现,并仅对已返回日志做关键词筛选。 +- 通道测试成功后展示 API 返回的真实 `testNo`、提交数量、手机号与 `SmsSubmitRecord.submitId`,禁用重复提交,并提供跳转至短信记录入口;没有虚构“发送成功”或模拟回执。 + +## 2026-07-10 Batch 3 通道报备与通道组配置 + +- 通道报备配置页新增返回通道列表入口,沿用现有 `/admin/channels/:channelId/reports` 路由的来源页面,避免运营人员进入配置页后没有回退路径。 +- 通道组“添加通道”弹窗不再使用固定省份数组:省份和候选通道均来自 `GET /api/admin/channels`,按真实运营商、地区和已绑定通道过滤;选中后展示通道代码、地区和真实连接状态。 +- 弹窗在省份、优先级或通道未选择时提供表单错误提示;没有符合条件的候选时显示可读空态。前端仅做交互约束,最终仍由 NestJS `ChannelsService` 校验运营商/地区兼容性、重复通道和优先级规则,并持久化到 `SmsChannelGroupItem`。 +- 已执行 `npm --prefix api test -- channels.service.spec.ts --runInBand`(23 项通过)、`npm run build` 和 `git diff --check`;前端保留既有 Vite chunk size warning。 +- 已部署生产验证:Prisma migration deploy 无待执行迁移,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常;生产只读接口返回 3 个真实通道(其中 2 个启用)、1 个真实通道组和 2 个组成员。 + +## 2026-07-10 Batch 4 用户管理与会话失效 + +- `User.sessionVersion` 真实持久化到 PostgreSQL;登录 token 携带该版本。浏览器携带 token 请求时,NestJS 会话中间件校验用户状态、删除状态和版本;禁用、删除、管理员改密和个人改密都会递增版本,使原会话在下一次请求被 401 拒绝,前端清理本地会话并跳回对应登录页。 +- 为避免破坏 Gateway 与现有服务间无浏览器会话链路,中间件仅校验带 `Authorization` 的浏览器 token;未携带该 header 的既有内部请求保持原行为。 +- 右上角“修改密码”补齐真实 `POST /api/auth/password`:要求当前密码、新密码(至少 6 位)和确认密码一致;成功后当前会话立即失效并回到登录页,写入操作日志。 +- 运营端和客户端用户新建补齐姓名、至少一个联系方式、初始密码/企业关联等前端校验,提交中禁用按钮并展示 API 错误;用户禁用操作改用通用 warning 语义色。 +- 已执行 `npm --prefix api run prisma:generate`、`npm --prefix api test -- auth.service.spec.ts session-validation.middleware.spec.ts users.service.spec.ts --runInBand`(3 suites、8 项通过)、`npm --prefix api run build`、`npm run build`、`git diff --check`;前端保留既有 Vite chunk size warning。 +- 已部署生产验证:第 25 条 Prisma migration `20260710153000_add_user_session_version` 成功应用;`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。生产管理员新登录 token 为版本格式且可读取真实用户列表;伪造旧版本 token 被 `401` 拒绝,验证会话版本失效生效。 + +## 2026-07-10 Batch 5 审核与企业配置 + +- 修复 Dashboard 待审核聚合:短信审核的真实状态存储在 `SmsSendTask.status=pending_review`,原逻辑错误统计 `SmsBatchTask.auditStatus=pending`。聚合现统一模板、签名、企业认证和风险审核的真实待审状态。 +- 生产 PostgreSQL 与对应 API 在部署后均显示四类待审为 0,Dashboard 也为 0,当前数据口径一致;无非零待审样本,未将该 0 值当作非零场景的充分验收。 +- 任务进度、企业应用、企业模板、企业签名与引流字段页面均使用真实 NestJS API;生产只读接口成功返回任务、应用、模板、签名和引流字段数据,不存在 mock/localStorage 回退。 +- 已根据下载的瑕疵文档修复 5.2-5.6:任务进度不重复累计未知/超时,企业应用启停语义色与搜索区,模板中文审核状态,签名三网状态驱动边框且移除人工状态选择,引流信息标题、链接展示、操作列和人工状态选择。 +- 已执行 `npm --prefix api test -- operations.service.spec.ts --runInBand`(12 项通过)、前端 build 和 `git diff --check`;已部署生产验证,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。 + +## 2026-07-10 瑕疵回归复查 + +- 逐图复查下载的《短信平台第一版瑕疵》后,确认中文图片文件名乱码仍真实存在:生产 `FileObject.fileName` 中可见 UTF-8 被按 Latin-1 解释后的值。上传链路现先恢复 multipart 文件名编码;历史记录由前端展示层兼容解码,避免签名材料和企业认证页继续显示乱码。 +- 通用输入框、文本框去除内部填充色;同时覆盖 Chromium 自动填充产生的蓝色内层背景。企业认证页此前额外写死的灰色输入背景已移除。 +- 已执行 FilesService 定向单测(3 项通过)、API build、前端 build 和 `git diff --check`;生产部署后四个服务均为 active,API/Gateway health 正常。通过真实 `POST /api/admin/files/upload` 上传 `营业执照-编码回归.png`,响应和 `FileObject` 持久化文件名均为正常中文。 +- Batch 6 的 6.1、6.2、7.1、8.1 仍为待修,不得因之前的前端构建通过而标记完成;其余 8.x 与客户端菜单顺序将继续按原始文档逐项复核。 diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index 97f3e81..7aa5a71 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "log" "net" "net/http" @@ -86,6 +87,7 @@ type DownstreamUplink struct { type downstreamSession struct { messageID string account string + protocol string srcID string phoneNumber string gatewayMsgID uint64 @@ -143,6 +145,7 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger resp.AuthIsmg = string(authISMG[:]) session := downstreamSession{ account: strings.TrimSpace(defaultString(auth.Account, account)), + protocol: cmppVersionName(req.Version), srcID: strings.TrimSpace(auth.Account), remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()), connectedAt: time.Now().UTC(), @@ -153,59 +156,150 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger } rememberAccount(session) go s.flushPending(defaultString(auth.Account, account), logger) - logger.Printf("cmpp inbound account=%s login ok remote=%s", account, packet.Conn.Conn.RemoteAddr()) + logger.Printf( + "cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x30 account=%s remote=%s", + cmppVersionName(req.Version), req.Version, account, packet.Conn.Conn.RemoteAddr(), + ) return false, nil } func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) { - req, ok := packet.Packer.(*cmpp.Cmpp3SubmitReqPkt) + req, ok := normalizeInboundSubmit(packet.Packer) if !ok { return true, nil } - resp := response.Packer.(*cmpp.Cmpp3SubmitRspPkt) - account := strings.TrimRight(req.MsgSrc, "\x00") + account := strings.TrimRight(req.msgSrc, "\x00") phone := "" - if len(req.DestTerminalId) > 0 { - phone = strings.TrimRight(req.DestTerminalId[0], "\x00") + if len(req.destTerminalIDs) > 0 { + phone = strings.TrimRight(req.destTerminalIDs[0], "\x00") } - content, err := decodeContent(req.MsgFmt, req.MsgContent) + remote := packet.Conn.Conn.RemoteAddr() + clientProtocol := inboundClientProtocol(account, packet.Conn, req.protocol) + logger.Printf( + "cmpp inbound event=submit_received protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s src_id=%s msg_fmt=%d pk=%d/%d dest_count=%d content_bytes=%d", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt, + req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent), + ) + content, err := decodeContent(req.msgFmt, req.msgContent) if err != nil { - logger.Printf("cmpp inbound decode submit failed account=%s seq=%d err=%v", account, req.SeqId, err) - resp.Result = 9 + logger.Printf( + "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=decode reason=%q", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, err, + ) + setInboundSubmitResponse(response.Packer, 0, 9) return false, nil } - result, err := s.submit(packet.Conn.Conn.RemoteAddr(), submitRequest{ + contentHash := fmt.Sprintf("%x", md5.Sum([]byte(content))) + startedAt := time.Now() + result, err := s.submit(remote, submitRequest{ Account: account, PhoneNumber: phone, Content: content, - SrcID: req.SrcId, + SrcID: req.srcID, DestID: phone, - SequenceID: req.SeqId, - RemoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()), + SequenceID: req.sequenceID, + RemoteIP: remoteIP(remote), }) if err != nil || !result.Accepted { - logger.Printf("cmpp inbound submit rejected account=%s phone=%s seq=%d err=%v", account, phone, req.SeqId, err) - resp.Result = 9 + reason := "api returned accepted=false" + if err != nil { + reason = err.Error() + } + logger.Printf( + "cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=business duration_ms=%d content_chars=%d content_hash=%s reason=%q", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, reason, + ) + setInboundSubmitResponse(response.Packer, 0, 9) return false, nil } - resp.MsgId = messageIDFrom(result.MessageID, req.SeqId) - resp.Result = 0 + gatewayMsgID := messageIDFrom(result.MessageID, req.sequenceID) + setInboundSubmitResponse(response.Packer, gatewayMsgID, 0) rememberDownstream(downstreamSession{ messageID: result.MessageID, account: account, - srcID: strings.TrimSpace(req.SrcId), + protocol: clientProtocol, + srcID: strings.TrimSpace(req.srcID), phoneNumber: phone, - gatewayMsgID: resp.MsgId, - remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()), + gatewayMsgID: gatewayMsgID, + remoteIP: remoteIP(remote), connectedAt: time.Now().UTC(), conn: packet.Conn, mu: &sync.Mutex{}, presence: s.PresenceStore, instanceID: s.gatewayInstanceID(), }) + logger.Printf( + "cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, + ) return false, nil } +type inboundSubmitPacket struct { + protocol string + pkTotal uint8 + pkNumber uint8 + msgFmt uint8 + msgSrc string + srcID string + destTerminalIDs []string + msgContent string + sequenceID uint32 +} + +func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) { + switch req := packet.(type) { + case *cmpp.Cmpp2SubmitReqPkt: + return inboundSubmitPacket{ + protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt, + msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, + msgContent: req.MsgContent, sequenceID: req.SeqId, + }, true + case *cmpp.Cmpp3SubmitReqPkt: + return inboundSubmitPacket{ + protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt, + msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId, + msgContent: req.MsgContent, sequenceID: req.SeqId, + }, true + default: + return inboundSubmitPacket{}, false + } +} + +func setInboundSubmitResponse(packet any, messageID uint64, result uint32) { + switch resp := packet.(type) { + case *cmpp.Cmpp2SubmitRspPkt: + resp.MsgId = messageID + resp.Result = uint8(result) + case *cmpp.Cmpp3SubmitRspPkt: + resp.MsgId = messageID + resp.Result = result + } +} + +func inboundClientProtocol(account string, conn *cmpp.Conn, fallback string) string { + downstreamRegistry.RLock() + defer downstreamRegistry.RUnlock() + session := downstreamRegistry.byAccount[account] + if session != nil && session.conn == conn && session.protocol != "" { + return session.protocol + } + return fallback +} + +func cmppVersionName(version cmpp.Type) string { + switch version { + case cmpp.V20: + return "cmpp20" + case cmpp.V21: + return "cmpp21" + case cmpp.V30: + return "cmpp30" + default: + return fmt.Sprintf("unknown_0x%02x", uint8(version)) + } +} + func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32) (authResponse, error) { payload := authRequest{ Account: account, @@ -320,11 +414,22 @@ func (s Server) post(ctx context.Context, path string, payload any, result any) return err } defer resp.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return fmt.Errorf("read api response: %w", err) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("api returned %s", resp.Status) + detail := strings.TrimSpace(string(responseBody)) + if detail == "" { + return fmt.Errorf("api returned %s", resp.Status) + } + return fmt.Errorf("api returned %s: %s", resp.Status, detail) } if result != nil { - return json.NewDecoder(resp.Body).Decode(result) + if len(responseBody) == 0 { + return io.EOF + } + return json.Unmarshal(responseBody, result) } return nil } diff --git a/gateway/internal/inbound/server_test.go b/gateway/internal/inbound/server_test.go index 6670b36..da3e1ab 100644 --- a/gateway/internal/inbound/server_test.go +++ b/gateway/internal/inbound/server_test.go @@ -1,6 +1,7 @@ package inbound import ( + "bytes" "context" "encoding/json" "log" @@ -166,6 +167,69 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { } } +func TestPostIncludesAPIErrorResponseBody(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"CMPP submit content does not match an approved template and signature","statusCode":400}`)) + })) + defer api.Close() + + err := (Server{APIBaseURL: api.URL}).post(context.Background(), "/inbound/submit", map[string]string{"account": "100001"}, nil) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte("CMPP submit content does not match an approved template and signature")) { + t.Fatalf("expected API response body in error, got %v", err) + } +} + +func TestNormalizeInboundSubmitSupportsCMPP2AndCMPP3(t *testing.T) { + tests := []struct { + name string + packet any + protocol string + }{ + {name: "cmpp2", packet: &cmpp.Cmpp2SubmitReqPkt{MsgSrc: "100001", SeqId: 20}, protocol: "cmpp20"}, + {name: "cmpp3", packet: &cmpp.Cmpp3SubmitReqPkt{MsgSrc: "100001", SeqId: 30}, protocol: "cmpp30"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := normalizeInboundSubmit(test.packet) + if !ok || got.protocol != test.protocol || got.msgSrc != "100001" { + t.Fatalf("unexpected normalized packet: %+v ok=%v", got, ok) + } + }) + } +} + +func TestSetInboundSubmitResponseSupportsCMPP2AndCMPP3(t *testing.T) { + cmpp2 := &cmpp.Cmpp2SubmitRspPkt{} + setInboundSubmitResponse(cmpp2, 101, 9) + if cmpp2.MsgId != 101 || cmpp2.Result != 9 { + t.Fatalf("unexpected CMPP2 response: %+v", cmpp2) + } + cmpp3 := &cmpp.Cmpp3SubmitRspPkt{} + setInboundSubmitResponse(cmpp3, 202, 9) + if cmpp3.MsgId != 202 || cmpp3.Result != 9 { + t.Fatalf("unexpected CMPP3 response: %+v", cmpp3) + } +} + +func TestInboundClientProtocolUsesConnectRequestVersion(t *testing.T) { + resetDownstreamRegistry() + defer resetDownstreamRegistry() + conn := &cmpp.Conn{} + downstreamRegistry.byAccount["100001"] = &downstreamSession{ + account: "100001", + protocol: "cmpp20", + conn: conn, + } + if got := inboundClientProtocol("100001", conn, "cmpp30"); got != "cmpp20" { + t.Fatalf("protocol = %s, want cmpp20", got) + } + if got := inboundClientProtocol("missing", conn, "cmpp30"); got != "cmpp30" { + t.Fatalf("fallback protocol = %s, want cmpp30", got) + } +} + func TestFlushOnlineAccountsFetchesPendingDeliveries(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() diff --git a/gateway/internal/queue/messages.go b/gateway/internal/queue/messages.go index 15f3d9f..cba4533 100644 --- a/gateway/internal/queue/messages.go +++ b/gateway/internal/queue/messages.go @@ -51,6 +51,7 @@ type Route struct { type CMPP struct { ServiceID string `json:"serviceId"` SrcID string `json:"srcId"` + ExtensionDigits int `json:"extensionDigits"` RegisteredDelivery int `json:"registeredDelivery"` MsgFmt int `json:"msgFmt"` FeeUserType int `json:"feeUserType,omitempty"` diff --git a/gateway/internal/queue/messages_test.go b/gateway/internal/queue/messages_test.go index 6727468..777200a 100644 --- a/gateway/internal/queue/messages_test.go +++ b/gateway/internal/queue/messages_test.go @@ -31,6 +31,7 @@ func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) { "cmpp": { "serviceId": "SMS", "srcId": "10690000", + "extensionDigits": 4, "registeredDelivery": 1, "msgFmt": 8 }, @@ -62,4 +63,7 @@ func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) { if command.Upstream.DesiredConnections != 2 || command.Upstream.WindowSize != 16 { t.Fatalf("unexpected upstream window config: %+v", command.Upstream) } + if command.CMPP.ExtensionDigits != 4 { + t.Fatalf("unexpected extension digits: %d", command.CMPP.ExtensionDigits) + } } diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 96ce9ec..525714a 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -1,4 +1,4 @@ -import { getSessionTenantId, readSession, type LoginSession } from './session'; +import { clearSession, getSessionTenantId, readSession, type LoginSession } from './session'; type RequestOptions = RequestInit & { tenantId?: string; @@ -35,6 +35,11 @@ async function request(path: string, options: RequestOptions = {}): Promise>; + connectionStates: CmppConnectionState[]; logs: Array<{ id: string; time: string; @@ -224,6 +234,7 @@ export type RechargeOrder = { paidAt?: string | null; operatorId?: string | null; remark?: string | null; + balanceAfterCents?: number | null; createdAt: string; tenant?: TenantOption; }; @@ -784,6 +795,8 @@ export const adminApi = { getCaptcha: () => request('/admin/auth/captcha'), login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), + changeOwnPassword: (body: { currentPassword: string; password: string }) => + request('/auth/password', { method: 'POST', body: JSON.stringify(body) }), listTenants: () => request('/admin/tenants'), listTenantManagementRows: () => request('/admin/tenants/management-list'), getTenant: (id: string) => request(`/admin/tenants/${id}`), @@ -929,7 +942,7 @@ export const adminApi = { request(withQuery('/admin/send/messages', query)), listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) => request(withQuery('/admin/operations/message-segment-audits', query)), - listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}) => + listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => request(withQuery('/admin/operations/messages', query)), listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => request(withQuery('/admin/operations/uplink-messages', query)), @@ -972,7 +985,8 @@ export const adminApi = { request>(withQuery('/admin/dictionaries/phone-segments', query)), createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), - listPhoneCarrierRules: () => request('/admin/dictionaries/phone-carrier-rules'), + listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => + request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), diff --git a/src/apps/admin/AdminChannelGroupFormPage.tsx b/src/apps/admin/AdminChannelGroupFormPage.tsx index 60d3eae..0d3fddd 100644 --- a/src/apps/admin/AdminChannelGroupFormPage.tsx +++ b/src/apps/admin/AdminChannelGroupFormPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Info, Pencil, Plus, Trash2 } from 'lucide-react'; +import { CheckCircle2, Info, Pencil, Plus, RadioTower, Trash2 } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; @@ -24,15 +24,6 @@ type RouteModalState = { route?: ProvinceRoute | NationalRoute; }; -const provinceOptions = [ - { label: '请选择', value: '' }, - { label: '山东', value: '山东' }, - { label: '河南', value: '河南' }, - { label: '北京', value: '北京' }, - { label: '上海', value: '上海' }, - { label: '广东', value: '广东' }, -]; - const priorityOptions = [ { label: '请选择', value: '' }, { label: '1', value: '1' }, @@ -83,12 +74,14 @@ function RouteConfigModal({ channels, carrier, modal, + occupiedChannelIds, onClose, onSubmit, }: { channels: AdminChannel[]; carrier: Carrier; modal: RouteModalState; + occupiedChannelIds: string[]; onClose: () => void; onSubmit: (route: ProvinceRoute | NationalRoute) => void; }) { @@ -97,9 +90,20 @@ function RouteConfigModal({ const [province, setProvince] = useState(provinceRoute?.province ?? ''); const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : ''); const [channelId, setChannelId] = useState(modal.route?.channelId ?? ''); + const [error, setError] = useState(''); + + const provinceOptions = [ + { label: '请选择省份', value: '' }, + ...Array.from(new Set(channels + .filter((channel) => isCarrierCompatible(channel.carrier, carrier)) + .map((channel) => channel.sendRegion) + .filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')), + )).sort().map((region) => ({ label: region, value: region })), + ]; const selectableChannels = channels.filter((channel) => { if (!isCarrierCompatible(channel.carrier, carrier)) return false; + if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false; if (modal.type === 'province' && province) { return normalizeRegion(channel.sendRegion) === normalizeRegion(province); } @@ -114,10 +118,16 @@ function RouteConfigModal({ ]; function submit() { - if (!channelId) return; + if (!channelId) { + setError('请选择可用通道'); + return; + } const channel = channels.find((item) => item.id === channelId); if (modal.type === 'province') { - if (!province) return; + if (!province) { + setError('请选择省份'); + return; + } onSubmit({ id: provinceRoute?.id ?? `p-${Date.now()}`, province, @@ -127,7 +137,10 @@ function RouteConfigModal({ return; } - if (!priority) return; + if (!priority) { + setError('请选择优先级'); + return; + } onSubmit({ id: nationalRoute?.id ?? `n-${Date.now()}`, priority: Number(priority), @@ -147,7 +160,12 @@ function RouteConfigModal({ onClose={onClose} open size="xl" - title={modal.mode === 'edit' ? '编辑通道' : '添加通道'} + title={( +
+ +

{modal.mode === 'edit' ? '编辑通道' : '添加通道'}

{modal.type === 'province' ? '为指定省份选择匹配的上游通道' : '按优先级配置全国通道补发顺序'}

+
+ )} >
{modal.type === 'province' ? ( @@ -162,6 +180,22 @@ function RouteConfigModal({ )} setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} /> setAccount(event.target.value)} placeholder="请输入网关账号" value={account} /> setPassword(event.target.value)} placeholder="请输入网关密码" type="password" value={password} /> + setPassword(event.target.value)} + placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'} + required={modal.mode === 'create'} + type="password" + value={password} + />
setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> - setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} /> + setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} /> setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
@@ -305,16 +336,18 @@ function ChannelFormModal({ function SmsTestModal({ channel, onClose, + onOpenRecords, }: { channel: SmsChannel; onClose: () => void; + onOpenRecords: () => void; }) { const [phones, setPhones] = useState(''); const [content, setContent] = useState(''); const [accessNo, setAccessNo] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); - const [result, setResult] = useState(''); + const [result, setResult] = useState(null); const billingCount = Math.max(1, Math.ceil(content.length / 67)); async function submitTestSms() { @@ -328,14 +361,14 @@ function SmsTestModal({ } setSubmitting(true); setError(''); - setResult(''); + setResult(null); try { const response = await adminApi.testChannel(channel.id, { phones, content, accessNo: accessNo.trim() || undefined, }); - setResult(`已提交 ${response.submitted} 条测试短信,测试流水号 ${response.testNo}`); + setResult(response); } catch (failure) { setError(failure instanceof Error ? failure.message : '测试短信发送失败'); } finally { @@ -348,8 +381,9 @@ function SmsTestModal({ footer={( <> - : null} + )} @@ -402,9 +436,22 @@ function SmsTestModal({
{error ?

{error}

: null} {result ? ( -
- - {result} +
+
+ +
+ 已写入真实发送队列 + 测试流水号:{result.testNo},共 {result.submitted} 条 +
+
+
+ {result.messages.map((message) => ( +
+ {message.phoneNumber} + {message.submitId} +
+ ))} +
) : null}
@@ -423,6 +470,7 @@ export function AdminChannelsPage() { const [testChannel, setTestChannel] = useState(null); const [confirmAction, setConfirmAction] = useState(null); const [logState, setLogState] = useState(null); + const [logKeyword, setLogKeyword] = useState(''); const [page, setPage] = useState(1); const pageSize = 10; @@ -496,8 +544,14 @@ export function AdminChannelsPage() { async function openLinkLogs(channel: SmsChannel) { setLogState({ channel }); - const data = await adminApi.listChannelConnectionLogs(channel.id); - setLogState({ channel, data }); + setLogKeyword(''); + try { + const data = await adminApi.listChannelConnectionLogs(channel.id); + setLogState({ channel, data }); + } catch (failure) { + setLogState(null); + setError(failure instanceof Error ? failure.message : '连接日志加载失败'); + } } function submitConfirmAction() { @@ -606,6 +660,8 @@ export function AdminChannelsPage() { onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} + totalPages={totalPages} + onPageChange={setPage} previousDisabled={currentPage <= 1} total={filteredChannels.length} /> @@ -623,6 +679,10 @@ export function AdminChannelsPage() { setTestChannel(null)} + onOpenRecords={() => { + setTestChannel(null); + navigate('/admin/sms-records'); + }} /> ) : null} @@ -654,8 +714,38 @@ export function AdminChannelsPage() { size="xl" title={

连接日志

{logState.channel.name}

} > -
- {(logState.data?.logs ?? []).map((log) => ( +
+ {logState.data ? ( +
+ {logState.data.connectionStates.map((connection) => ( +
+
+ 连接 ID + {connection.connectionId} +
+ + {connectionStatusLabelMap[connection.status] ?? connection.status} + +
+ 当前 / 期望 + {connection.currentConnections} / {connection.desiredConnections} +
+
+ 最近心跳 + {connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN', { hour12: false }) : '-'} +
+ {connection.lastError ?

{connection.lastError}

: null} +
+ ))} + {logState.data.connectionStates.length === 0 ?

暂无连接状态回写

: null} +
+ ) : null} + setLogKeyword(event.target.value)} placeholder="事件、资源或详情关键词" value={logKeyword} /> +
+ {(logState.data?.logs ?? []).filter((log) => { + const keyword = logKeyword.trim().toLowerCase(); + return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword); + }).map((log) => (
{log.event} @@ -663,12 +753,16 @@ export function AdminChannelsPage() {
{log.resourceId} -

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

+
{formatLogDetail(log.detail)}
))} - {logState.data && logState.data.logs.length === 0 ?

暂无连接日志

: null} + {logState.data && logState.data.logs.filter((log) => { + const keyword = logKeyword.trim().toLowerCase(); + return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword); + }).length === 0 ?

未找到匹配的连接日志

: null} {!logState.data ?

正在加载连接日志...

: null} +
) : null} diff --git a/src/apps/admin/AdminDownstreamDeliveriesPage.tsx b/src/apps/admin/AdminDownstreamDeliveriesPage.tsx index 3c20016..3478447 100644 --- a/src/apps/admin/AdminDownstreamDeliveriesPage.tsx +++ b/src/apps/admin/AdminDownstreamDeliveriesPage.tsx @@ -364,6 +364,8 @@ export function AdminDownstreamDeliveriesPage() { = totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} diff --git a/src/apps/admin/AdminDownstreamRecoveryStatusesPage.tsx b/src/apps/admin/AdminDownstreamRecoveryStatusesPage.tsx index 1ab0956..06e3f44 100644 --- a/src/apps/admin/AdminDownstreamRecoveryStatusesPage.tsx +++ b/src/apps/admin/AdminDownstreamRecoveryStatusesPage.tsx @@ -332,6 +332,8 @@ export function AdminDownstreamRecoveryStatusesPage() { = totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index d2941c4..b584265 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -3,6 +3,7 @@ import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi'; +import { formatDateTime } from '@/utils/dateTime'; type SmsApp = { id: string; @@ -140,7 +141,6 @@ function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) { `接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`, `最大连接数: ${cmppParams.maxConnections}`, `心跳间隔: ${cmppParams.heartbeatSeconds}秒`, - `提交窗口: ${cmppParams.windowSize}`, `协议版本: ${cmppParams.protocolVersion}`, ].join('\n'); } @@ -186,7 +186,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
接入号{srcId}
最大连接数{params?.maxConnections ?? app.cmppParams.maxConnections}
心跳间隔{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒
-
提交窗口{params?.windowSize ?? app.cmppParams.windowSize}
协议版本{params?.protocolVersion ?? app.cmppParams.protocolVersion}
{paramsText}
@@ -382,7 +381,7 @@ export function AdminEnterpriseApplicationsPage() { render: (record) => (
- @@ -401,7 +400,7 @@ export function AdminEnterpriseApplicationsPage() {
-
+
setEnterpriseKeyword(event.target.value)} @@ -481,9 +480,9 @@ function mapConnection(connection: CmppConnectionState): CmppConnection { bindType: 'transceiver', clientIp: String(connection.channel?.gatewayHost ?? ''), sourceAddr: String(connection.channel?.enterpriseCode ?? ''), - establishedAt: connection.lastConnectedAt ? new Date(connection.lastConnectedAt).toLocaleString('zh-CN') : '', - lastHeartbeatAt: connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN') : '', - lastSubmitAt: connection.updatedAt ? new Date(connection.updatedAt).toLocaleString('zh-CN') : '', + establishedAt: formatDateTime(connection.lastConnectedAt), + lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt), + lastSubmitAt: formatDateTime(connection.updatedAt), pendingWindow: connection.currentConnections, }; } diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index 28e46dc..e8f6199 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react'; import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi'; import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui'; +import { displayFileName } from '@/utils/fileName'; +import { formatDateTime } from '@/utils/dateTime'; type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing'; @@ -69,13 +71,6 @@ const statusToneMap: Record{statusLabelMap[status]}; } @@ -170,12 +165,16 @@ function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filin return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback; } -function toAuditStatus(status: CarrierStatus) { - return status === 'filing' ? 'pending' : status; +function signatureCardTone(statuses: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }) { + const values = Object.values(statuses); + if (values.includes('rejected')) return 'red'; + if (values.every((status) => status === 'approved')) return 'green'; + if (values.includes('pending')) return 'blue'; + return 'gray'; } function formatDate(value?: string) { - return value ? new Date(value).toLocaleString('zh-CN') : '-'; + return formatDateTime(value); } function SignatureUploadBox({ @@ -210,7 +209,7 @@ function SignatureUploadBox({
-
-

三网报备状态

-
- update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} /> - update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} /> update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} /> update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} /> - update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} /> - update('submittedAt', event.target.value)} value={form.submittedAt} />