feat: improve application access and money precision
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
-- Monetary values were historically stored as integer cents. The platform now
|
||||
-- stores integer units of 0.0001 yuan. Convert existing values by 100 while
|
||||
-- widening columns to bigint so balances and report aggregates do not inherit
|
||||
-- PostgreSQL integer's ~214,748 yuan ceiling at the new scale.
|
||||
|
||||
ALTER TABLE "TenantAccount"
|
||||
ALTER COLUMN "balanceCents" TYPE BIGINT USING ("balanceCents"::BIGINT * 100),
|
||||
ALTER COLUMN "creditCents" TYPE BIGINT USING ("creditCents"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "AccountTransaction"
|
||||
ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100),
|
||||
ALTER COLUMN "balanceAfter" TYPE BIGINT USING ("balanceAfter"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "BillingRule"
|
||||
ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "RechargeOrder"
|
||||
ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "SmsBillingRecord"
|
||||
ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100),
|
||||
ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "SmsApplication"
|
||||
ALTER COLUMN "customerUnitPrice" TYPE BIGINT USING ("customerUnitPrice"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "SmsChannel"
|
||||
ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "SmsMessageRecord"
|
||||
ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100),
|
||||
ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "SmsSubmitRecord"
|
||||
ALTER COLUMN "costUnitPrice" TYPE BIGINT USING ("costUnitPrice"::BIGINT * 100),
|
||||
ALTER COLUMN "costAmountCents" TYPE BIGINT USING ("costAmountCents"::BIGINT * 100);
|
||||
|
||||
ALTER TABLE "DailyProfitReport"
|
||||
ALTER COLUMN "revenueCents" TYPE BIGINT USING ("revenueCents"::BIGINT * 100),
|
||||
ALTER COLUMN "costCents" TYPE BIGINT USING ("costCents"::BIGINT * 100),
|
||||
ALTER COLUMN "profitCents" TYPE BIGINT USING ("profitCents"::BIGINT * 100);
|
||||
+17
-17
@@ -269,8 +269,8 @@ model DrainageField {
|
||||
model TenantAccount {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
balanceCents Int @default(0)
|
||||
creditCents Int @default(0)
|
||||
balanceCents BigInt @default(0)
|
||||
creditCents BigInt @default(0)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -284,8 +284,8 @@ model AccountTransaction {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
transactionType String
|
||||
amountCents Int @default(0)
|
||||
balanceAfter Int @default(0)
|
||||
amountCents BigInt @default(0)
|
||||
balanceAfter BigInt @default(0)
|
||||
relatedType String?
|
||||
relatedId String?
|
||||
remark String?
|
||||
@@ -302,7 +302,7 @@ model BillingRule {
|
||||
code String @unique
|
||||
name String
|
||||
chargeBasis String @default("submit_success")
|
||||
unitPrice Int
|
||||
unitPrice BigInt
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -312,7 +312,7 @@ model RechargeOrder {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
orderNo String @unique
|
||||
amountCents Int
|
||||
amountCents BigInt
|
||||
status String @default("created")
|
||||
payMethod String?
|
||||
paidAt DateTime?
|
||||
@@ -335,8 +335,8 @@ model SmsBillingRecord {
|
||||
phoneNumber String?
|
||||
contentLength Int
|
||||
billingUnits Int
|
||||
unitPrice Int
|
||||
amountCents Int
|
||||
unitPrice BigInt
|
||||
amountCents BigInt
|
||||
billingStatus String @default("estimated")
|
||||
transactionId String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -367,7 +367,7 @@ model SmsApplication {
|
||||
cmppMaxConnections Int @default(1)
|
||||
cmppWindowSize Int @default(16)
|
||||
dailyLimit Int?
|
||||
customerUnitPrice Int @default(0)
|
||||
customerUnitPrice BigInt @default(0)
|
||||
queuePriority String @default("normal")
|
||||
maxPhonesPerTask Int @default(1000000)
|
||||
templateMismatchMode String @default("reject")
|
||||
@@ -730,7 +730,7 @@ model SmsChannel {
|
||||
srcId String
|
||||
cmppVersion String @default("2.0")
|
||||
rateLimitPerSecond Int @default(100)
|
||||
unitPrice Int @default(0)
|
||||
unitPrice BigInt @default(0)
|
||||
status String @default("active")
|
||||
config Json?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -1317,8 +1317,8 @@ model SmsMessageRecord {
|
||||
province String?
|
||||
content String
|
||||
billingUnits Int @default(1)
|
||||
unitPrice Int @default(0)
|
||||
amountCents Int @default(0)
|
||||
unitPrice BigInt @default(0)
|
||||
amountCents BigInt @default(0)
|
||||
queuePriority String @default("normal")
|
||||
channelId String?
|
||||
submitId String?
|
||||
@@ -1389,8 +1389,8 @@ model SmsSubmitRecord {
|
||||
sequenceId Int?
|
||||
gatewayMessageId String?
|
||||
submitStatus String @default("queued")
|
||||
costUnitPrice Int @default(0)
|
||||
costAmountCents Int @default(0)
|
||||
costUnitPrice BigInt @default(0)
|
||||
costAmountCents BigInt @default(0)
|
||||
errorCode String?
|
||||
errorMessage String?
|
||||
submittedAt DateTime?
|
||||
@@ -1439,9 +1439,9 @@ model DailyProfitReport {
|
||||
channelId String?
|
||||
sentUnits Int @default(0)
|
||||
successUnits Int @default(0)
|
||||
revenueCents Int @default(0)
|
||||
costCents Int @default(0)
|
||||
profitCents Int @default(0)
|
||||
revenueCents BigInt @default(0)
|
||||
costCents BigInt @default(0)
|
||||
profitCents BigInt @default(0)
|
||||
profitRateBps Int @default(0)
|
||||
generatedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -61,6 +61,16 @@ describe('BillingService', () => {
|
||||
amountCents: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
service.estimateSmsCost({ tenantId: 'tenant-1', content: '四位小数单价', phoneCount: 2, unitPrice: 325 }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
unitPrice: 325,
|
||||
totalBillingUnits: 2,
|
||||
amountCents: 650,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows sending only when cash balance plus credit is greater than zero', async () => {
|
||||
@@ -81,7 +91,7 @@ describe('BillingService', () => {
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: true }),
|
||||
);
|
||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度必须为整数金额');
|
||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'billing.credit_limit_updated',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantAccountDto {
|
||||
@@ -87,7 +88,8 @@ export class BillingService {
|
||||
}
|
||||
|
||||
createAccount(data: CreateTenantAccountDto) {
|
||||
assertCreditAmount(data.creditCents ?? 0);
|
||||
assertMoneyUnits(data.balanceCents ?? 0, '账户余额', { allowNegative: true });
|
||||
assertMoneyUnits(data.creditCents ?? 0, '授信额度', { allowNegative: true });
|
||||
const createData: Prisma.TenantAccountUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
balanceCents: data.balanceCents ?? 0,
|
||||
@@ -98,7 +100,7 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async updateCreditLimit(tenantId: string, data: UpdateCreditLimitDto) {
|
||||
assertCreditAmount(data.creditCents);
|
||||
assertMoneyUnits(data.creditCents, '授信额度', { allowNegative: true });
|
||||
const account = await this.getAccountOrCreate(tenantId);
|
||||
const updated = await this.prisma.tenantAccount.update({
|
||||
where: { tenantId },
|
||||
@@ -112,7 +114,7 @@ export class BillingService {
|
||||
resource: 'tenant_account',
|
||||
resourceId: account.id,
|
||||
detail: {
|
||||
previousCreditCents: account.creditCents,
|
||||
previousCreditCents: moneyToNumber(account.creditCents),
|
||||
creditCents: data.creditCents,
|
||||
remark: data.remark,
|
||||
} as Prisma.InputJsonValue,
|
||||
@@ -148,7 +150,7 @@ export class BillingService {
|
||||
},
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
});
|
||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, transaction.balanceAfter]));
|
||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
||||
|
||||
return orders.map((order) => ({
|
||||
...order,
|
||||
@@ -158,6 +160,7 @@ export class BillingService {
|
||||
|
||||
async createRechargeOrder(data: CreateRechargeOrderDto) {
|
||||
const amountCents = data.amountCents;
|
||||
assertMoneyUnits(amountCents, '充值金额', { allowNegative: true, allowZero: false });
|
||||
const order = await this.prisma.rechargeOrder.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -211,7 +214,10 @@ export class BillingService {
|
||||
estimateSmsCost(data: EstimateSmsCostDto) {
|
||||
const billingUnits = estimateBillingUnits(data.content);
|
||||
const unitPrice = data.unitPrice ?? 0;
|
||||
assertMoneyUnits(unitPrice, '短信单价');
|
||||
const totalUnits = billingUnits * data.phoneCount;
|
||||
const amountCents = totalUnits * unitPrice;
|
||||
assertMoneyUnits(amountCents, '短信计费金额');
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
@@ -221,20 +227,22 @@ export class BillingService {
|
||||
billingUnitsPerMessage: billingUnits,
|
||||
totalBillingUnits: totalUnits,
|
||||
unitPrice,
|
||||
amountCents: totalUnits * unitPrice,
|
||||
amountCents,
|
||||
};
|
||||
}
|
||||
|
||||
async checkAccount(data: BillingActionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const requiredAmount = data.amountCents ?? 0;
|
||||
const availableAmount = account.balanceCents + account.creditCents;
|
||||
const balanceCents = moneyToNumber(account.balanceCents);
|
||||
const creditCents = moneyToNumber(account.creditCents);
|
||||
const availableAmount = balanceCents + creditCents;
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
requiredAmount,
|
||||
availableAmount,
|
||||
balanceCents: account.balanceCents,
|
||||
creditCents: account.creditCents,
|
||||
balanceCents,
|
||||
creditCents,
|
||||
canSend: availableAmount > 0,
|
||||
};
|
||||
}
|
||||
@@ -316,6 +324,7 @@ export class BillingService {
|
||||
}
|
||||
|
||||
createRule(data: CreateBillingRuleDto) {
|
||||
assertMoneyUnits(data.unitPrice, '计费规则单价');
|
||||
return this.prisma.billingRule.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
@@ -337,7 +346,7 @@ export class BillingService {
|
||||
|
||||
private async applyAccountDelta(data: CreateAccountTransactionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const nextBalance = account.balanceCents + (data.amountCents ?? 0);
|
||||
const nextBalance = moneyToNumber(account.balanceCents) + (data.amountCents ?? 0);
|
||||
await this.prisma.tenantAccount.update({
|
||||
where: { tenantId: data.tenantId },
|
||||
data: {
|
||||
@@ -359,12 +368,6 @@ export class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
function assertCreditAmount(creditCents: number) {
|
||||
if (!Number.isInteger(creditCents)) {
|
||||
throw new BadRequestException('授信额度必须为整数金额(分)');
|
||||
}
|
||||
}
|
||||
|
||||
function estimateBillingUnits(content: string) {
|
||||
const length = [...content].length;
|
||||
if (length <= 70) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateChannelDto {
|
||||
@@ -230,6 +231,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
assertMoneyUnits(data.unitPrice ?? 0, '通道单价');
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
@@ -275,6 +277,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (data.unitPrice !== undefined) {
|
||||
assertMoneyUnits(data.unitPrice, '通道单价');
|
||||
}
|
||||
const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort);
|
||||
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
@@ -323,7 +328,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
enterpriseCode: channel.enterpriseCode,
|
||||
account: channel.account,
|
||||
srcId: channel.srcId,
|
||||
unitPrice: channel.unitPrice,
|
||||
unitPrice: moneyToNumber(channel.unitPrice),
|
||||
},
|
||||
after: data,
|
||||
} as Prisma.InputJsonValue,
|
||||
@@ -502,7 +507,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice,
|
||||
costAmountCents: channel.unitPrice * messageRecord.billingUnits,
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits,
|
||||
},
|
||||
});
|
||||
const command = buildChannelTestSubmitCommand({
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export function isIpAllowed(remoteIp: string, allowlist: string[]) {
|
||||
const normalizedRemoteIp = normalizeIp(remoteIp);
|
||||
if (allowlist.length === 0) return true;
|
||||
return allowlist.some((rule) => ipMatchesRule(normalizedRemoteIp, rule));
|
||||
}
|
||||
|
||||
function ipMatchesRule(remoteIp: string, rule: string) {
|
||||
const normalizedRule = normalizeIp(rule.trim());
|
||||
if (!normalizedRule) return false;
|
||||
if (!normalizedRule.includes('/')) return remoteIp === normalizedRule;
|
||||
const [network, prefixText] = normalizedRule.split('/');
|
||||
const prefix = Number(prefixText);
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32 || isIP(remoteIp) !== 4 || isIP(network) !== 4) return false;
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return (ipv4ToInt(remoteIp) & mask) === (ipv4ToInt(network) & mask);
|
||||
}
|
||||
|
||||
function normalizeIp(value: string) {
|
||||
return value.replace(/^::ffff:/, '').trim();
|
||||
}
|
||||
|
||||
function ipv4ToInt(value: string) {
|
||||
return value.split('.').reduce((result, part) => ((result << 8) + Number(part)) >>> 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export const MONEY_UNITS_PER_YUAN = 10_000;
|
||||
|
||||
export function moneyToNumber(value: number | bigint | null | undefined) {
|
||||
if (value === null || value === undefined) return 0;
|
||||
const result = typeof value === 'bigint' ? Number(value) : value;
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new RangeError('金额超过 JavaScript 安全整数范围');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function moneyUnitsToYuan(value: number | bigint | null | undefined) {
|
||||
return moneyToNumber(value) / MONEY_UNITS_PER_YUAN;
|
||||
}
|
||||
|
||||
export function moneyUnitsToFixedYuan(value: number | bigint | null | undefined) {
|
||||
return moneyUnitsToYuan(value).toFixed(4);
|
||||
}
|
||||
|
||||
export function assertMoneyUnits(
|
||||
value: number,
|
||||
label: string,
|
||||
options: { allowNegative?: boolean; allowZero?: boolean } = {},
|
||||
) {
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new BadRequestException(`${label}最多支持人民币小数点后 4 位,且不能超过安全金额范围`);
|
||||
}
|
||||
if (!options.allowNegative && value < 0) {
|
||||
throw new BadRequestException(`${label}不能为负数`);
|
||||
}
|
||||
if (options.allowZero === false && value === 0) {
|
||||
throw new BadRequestException(`${label}不能为 0`);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,17 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
configurable: true,
|
||||
value(this: bigint) {
|
||||
const result = Number(this);
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new RangeError('金额超过 JavaScript 安全整数范围');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
|
||||
export interface MessageQuery {
|
||||
tenantId?: string;
|
||||
@@ -268,7 +269,7 @@ export class OperationsService {
|
||||
unknown: todayTotals.unknown,
|
||||
successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0,
|
||||
spendCents: todayTotals.amountCents,
|
||||
returnedCents: transactionAggregate._sum.amountCents ?? 0,
|
||||
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
||||
billingUnits: todayTotals.billingUnits,
|
||||
},
|
||||
uplinkCount,
|
||||
@@ -794,9 +795,9 @@ export class OperationsService {
|
||||
_sum: { amountCents: true },
|
||||
}),
|
||||
]);
|
||||
const messageAmount = messages._sum.amountCents ?? 0;
|
||||
const billingAmount = billing._sum.amountCents ?? 0;
|
||||
const transactionAmount = transactions._sum.amountCents ?? 0;
|
||||
const messageAmount = moneyToNumber(messages._sum.amountCents);
|
||||
const billingAmount = moneyToNumber(billing._sum.amountCents);
|
||||
const transactionAmount = moneyToNumber(transactions._sum.amountCents);
|
||||
return {
|
||||
messages,
|
||||
billing,
|
||||
@@ -1014,12 +1015,12 @@ function formatExportTimestamp(date: Date) {
|
||||
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
|
||||
}
|
||||
|
||||
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | null; billingUnits: number | null } }>) {
|
||||
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
|
||||
return groups.reduce(
|
||||
(summary, group) => {
|
||||
const count = group._count._all;
|
||||
summary.total += count;
|
||||
summary.amountCents += group._sum.amountCents ?? 0;
|
||||
summary.amountCents += moneyToNumber(group._sum.amountCents);
|
||||
summary.billingUnits += group._sum.billingUnits ?? 0;
|
||||
if (group.status === 'delivered') {
|
||||
summary.delivered += count;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { moneyUnitsToFixedYuan } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
@@ -79,7 +80,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
async exportProfit(query: ReportListQuery) {
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '消费金额(分)', '成本金额(分)', '利润(分)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.revenueCents, item.costCents, item.profitCents, (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '消费金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
}
|
||||
|
||||
async exportQuality(query: ReportListQuery) {
|
||||
@@ -141,11 +142,11 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
WITH billing AS (
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue
|
||||
FROM "SmsBillingRecord"
|
||||
GROUP BY "messageId"
|
||||
), costs AS (
|
||||
SELECT submit."messageRecordId", SUM(submit."costAmountCents")::integer AS cost
|
||||
SELECT submit."messageRecordId", SUM(submit."costAmountCents")::bigint AS cost
|
||||
FROM "SmsSubmitRecord" submit
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
GROUP BY submit."messageRecordId"
|
||||
@@ -168,9 +169,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
NULL,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(billing.revenue), 0)::integer,
|
||||
COALESCE(SUM(costs.cost), 0)::integer,
|
||||
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::integer,
|
||||
COALESCE(SUM(billing.revenue), 0)::bigint,
|
||||
COALESCE(SUM(costs.cost), 0)::bigint,
|
||||
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
|
||||
CURRENT_TIMESTAMP,
|
||||
@@ -187,7 +188,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
WITH billing AS (
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue
|
||||
FROM "SmsBillingRecord"
|
||||
GROUP BY "messageId"
|
||||
)
|
||||
@@ -214,9 +215,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(submit."costAmountCents"), 0)::integer,
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::integer,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(submit."costAmountCents"), 0)::bigint,
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0)) * 10000.0 /
|
||||
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
|
||||
|
||||
@@ -73,6 +73,7 @@ function createPrismaMock() {
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
cmppMaxConnections: 2,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
@@ -520,6 +521,7 @@ describe('SendChainService', () => {
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
account: '100001',
|
||||
enterpriseCode: 'SP0001',
|
||||
maxConnections: 2,
|
||||
status: 'authenticated',
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { OpenApiService } from '../open-api/open-api.service';
|
||||
@@ -681,7 +682,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
select: { id: true, amountCents: true, billingUnits: true },
|
||||
take: 100000,
|
||||
});
|
||||
const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0);
|
||||
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
||||
@@ -1754,7 +1755,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!matchesApplicationSecret(data, application.secretHash)) {
|
||||
throw new BadRequestException('CMPP account or password is invalid');
|
||||
}
|
||||
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
return {
|
||||
@@ -1763,6 +1764,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
account: application.cmppAccount,
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
passwordCipher: application.secretHash,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
status: 'authenticated',
|
||||
};
|
||||
}
|
||||
@@ -1772,7 +1774,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
|
||||
@@ -1781,7 +1783,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
|
||||
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
const unitPrice = application.customerUnitPrice ?? 0;
|
||||
const unitPrice = moneyToNumber(application.customerUnitPrice);
|
||||
const queuePriority = normalizeQueuePriority(application.queuePriority);
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
tenantId: application.tenantId,
|
||||
@@ -2089,7 +2091,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice ?? 0,
|
||||
costAmountCents: (channel.unitPrice ?? 0) * Math.max(1, message.billingUnits ?? 1),
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
|
||||
},
|
||||
});
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
@@ -2241,7 +2243,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new NotFoundException('无已报备通过且在线的可用通道');
|
||||
}
|
||||
return {
|
||||
channel: selected.channel,
|
||||
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
|
||||
carrier,
|
||||
province,
|
||||
groupId: route.groupId,
|
||||
@@ -2321,7 +2323,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return 0;
|
||||
}
|
||||
return application.customerUnitPrice ?? 0;
|
||||
return moneyToNumber(application.customerUnitPrice);
|
||||
}
|
||||
|
||||
private async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
|
||||
@@ -2533,10 +2535,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
unitPrice: number;
|
||||
amountCents: number;
|
||||
unitPrice: number | bigint;
|
||||
amountCents: number | bigint;
|
||||
}) {
|
||||
const amountCents = message.amountCents ?? 0;
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
const unitPrice = moneyToNumber(message.unitPrice);
|
||||
const billingUnits = message.billingUnits ?? 0;
|
||||
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
||||
if (exists?.billingStatus === 'charged') {
|
||||
@@ -2566,7 +2569,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
phoneNumber: message.phoneNumber,
|
||||
contentLength: [...message.content].length,
|
||||
billingUnits,
|
||||
unitPrice: message.unitPrice ?? 0,
|
||||
unitPrice,
|
||||
amountCents,
|
||||
billingStatus: 'charged',
|
||||
transactionId: transaction.id,
|
||||
@@ -2579,10 +2582,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number },
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
if ((message.amountCents ?? 0) <= 0) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
@@ -2597,7 +2601,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents: message.amountCents,
|
||||
amountCents,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: `${remark}: ${message.messageId}`,
|
||||
@@ -2605,10 +2609,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number },
|
||||
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
if ((message.amountCents ?? 0) <= 0) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
|
||||
@@ -2621,7 +2626,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
const transaction = await this.billing.refund({
|
||||
tenantId: message.tenantId,
|
||||
amountCents: message.amountCents,
|
||||
amountCents,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark,
|
||||
@@ -3329,36 +3334,3 @@ function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusD
|
||||
}
|
||||
return data.state ? 'unknown' : null;
|
||||
}
|
||||
|
||||
function isApplicationIpAllowed(remoteIp: string, allowlist: string[]) {
|
||||
const normalizedRemoteIp = normalizeIp(remoteIp);
|
||||
if (allowlist.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowlist.some((rule) => ipMatchesRule(normalizedRemoteIp, rule));
|
||||
}
|
||||
|
||||
function ipMatchesRule(remoteIp: string, rule: string) {
|
||||
const normalizedRule = normalizeIp(rule.trim());
|
||||
if (!normalizedRule) {
|
||||
return false;
|
||||
}
|
||||
if (!normalizedRule.includes('/')) {
|
||||
return remoteIp === normalizedRule;
|
||||
}
|
||||
const [network, prefixText] = normalizedRule.split('/');
|
||||
const prefix = Number(prefixText);
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32 || isIP(remoteIp) !== 4 || isIP(network) !== 4) {
|
||||
return false;
|
||||
}
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return (ipv4ToInt(remoteIp) & mask) === (ipv4ToInt(network) & mask);
|
||||
}
|
||||
|
||||
function normalizeIp(value: string) {
|
||||
return value.replace(/^::ffff:/, '').trim();
|
||||
}
|
||||
|
||||
function ipv4ToInt(value: string) {
|
||||
return value.split('.').reduce((result, part) => ((result << 8) + Number(part)) >>> 0, 0);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ function createPrismaMock() {
|
||||
interfaceType: 'cmpp20',
|
||||
queuePriority: 'normal',
|
||||
secretHash: '0123456789abcdef',
|
||||
ipAllowlist: [],
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
||||
@@ -261,9 +262,11 @@ describe('SmsConfigService', () => {
|
||||
expect(applications.map((application) => application.id)).toEqual(['app-3', 'app-2', 'app-1']);
|
||||
});
|
||||
|
||||
it('returns CMPP params from persisted application and channel config', async () => {
|
||||
it('returns CMPP params from the public inbound Gateway config instead of an upstream channel', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
process.env.CMPP_PUBLIC_HOST = 'cmpp.example.com';
|
||||
process.env.CMPP_PUBLIC_PORT = '17891';
|
||||
|
||||
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
@@ -275,14 +278,17 @@ describe('SmsConfigService', () => {
|
||||
applicationExtension: '0001',
|
||||
accessNumberFillEnabled: true,
|
||||
accessNumberFillPrefix: '00',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
gatewayHost: 'cmpp.example.com',
|
||||
gatewayPort: 17891,
|
||||
interfaceEnabled: true,
|
||||
interfaceType: 'cmpp20',
|
||||
maxConnections: 2,
|
||||
windowSize: 32,
|
||||
protocolVersion: 'CMPP2.0',
|
||||
}));
|
||||
expect(prisma.smsChannel.findFirst).not.toHaveBeenCalled();
|
||||
delete process.env.CMPP_PUBLIC_HOST;
|
||||
delete process.env.CMPP_PUBLIC_PORT;
|
||||
});
|
||||
|
||||
it('creates enterprise applications with persisted queue priority', async () => {
|
||||
@@ -428,7 +434,9 @@ describe('SmsConfigService', () => {
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
|
||||
await expect(service.updateApplication('app-1', { customerUnitPrice: 325.5 }))
|
||||
.rejects.toThrow('客户单价最多支持人民币小数点后 4 位');
|
||||
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 325, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
|
||||
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
|
||||
|
||||
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
|
||||
@@ -437,7 +445,7 @@ describe('SmsConfigService', () => {
|
||||
data: expect.objectContaining({
|
||||
name: '新应用',
|
||||
cmppEnterpriseCode: '100001',
|
||||
customerUnitPrice: 300,
|
||||
customerUnitPrice: 325,
|
||||
queuePriority: 'priority',
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
}),
|
||||
@@ -498,6 +506,17 @@ describe('SmsConfigService', () => {
|
||||
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
|
||||
});
|
||||
|
||||
it('forbids client CMPP parameter access when the interface is not enabled', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1', tenantId: 'tenant-1', interfaceEnabled: false,
|
||||
tenant: { id: 'tenant-1', name: '租户A' },
|
||||
});
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.getApplicationCmppParams('app-1', 'tenant-1')).rejects.toThrow('该企业应用未开通 CMPP 接口');
|
||||
});
|
||||
|
||||
it('records Gateway downstream CMPP connection and heartbeat events against the real application account', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
@@ -531,6 +550,31 @@ describe('SmsConfigService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
|
||||
interfaceEnabled: true, status: 'active', ipAllowlist: [{ ipCidr: '10.0.0.0/24' }],
|
||||
});
|
||||
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-2', connectedAt: new Date() });
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.recordDownstreamConnectionEvent({
|
||||
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP source IP is not in application allowlist');
|
||||
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
|
||||
interfaceEnabled: true, status: 'active', ipAllowlist: [],
|
||||
});
|
||||
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([
|
||||
{ connectionId: 'gateway-1-1' }, { connectionId: 'gateway-1-2' },
|
||||
]);
|
||||
await expect(service.recordDownstreamConnectionEvent({
|
||||
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP connection limit exceeded (1)');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['heartbeat', 'lastHeartbeatAt'],
|
||||
['submit', 'lastSubmitAt'],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateSmsApplicationDto {
|
||||
@@ -358,6 +360,7 @@ export class SmsConfigService {
|
||||
}
|
||||
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价');
|
||||
const secret = normalizeApplicationPassword(data.passwordCipher);
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
||||
@@ -402,6 +405,9 @@ export class SmsConfigService {
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
if (data.customerUnitPrice !== undefined) {
|
||||
assertMoneyUnits(data.customerUnitPrice, '客户单价');
|
||||
}
|
||||
const queuePriority = data.queuePriority === undefined
|
||||
? undefined
|
||||
: normalizeApplicationQueuePriority(data.queuePriority);
|
||||
@@ -587,18 +593,17 @@ export class SmsConfigService {
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findFirst({
|
||||
where: { status: { not: 'deleted' } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (tenantId && !application.interfaceEnabled) {
|
||||
throw new ForbiddenException('该企业应用未开通 CMPP 接口');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
applicationName: application.name,
|
||||
tenantId: application.tenantId,
|
||||
tenantName: application.tenant.name,
|
||||
appCode: application.id,
|
||||
gatewayHost: channel?.gatewayHost ?? '',
|
||||
gatewayPort: channel?.gatewayPort ?? 0,
|
||||
gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1',
|
||||
gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890),
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
@@ -648,7 +653,7 @@ export class SmsConfigService {
|
||||
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: data.account },
|
||||
select: { id: true, tenantId: true, cmppEnterpriseCode: true },
|
||||
include: { ipAllowlist: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account does not reference an application');
|
||||
@@ -670,6 +675,22 @@ export class SmsConfigService {
|
||||
});
|
||||
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
|
||||
}
|
||||
if (!application.interfaceEnabled || application.status !== 'active') {
|
||||
throw new ForbiddenException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new ForbiddenException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
const activeConnections = await this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId: application.id, status: 'connected' },
|
||||
select: { connectionId: true },
|
||||
orderBy: [{ connectedAt: 'asc' }, { connectionId: 'asc' }],
|
||||
});
|
||||
const allowedConnectionIds = activeConnections.slice(0, application.cmppMaxConnections).map((item) => item.connectionId);
|
||||
if ((!existing && activeConnections.length >= application.cmppMaxConnections)
|
||||
|| (existing && activeConnections.length > application.cmppMaxConnections && !allowedConnectionIds.includes(data.connectionId))) {
|
||||
throw new ForbiddenException(`CMPP connection limit exceeded (${application.cmppMaxConnections})`);
|
||||
}
|
||||
const payload = {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantDto {
|
||||
@@ -64,8 +65,8 @@ export class TenantsService {
|
||||
}),
|
||||
]);
|
||||
const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account]));
|
||||
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
|
||||
const todayRefundByTenant = new Map(todayRefundGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
|
||||
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, moneyToNumber(group._sum.amountCents)]));
|
||||
const todayRefundByTenant = new Map(todayRefundGroups.map((group) => [group.tenantId, moneyToNumber(group._sum.amountCents)]));
|
||||
return tenants.map((tenant) => ({
|
||||
...withEnterpriseProfile(tenant),
|
||||
account: accountsByTenant.get(tenant.id) ?? null,
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
11. 应用 `AppID` 是平台内部应用标识,用于页面展示、复制参数和工单定位,不作为 CMPP bind/login 认证参数。
|
||||
12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections`;客户侧提交窗口 `cmppWindowSize` 后端保留默认值,当前第一版不在运营端展示或要求运营配置,待 Gateway 入站侧按应用窗口真正限流后再开放为高级配置。
|
||||
13. 短信应用必须恢复设计基线中的“短信接口”开关,字段为 `interfaceEnabled`,默认开通;关闭后客户端/API 发送链路、客户侧 CMPP Gateway bind/login 和 submit 都必须被真实后端拒绝,不允许只在前端隐藏入口。
|
||||
14. 短信应用必须恢复设计基线中的“接口类型”配置,当前第一版仅允许 `CMPP2.0`,字段为 `interfaceType=cmpp20`;HTTP 接口在页面中展示为暂不可选,后端也必须拒绝 `http` 等未实现类型。
|
||||
14. CMPP 协议类型当前第一版仅允许 `CMPP2.0`,字段保持 `interfaceType=cmpp20`;HTTP 不写入该字段,而是通过独立的 `SmsApplicationHttpConfig` 总开关和子能力配置开通。前后端仍必须拒绝把 `interfaceType` 直接改成 `http` 等无效协议值。
|
||||
|
||||
### 4.3 签名与引流信息
|
||||
|
||||
@@ -359,7 +359,7 @@
|
||||
6. 最终失败、超时失败需要退费。
|
||||
7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。
|
||||
8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。
|
||||
9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留三位小数;内部仍使用分或最小计费单位持久化,不以展示精度改变账务计算。
|
||||
9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留四位小数;内部使用 `0.0001 元`整数金额单位持久化,不以浮点数执行账务计算。
|
||||
10. API 必须定时扫描提交成功但超过 72 小时仍未收到明确最终回执的短信,转为 timeout 并退还已扣金额;扫描需覆盖 `submitted` 和 `unknown`,且用条件更新避免多实例重复退款。
|
||||
|
||||
## 5. 功能需求
|
||||
@@ -546,7 +546,7 @@
|
||||
- 利润报表按发送日期汇总日发送条数、成功条数、消费金额、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。
|
||||
- 企业应用维度的消费金额只统计仍为 `charged` 的客户账单,最终失败并退款的短信不再形成收入;成本金额统计该应用短信所有上游 `accepted` 提交的通道成本,包括补发产生的真实额外成本。
|
||||
- 通道维度按实际上游 `accepted` 提交统计发送量和成本,按同一 Gateway 消息回执统计成功量;客户收入只归属最终有效提交,避免补发时重复计算收入。通道成本单价和成本金额必须在提交记录创建时快照,后续修改通道单价不得改写历史成本。
|
||||
- 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额继续使用整数分持久化并按三位小数展示。
|
||||
- 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额使用 `0.0001 元`整数金额单位持久化并按四位小数展示。
|
||||
- 报表按北京时间 T+1 生成,不生成当天未完整数据;每日刷新时必须在同一事务内重新生成 T-4 至 T-1 四个完整自然日,使 72 小时内到达或变化的回执能够修正发送成功和利润结果。
|
||||
- API 启动后自动补生成最近四个完整自然日,并按日执行滚动刷新;报表查询支持服务端日期、企业、应用、通道和维度过滤及分页。
|
||||
- “报表对账”增加“发送质量报表”,包含企业应用、通道、签名、引流信息四个 Tab。每个 Tab 按发送日期和对应维度展示发送条数、成功条数、成功率、平均到达时长,并默认按发送条数从大到小排序。
|
||||
@@ -1495,6 +1495,7 @@
|
||||
### 管理端企业应用配置
|
||||
|
||||
- CMPP 与 HTTP 是两套可独立开通的接入能力,不再把 HTTP 作为 `interfaceType` 的互斥选项。运营端在企业应用“接口配置”中维护 HTTP 总开关,以及单条发送、短信状态查询、回执 Webhook、上行 Webhook、上行查询、客户端凭据自助管理等子能力。
|
||||
- 企业应用新增/编辑页必须将 CMPP 与 HTTP 配置拆成两个视觉和语义独立的区域:CMPP 区只放协议、账号、扩展码、客户接入号、接口密码、连接数、CMPP 白名单及下游重试;HTTP 区只放 HTTP 子能力、HTTP 白名单、QPS、凭据限制、投递模式和 Webhook 策略。协议关闭时收起该协议参数,只保留独立开关和关闭说明,不得再把两套字段混排在同一表单网格中。
|
||||
- HTTP 配置独立维护 IP/CIDR 白名单、应用级 QPS、签名时间容差、最多有效凭据数、上行保留/查询范围/分页上限、Webhook 超时和最多尝试次数、生产 HTTPS 约束、客户手工重投权限。
|
||||
- 回执和上行分别配置 `cmpp/http/both/none` 投递模式。Gateway 产生的回执或上行必须先写入现有真实短信记录,再按模式投递;HTTP 回调不得取代或伪造 Gateway、回执匹配和上行认领链路。
|
||||
- HTTP 访问密钥和 Webhook 签名密钥使用 `HTTP_API_MASTER_KEY` 派生的 AES-256-GCM 密钥加密保存。Secret 只在创建或轮换当次返回,后续运营端和客户端仅显示末四位;允许同时保留多个有效凭据以完成无停机轮换。
|
||||
@@ -1515,3 +1516,30 @@
|
||||
- Webhook 禁止重定向,并在保存和每次投递前解析域名,拒绝环回、私网、链路本地、共享地址和元数据地址。2xx 成功;网络错误、408、429、5xx 可按立即、1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时重试;其他 4xx 直接终结。
|
||||
- PostgreSQL 分别保存 Webhook 事件、投递状态和每次尝试摘要;客户和运营人员可查询,授权后可手工重投。首次投递与重试均由 BullMQ 执行,不得使用浏览器定时器或 localStorage 冒充。
|
||||
- 客户端“短信基础配置”新增“接口对接”,包含接口概览、访问凭据、回调配置、接口文档、调用与回调记录五个页签;企业应用卡片显示 HTTP 开通状态并跳转。客户端上行列表改为真实服务端条件查询,不再先拉全量数据后仅在浏览器过滤。
|
||||
|
||||
## 2026-07-16 运营端与客户端移动端适配要求
|
||||
|
||||
1. 运营端和客户端在宽度不大于 780px 的小屏设备上统一使用顶部栏加左侧抽屉导航。抽屉默认关闭,由顶部菜单按钮打开,支持遮罩、关闭按钮、Esc 和选择菜单后关闭;菜单内容在抽屉内部独立滚动,业务内容不得被完整侧栏挤到页面下方。
|
||||
2. 320px、360px、375px、390px 和 768px 常见视口不得出现页面级横向滚动。登录面板、筛选条件、表单、统计卡、操作区和弹窗必须限制在可用宽度内,桌面端既有可折叠侧栏行为保持不变。
|
||||
3. 通用数据表格在小屏下改为带字段名称的纵向记录卡片,操作按钮允许换行;不得要求用户横向滚动才能看到状态、失败原因或操作。业务专用的签名、引流、通道报备列表也必须按同一原则重排。
|
||||
4. 多列查询条件和报表筛选在小屏下收敛为单列;相关查询、重置和导出按钮保持可见并可换行。通道组配置、手机号段库、HTTP 接口凭据、企业签名报备目标等固定宽度区域必须取消页面级最小宽度。
|
||||
5. 移动端顶部栏至少保留导航入口、平台标识、通知和用户菜单;交互控件应具备可读的无障碍名称,抽屉打开状态使用 `aria-expanded` 表达,并尊重系统“减少动态效果”设置。
|
||||
6. 客户端彩信签名、彩信模板、彩信发送、彩信任务、彩信详情和上行彩信均未完成真实后端闭环,在功能完成前不得展示“彩信服务”菜单或其子菜单;保留内部路由不代表可向客户开放。
|
||||
7. 运营端手机号段库使用平台通用 Breadcrumb、Button、Input、Tabs、Table、Tag、Pagination 和 Modal 实现。Tab 位于标题下方和筛选条件上方;当前 Tab 仅显示自身的真实总数。统计使用紧凑信息带,手机号段突出显示、运营商使用语义标签、删除使用克制的危险操作样式,不得另造一套组件或用大面积统计卡挤压表格。
|
||||
|
||||
## 2026-07-16 全平台金额精度要求
|
||||
|
||||
1. 企业应用客户单价、通道成本单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数,页面及导出文件统一展示 4 位小数。
|
||||
2. 数据库和计费链路继续使用整数运算,最小金额单位统一为 `0.0001 元`,即 `1 元 = 10000 金额单位`。历史字段名中的 `Cents` 为兼容既有 API 暂不改名,但其数值语义同步调整为金额单位,不再表示人民币“分”。
|
||||
3. PostgreSQL 金额列统一升级为 `BIGINT`。上线迁移时既有按分保存的数据乘以 100,应用换算除数由 100 改为 10000,确保迁移前后实际人民币金额完全一致。
|
||||
4. 企业应用单价修改必须写入真实 `SmsApplication.customerUnitPrice`,例如 `0.0325 元/条` 保存为 `325`;后续预估、冻结、扣费、返还和利润统计均使用该整数值,不得在前端或后端再次四舍五入到分。
|
||||
5. API 返回 `BIGINT` 金额时仅在 JavaScript 安全整数范围内转换为 JSON number;超过安全整数范围必须显式报错,避免静默丢失金额精度。
|
||||
|
||||
## 2026-07-16 企业应用接口参数复制与下游接入约束
|
||||
|
||||
1. 运营端企业应用列表同时提供 CMPP 参数和 HTTP 参数复制;客户端应用列表提供 CMPP 参数复制,客户端“接口对接”页提供 HTTP 参数复制。复制内容必须来自真实应用和 HTTP 配置 API,不得用静态数组、localStorage 或页面默认值冒充。
|
||||
2. 客户端仅在应用已开通对应协议时允许复制参数。未开通 CMPP 时按钮不可操作,且客户端直接请求 CMPP 参数 API 必须返回 403;未开通 HTTP 时同样不得复制 HTTP 参数。
|
||||
3. 客户侧 CMPP 网关地址和端口是平台对外公布的下游接入地址,分别由 `CMPP_PUBLIC_HOST`、`CMPP_PUBLIC_PORT` 配置,不得读取任一上游短信通道的网关地址。生产默认值为 `8.160.169.106:17890`。
|
||||
4. 参数复制必须兼容平台当前 HTTP 页面:优先使用 Clipboard API;浏览器因非安全上下文或权限拒绝时,使用受控 textarea 复制降级,并向用户明确反馈成功或失败,不得无提示失败。
|
||||
5. `cmppMaxConnections` 必须在 Gateway 登录时按应用和活动 TCP 会话真实计数并限制,同时由 API 的连接事件校验兜底。连接关闭或异常断开后必须及时释放连接名额并回写断开事件。
|
||||
6. CMPP IP/CIDR 白名单必须在登录和连接事件中校验;运营端修改白名单、关闭接口、停用应用或降低最大连接数后,Gateway 应在下一次心跳校验时关闭不再符合条件的存量连接,不能只限制后续 Submit。
|
||||
|
||||
@@ -50,6 +50,8 @@ REPORT_DAILY_REFRESH_ENABLED=true
|
||||
REPORT_REFRESH_INTERVAL_MS=3600000
|
||||
CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30
|
||||
GATEWAY_CMPP_ADDR=0.0.0.0:17890
|
||||
CMPP_PUBLIC_HOST=8.160.169.106
|
||||
CMPP_PUBLIC_PORT=17890
|
||||
GATEWAY_STARTUP_RECONNECT_DELAY_MS=1000
|
||||
OBJECT_STORAGE_DRIVER=minio
|
||||
OBJECT_STORAGE_LOCAL_ROOT=/var/lib/cmpp-platform/object-storage
|
||||
|
||||
@@ -639,7 +639,7 @@
|
||||
- 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。
|
||||
- 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`interfaceEnabled`、`interfaceType=cmpp20`、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections` 和通道组绑定;企业应用表单不展示或提交客户侧 `cmppWindowSize`。
|
||||
- “短信接口”开关刷新后仍来自真实数据库;关闭后该应用不能通过客户端/API 发送,也不能通过 Gateway bind/login 或 submit。
|
||||
- “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。
|
||||
- CMPP 协议当前只能选择 CMPP2.0;HTTP 使用独立配置区、总开关和子能力,不与 CMPP `interfaceType` 互斥;手工提交 `interfaceType=http` 时后端仍返回 400。
|
||||
- `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。
|
||||
- `cmppEnterpriseCode` 来自应用自身配置,不透传上游通道企业代码;`passwordCipher` 为 16 位,编辑留空不覆盖原密码。
|
||||
- 填充开关关闭时不渲染填充前缀输入框;开启后前缀才显示并可编辑。前后端只接受数字扩展码/前缀,客户侧接入号为二者拼接且不超过 21 位;关闭填充后前缀清空。
|
||||
@@ -3378,6 +3378,20 @@ npm run verify:phase8
|
||||
| TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 |
|
||||
| TC-CMPP-STATUS-017 | 新建/启用通道后 Gateway 未回写,连接状态停留 connecting 超过 30 秒。 | API 兜底任务将连接标记为 failed,currentConnections=0,lastError 为 `Gateway connection request timed out after 30 seconds`;连接日志包含 connect_timeout;发送路由不可选择该通道。 |
|
||||
|
||||
### 17.8.1 运营端与客户端移动端适配
|
||||
|
||||
| 用例编号 | 操作 | 预期结果 |
|
||||
| --- | --- | --- |
|
||||
| TC-RESP-001 | 分别以运营管理员和企业管理员登录,在 390×844 视口打开首页。 | 左侧栏默认不占据业务内容空间;顶部显示菜单、平台标识、通知和用户入口;页面 `scrollWidth` 不大于视口宽度。 |
|
||||
| TC-RESP-002 | 点击顶部菜单,滚动长菜单,随后点击任一业务菜单;重复打开后点击遮罩、关闭按钮和按 Esc。 | 抽屉覆盖在业务内容上方并带遮罩,菜单内部可滚动;四种关闭方式均有效,路由切换后抽屉自动收起。 |
|
||||
| TC-RESP-003 | 在 320、360、375、390 和 768px 宽度打开两端登录页。 | 登录面板、账号、密码、验证码和登录按钮完整处于视口内,不出现页面级横向滚动。 |
|
||||
| TC-RESP-004 | 在小屏打开客户端签名与引流信息、账户账单、短信记录和模板列表。 | 通用表格或业务列表以带字段标签的纵向卡片呈现,长文本可换行,状态和操作无需横向滚动即可查看。 |
|
||||
| TC-RESP-005 | 在小屏打开运营端发送质量、对账、利润报表、企业查询、审核日志和手机号段库。 | 多列筛选收敛为单列或紧凑按钮组;查询、重置、导出均可操作;页面无固定宽度导致的横向溢出。 |
|
||||
| TC-RESP-006 | 在小屏打开通道报备详情、通道组新增/编辑、企业签名管理和 HTTP 接口凭据。 | 报备行按卡片重排,通道组表单与报备目标单列显示,凭据和密钥可换行,操作区可换行且不超出视口。 |
|
||||
| TC-RESP-007 | 在 1280px 及以上桌面视口复核运营端和客户端。 | 保持原桌面侧栏及折叠按钮;筛选和表格仍按桌面布局展示,不因移动端规则产生回归。 |
|
||||
| TC-RESP-008 | 登录客户端,在桌面侧栏和移动端抽屉中检查全部菜单。 | 不展示“彩信服务”分组,也不展示彩信签名、模板、发送、任务、详情或上行彩信入口;短信和账户等已完成功能菜单正常显示。 |
|
||||
| TC-RESP-009 | 在桌面和 390px 小屏打开手机号段库,切换“手机号段/运营商区分规则”,执行关键词查询和重置。 | 页面使用系统通用控件;Tab 位于标题与筛选之间;仅展示当前 Tab 的真实总数;表格/移动卡片无横向溢出,查询和重置继续调用真实 API 查询状态。 |
|
||||
|
||||
### 17.9 自动化落地建议
|
||||
|
||||
| 层级 | 建议覆盖 |
|
||||
@@ -3476,9 +3490,26 @@ npm run verify:phase8
|
||||
| 用例编号 | 操作 | 预期结果 |
|
||||
| --- | --- | --- |
|
||||
| TC-HTTP-CONFIG-001 | 运营端编辑企业应用,独立开关 CMPP 与 HTTP,并配置发送/查询/回调子能力、独立 IP 白名单、QPS、投递模式和 Webhook 策略,保存后刷新。 | 配置写入 `SmsApplicationHttpConfig` 和 HTTP 白名单表;CMPP 原配置不丢失;刷新一致;关闭某项能力后对应 OpenAPI 返回稳定 403 业务码。 |
|
||||
| TC-HTTP-CONFIG-002 | 打开运营端企业应用新增/编辑页,分别开关 CMPP 与 HTTP,并在桌面及 390px 小屏检查两块配置。 | CMPP 与 HTTP 以两个独立区块展示;CMPP 区不出现 HTTP 参数,HTTP 区不出现 CMPP 账号、密码或白名单;关闭某协议后仅收起该协议参数且不影响另一协议区域;小屏无页面级横向滚动。 |
|
||||
| TC-HTTP-AUTH-001 | 使用正确 Access Key/Secret 按原始请求体签名,再分别修改 path、body、timestamp、nonce、来源 IP 和签名。 | 正确请求通过;篡改项返回 `application/problem+json`;过期时间、重复 nonce、白名单外 IP 和错误签名被拒绝;错误签名不得提前占用 nonce。 |
|
||||
| TC-HTTP-AUTH-002 | 同一应用一秒内并发调用超过配置 QPS,再在下一秒继续调用。 | Redis 应用级额度不被多个凭据放大;超额返回 429,下一秒恢复;不依赖单进程内存计数。 |
|
||||
| TC-HTTP-CREDENTIAL-001 | 客户创建第一把凭据、保存 Secret,再创建第二把完成切换并吊销第一把;刷新页面和查看数据库。 | Secret 仅创建当次可见,数据库为 AES-256-GCM 密文;列表只显示末四位;两把凭据轮换期可并存,吊销后旧凭据立即返回 401。 |
|
||||
|
||||
### 17.11 全平台金额四位小数精度
|
||||
|
||||
| 用例编号 | 操作 | 预期结果 |
|
||||
| --- | --- | --- |
|
||||
| TC-MONEY-001 | 运营端编辑企业应用,将客户单价填写为 `0.0325` 并保存,刷新列表后再次进入编辑页。 | 保存调用真实 NestJS API;数据库 `SmsApplication.customerUnitPrice=325`;列表和编辑页均展示 `0.0325`,不被舍入为 `0.03`。 |
|
||||
| TC-MONEY-002 | 使用单价 `0.0325` 的应用发送 2 个计费条数。 | 预估、冻结及最终计费金额均为 `650` 金额单位,即 `0.0650 元`;返还时按相同精度原额冲回。 |
|
||||
| TC-MONEY-003 | 分别设置余额、授信、充值、今日消费和今日返还为含 4 位小数的金额,查看运营端企业列表、详情、首页以及客户端首页和账单。 | 所有位置展示同一真实金额且固定为 4 位小数,不使用浮点累计或仅保留到分。 |
|
||||
| TC-MONEY-004 | 打开短信详单、对账单、利润报表并导出 CSV。 | 消费金额、成本金额和利润均按 4 位小数显示;CSV 表头以“元”为单位,值固定 4 位小数,汇总结果与数据库整数金额单位一致。 |
|
||||
| TC-MONEY-005 | 在迁移前备份数据库并记录各金额列汇总,执行四位精度迁移后复核字段类型及汇总。 | 金额列升级为 `BIGINT`;迁移后整数汇总等于迁移前的 100 倍,按新除数换算后的人民币金额完全相等。 |
|
||||
| TC-MONEY-006 | 在单价、授信和充值输入中分别填写超过 4 位小数、非法字符和超出 JavaScript 安全整数范围的值。 | 前后端拒绝无效值并返回可读错误;API 不静默舍入或输出已失真的金额。 |
|
||||
| TC-IF-PARAM-001 | 运营端分别打开已开通 CMPP、HTTP 的企业应用参数弹窗并一键复制;模拟 Clipboard API 在 HTTP 页面被拒绝。 | CMPP 内容展示平台公网地址和端口而非上游通道地址;HTTP 内容包含应用、能力、QPS、白名单、投递模式和文档地址;降级复制成功且有明确提示。 |
|
||||
| TC-IF-PARAM-002 | 客户端查看未开通 CMPP 的应用并直接调用该应用 CMPP 参数 API。 | 页面复制按钮禁用;真实 API 返回 403,不泄露账号、密码、接入号等参数。 |
|
||||
| TC-IF-PARAM-003 | 客户端在已开通 HTTP 的应用“接口对接”页复制 HTTP 参数,再关闭 HTTP 后复测。 | 开通时复制真实 HTTP 配置;关闭时按钮不可用且不生成参数文本。 |
|
||||
| TC-CMPP-DOWNSTREAM-001 | 将应用最大连接数设为 2,依次建立 3 条 CMPP TCP 连接,断开其中一条后再次连接。 | 前 2 条 bind 成功,第 3 条被拒绝;断开后名额立即释放,新连接可成功;连接记录与真实 TCP 会话一致。 |
|
||||
| TC-CMPP-DOWNSTREAM-002 | 已建立连接后修改应用 IP/CIDR 白名单为不包含当前来源 IP,或将最大连接数降至当前连接数以下,等待下一次心跳。 | API 拒绝连接事件,Gateway 主动关闭不符合配置的存量连接并回写断开原因;连接数不继续显示为正常。 |
|
||||
| TC-HTTP-SEND-001 | 调用单条发送接口,使用真实已审核签名/模板、余额和通道路由。 | 返回 202 和平台 messageId;真实创建 API 来源批次与短信记录,执行风控、冻结/计费并进入 Redis/BullMQ/Gateway 链路;不得使用静态数组或直接伪造 delivered。 |
|
||||
| TC-HTTP-IDEMPOTENCY-001 | 并发使用相同 `Idempotency-Key` 和相同 body 调用,再用相同 key 改变 body;另重复 clientMessageId。 | 只创建一条真实短信;完成后同内容重放原响应,处理中返回 409 processing;不同 body 返回 409 conflict;应用内重复 clientMessageId 被拒绝。 |
|
||||
| TC-HTTP-QUERY-001 | 用本应用凭据按 messageId/clientMessageId 查询本应用和其他应用短信。 | 只返回当前应用短信状态、提交/回执时间和失败信息;其他应用记录统一 404,不泄露租户数据。 |
|
||||
|
||||
@@ -1969,3 +1969,38 @@ git diff --check
|
||||
- 发布包本地与服务器 SHA-256 均为 `b49b51ef1acd2a0bb709ca91fbe0b98c16c80bc86f24b35b5dba141907cc7116`;生产原缺失的 `HTTP_API_MASTER_KEY` 已生成并以 `600` 权限保存,密钥内容未输出。migration `20260716110000_add_http_open_api` 已应用,52 条 migration 全部齐全且 schema 最新;生产运行提交为 `dcb6162dcf93a70e9886c978b07fdccee19dd657`。
|
||||
- `cmpp-api`、`cmpp-gateway`、MinIO、Nginx、PostgreSQL 和 Redis 正常,`12026/17890/8090/3000/9000` 监听,API/Gateway health、Redis PONG、PostgreSQL readiness 和外部首页、运营端、客户端、客户 Swagger 文档均通过。两个 Redis 通道权威 TPS key 均恢复为真实值 100,`gateway.submit.commands` consumer group 为 `pending=0、lag=0`,数据库连接状态有 3 条 connected,部署后 10 分钟内 API/Gateway 无 error 级日志。
|
||||
- 客户文档 JSON 仅包含约定的 4 个路径:单条发送、短信状态查询、上行列表和上行详情;未鉴权访问真实 HTTP 接口返回 401。Browser 插件不可用,使用现有 Playwright/Chromium 复核生产运营登录、客户端鉴权跳转和 Swagger 文档:页面非空、标题/表单/4 个接口正常,输入控件交互成功,无框架错误覆盖或 console error/warn;未重置账号、未绕过验证码、未发送真实短信。
|
||||
|
||||
## 2026-07-16 运营端与客户端 P1/P2 移动端适配(本地未提交)
|
||||
|
||||
- AppShell 在不大于 780px 的视口改为 56px 顶部栏与覆盖式左侧抽屉,抽屉默认关闭,支持遮罩、关闭按钮、Esc 和路由切换后自动收起;运营端长菜单和客户端菜单在抽屉内部独立滚动,不再作为 260px 高的页面顶部区域挤压业务内容。桌面折叠侧栏保持原行为。
|
||||
- 修复两端登录面板宽度计算;运营端报表、企业查询和审核筛选、手机号段库、通道组、通道报备详情、企业签名报备目标、客户端 HTTP 凭据等多列/固定宽度区域在小屏下改为单列或可换行布局。
|
||||
- 通用 `Table` 为每个单元格输出字段标签,小屏统一转为纵向记录卡片;客户端签名和引流信息列表、通道报备业务列表补专用卡片规则。状态、失败原因、密钥和操作按钮无需依赖横向滚动查看。
|
||||
- 应用内浏览器使用本地真实 NestJS API/PostgreSQL 的既有客户端会话在 390×844 视口验证:关闭态 `document.scrollWidth=390`、侧栏完全移出视口;打开态抽屉约占 82% 宽度且菜单独立滚动;点击“签名与引流信息”后路由切换并自动关闭。签名列表由原 1050px 横向内容收敛为 343px 卡片,账户账单表格容器和表格均为 293px 且无内部横向滚动;客户端登录页正文和文档宽度均为 390px,登录面板边界为 16~374px。未注入 mock、未绕过认证、未写业务数据。
|
||||
- 前端 TypeScript/Vite 生产构建、API build 和 `git diff --check` 均已通过;前端仅有既有 Vite chunk size warning。桌面规则位于 780px 媒体查询之外,既有侧栏折叠和桌面表格结构保持不变;按要求暂不提交、不 push、不部署。
|
||||
- 客户端导航隐藏尚未完成真实后端闭环的整个“彩信服务”分组及六个彩信子菜单;内部占位路由暂时保留,避免把未完成能力暴露给客户。按本轮要求继续保持工作区未提交、未部署。
|
||||
- 运营端手机号段库按确认设计重做:复用平台通用 Breadcrumb、Button、Input、Tabs、Table、Tag、Pagination 和 Modal;标题区增加用途说明,Tab 下使用当前数据类型的紧凑真实总数信息带,搜索工具栏与数据表形成单一工作区,手机号段使用等宽强调,运营商使用语义标签,删除改为克制的文本危险操作。未增加批量导入、额外筛选或虚构覆盖统计。
|
||||
- 应用内浏览器使用真实本地运营管理员会话和 NestJS API 验收手机号段库:1536×1024 下页面与表格 `scrollWidth=clientWidth=1230`,1280px 下均为 959px,无横向滚动;390×844 下文档、面板、Tab、统计、筛选和表格容器均未超出 390px。已验证 Tab 切换同步“新增号段/新增规则”和当前统计,新增规则 Modal 可打开/关闭,关键词 `138` 查询后输入值保留、重置后清空,console 无 error/warn。设计图中的示例总数、31 省覆盖说明和示例行未照搬,页面只展示本地 PostgreSQL 的真实 0 条数据;分页和标题采用平台通用组件样式。
|
||||
|
||||
## 2026-07-16 企业应用 CMPP/HTTP 配置区域拆分(本地未提交)
|
||||
|
||||
- 运营端企业应用新增/编辑页将原来混排的“接口配置”拆成独立 CMPP 接入配置和 HTTP 接口配置区块。CMPP 区集中账号、扩展码、接入号、密码、连接数、CMPP 白名单和下游重试;HTTP 区集中能力、HTTP 白名单、QPS、凭据限制、投递方式及 Webhook 安全重试,保存仍沿用现有真实 NestJS API 字段。
|
||||
- 两个协议使用独立开关和视觉标识;关闭时只收起本协议参数并展示关闭说明,不改变另一协议的开关或表单状态。HTTP 能力静态定义移出组件渲染,避免每次渲染重复创建配置数组。
|
||||
- Browser 插件不可用,使用已有 Playwright/Chromium、真实本地 NestJS API、PostgreSQL、Redis 和临时平台管理员完成 1440×1000 验收:HTTP 从关闭切换为开启后只展开 HTTP 参数;CMPP 关闭后账号等字段消失但 HTTP QPS 保持可见;页面标题、DOM、控制台均正常。390×844 复核 `body.scrollWidth=viewportWidth=390`,无页面级横向滚动。临时管理员、登录操作日志和会话均已清理,未创建企业应用、未发送短信。
|
||||
- 使用 Node.js 24 执行前端 TypeScript/Vite 生产构建通过,仅有既有 chunk size warning;默认 Node.js 14 不支持当前 Vite 的 `??=` 语法且会错误返回退出码 0,因此未将旧 Node 结果计为有效构建。按要求暂不提交、不 push、不部署。
|
||||
|
||||
## 2026-07-16 全平台金额四位小数精度(本地未提交)
|
||||
|
||||
- 根因确认:企业应用编辑页原先按 `Math.round(元 × 100)` 保存,`0.0325 元`只能落为 3 分;PostgreSQL 的余额、流水、单价、计费和利润字段也均为 `Int` 分,无法表达万分之一元。现统一调整为 `1 元 = 10000 金额单位`,字段名中的 `Cents` 仅为兼容既有 API 保留。
|
||||
- Prisma 金额列统一升级为 `BigInt`,migration `20260716150000_expand_money_precision_to_four_decimals` 将历史整数分乘以 100。迁移前已备份本地真实 PostgreSQL;抽查 `AccountTransaction、SmsApplication、SmsMessageRecord、TenantAccount` 汇总,迁移后整数值均精确为迁移前 100 倍,按新除数换算后的人民币金额不变。53 条 migration 已全部应用,Prisma schema validate 和 migrate status 均通过。
|
||||
- 企业应用客户价、通道成本价、授信和人工充值输入均允许最多 4 位小数并转换为整数金额单位;运营端与客户端的余额、授信、今日消费、今日返还、充值、短信详单、客户价、通道价和利润报表统一固定展示 4 位小数。利润 CSV 改为以“元”为表头并导出 4 位小数。
|
||||
- NestJS 对客户价、通道价、充值、授信、计费规则和计费结果增加安全整数校验;Prisma `BigInt` 响应仅在 JavaScript 安全整数范围内序列化为 number,超限直接报错,避免静默精度损失。计费单测新增 `325 × 2 = 650` 金额单位,企业应用更新单测使用 `customerUnitPrice=325`。
|
||||
- 使用真实本地 NestJS API、PostgreSQL、Redis 和 Playwright/Chromium 编辑一条已配置通道组的企业应用:页面填写 `0.0325` 后保存,数据库核对 `SmsApplication.customerUnitPrice=325`,再次进入编辑页仍为 `0.0325`,控制台无 error;随后已恢复原单价并清理临时管理员、角色关联和操作日志。
|
||||
- API 全量 20 个 Jest 测试套件通过,其中金额与企业应用目标套件 45 条用例通过;API TypeScript build、前端 TypeScript/Vite 生产 build、Gateway `go test ./...`、Prisma validate/status 和 `git diff --check` 均通过。按要求暂不提交、不 push、不部署。
|
||||
|
||||
## 2026-07-16 企业应用参数复制与下游 CMPP 约束修复(本地未提交)
|
||||
|
||||
- 根因确认:运营端 CMPP 参数 API 原先误取最新上游 `SmsChannel.gatewayHost/gatewayPort`,不是客户接入平台的地址;页面直接调用 `navigator.clipboard.writeText`,在生产 HTTP 非安全上下文可能无提示失败。现改为读取 `CMPP_PUBLIC_HOST/CMPP_PUBLIC_PORT`,并增加 Clipboard API 失败后的 textarea 降级复制和错误提示。
|
||||
- 运营端企业应用列表补充 HTTP 参数查看/复制;客户端接口对接页补充 HTTP 参数复制。客户端未开通 CMPP 时按钮禁用,客户端 CMPP 参数 API 同时返回 403,避免仅靠前端隐藏后仍可读取密码等接入参数。
|
||||
- Gateway 登录响应接入真实 `cmppMaxConnections`,按应用活动 TCP 会话计数并拒绝超限 bind;底层连接关闭回调会清理会话和回写断开。API 连接事件再次校验应用状态、接口开关、IP/CIDR 白名单和连接数,存量连接在下一次心跳不再符合配置时由 Gateway 主动关闭。
|
||||
- 生产只读核查发现应用 `715011` 最大连接数为 1,当前来源 IP 为 `183.194.97.158`,白名单后来改为 `2.2.2.2`;连接建立早于白名单修改,印证旧实现不会主动清理存量连接。核查期间未修改生产配置、数据库或进程。
|
||||
- API 目标测试 2 个套件 97 条以及全量 20 个套件 213 条断言通过,API TypeScript build、Gateway `go test ./...`、前端 TypeScript/Vite build 和部署脚本语法检查通过;全量 Jest 完成后仍提示既有异步句柄未关闭,断言结果不受影响,测试进程已单独结束。使用真实本地 NestJS API、PostgreSQL 和 Playwright/Chromium 验证运营端两类参数复制、客户端未开通 CMPP 的页面禁用与 API 403,以及 HTTP 页面复制降级;临时数据已清理。按要求暂不提交、不 push、不部署。
|
||||
|
||||
@@ -64,6 +64,7 @@ type authResponse struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
Account string `json:"account"`
|
||||
EnterpriseCode string `json:"enterpriseCode"`
|
||||
MaxConnections int `json:"maxConnections"`
|
||||
}
|
||||
|
||||
type DownstreamReceipt struct {
|
||||
@@ -179,7 +180,7 @@ func (s Server) ListenAndServe() error {
|
||||
s.logRecoveryCandidates(log.Default())
|
||||
go s.recoverPendingCandidates(log.Default())
|
||||
go s.runPendingFlusher(log.Default())
|
||||
return cmpp.ListenAndServe(addr, cmpp.V30, 30*time.Second, 3, s.LogWriter,
|
||||
return cmpp.ListenAndServeWithClose(addr, cmpp.V30, 30*time.Second, 3, s.LogWriter, s.handleConnectionClosed,
|
||||
cmpp.HandlerFunc(s.handleLogin),
|
||||
cmpp.HandlerFunc(s.handleSubmit),
|
||||
cmpp.HandlerFunc(s.handleActivity),
|
||||
@@ -206,9 +207,8 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
|
||||
setInboundConnectResponse(response.Packer, cmpp.ErrnoConnAuthFailed, req.AuthSrc, "", req.Version)
|
||||
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed]
|
||||
}
|
||||
setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version)
|
||||
now := time.Now().UTC()
|
||||
session := downstreamSession{
|
||||
session := &downstreamSession{
|
||||
account: strings.TrimSpace(defaultString(auth.Account, account)),
|
||||
enterpriseCode: strings.TrimSpace(auth.EnterpriseCode),
|
||||
protocol: cmppVersionName(req.Version),
|
||||
@@ -223,8 +223,13 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
|
||||
report: s.reportConnection,
|
||||
deliveryReport: s.reportDownstreamDelivery,
|
||||
}
|
||||
rememberAccount(session)
|
||||
go s.reportConnection(&session, "connected", "")
|
||||
if !rememberAccount(session, auth.MaxConnections) {
|
||||
logger.Printf("cmpp inbound auth failed account=%s remote=%s err=connection limit exceeded max=%d", account, packet.Conn.Conn.RemoteAddr(), auth.MaxConnections)
|
||||
setInboundConnectResponse(response.Packer, cmpp.ErrnoConnOthers, req.AuthSrc, "", req.Version)
|
||||
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnOthers]
|
||||
}
|
||||
setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version)
|
||||
go s.reportConnectionOrDisconnect(session, "connected", "")
|
||||
response.AfterSend = func(sendErr error) {
|
||||
if sendErr == nil {
|
||||
go s.flushPending(defaultString(auth.Account, account), logger)
|
||||
@@ -353,7 +358,7 @@ func (s Server) handleActivity(_ *cmpp.Response, packet *cmpp.Packet, logger *lo
|
||||
switch response := packet.Packer.(type) {
|
||||
case *cmpp.CmppActiveTestReqPkt, *cmpp.CmppActiveTestRspPkt:
|
||||
if session.report != nil {
|
||||
go session.report(session, "heartbeat", "")
|
||||
go s.reportConnectionOrDisconnect(session, "heartbeat", "")
|
||||
}
|
||||
case *cmpp.Cmpp2DeliverRspPkt:
|
||||
handleDownstreamAcknowledgement(packet.Conn, response.SeqId, response.MsgId, uint32(response.Result), logger)
|
||||
@@ -364,8 +369,12 @@ func (s Server) handleActivity(_ *cmpp.Response, packet *cmpp.Packet, logger *lo
|
||||
}
|
||||
|
||||
func (s Server) reportConnection(session *downstreamSession, status string, errorMessage string) {
|
||||
_ = s.reportConnectionChecked(session, status, errorMessage)
|
||||
}
|
||||
|
||||
func (s Server) reportConnectionChecked(session *downstreamSession, status string, errorMessage string) error {
|
||||
if session == nil || strings.TrimSpace(session.account) == "" || strings.TrimSpace(session.connectionID) == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
event := downstreamConnectionEvent{
|
||||
Account: session.account, ConnectionID: session.connectionID, Status: status,
|
||||
@@ -375,7 +384,28 @@ func (s Server) reportConnection(session *downstreamSession, status string, erro
|
||||
}
|
||||
if err := s.post(context.Background(), "/gateway/events/inbound/connection", event, nil); err != nil {
|
||||
log.Printf("cmpp inbound connection state callback failed account=%s connection_id=%s status=%s err=%v", session.account, session.connectionID, status, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Server) reportConnectionOrDisconnect(session *downstreamSession, status string, errorMessage string) {
|
||||
if err := s.reportConnectionChecked(session, status, errorMessage); err != nil && status != "disconnected" {
|
||||
_ = s.reportConnectionChecked(session, "disconnected", err.Error())
|
||||
forgetDownstream(session)
|
||||
if session.conn != nil {
|
||||
session.conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s Server) handleConnectionClosed(conn *cmpp.Conn) {
|
||||
session := findSessionByConn(conn)
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
forgetDownstream(session)
|
||||
_ = s.reportConnectionChecked(session, "disconnected", "CMPP client connection closed")
|
||||
}
|
||||
|
||||
func (s Server) reportDownstreamDelivery(event downstreamDeliveryLifecycleEvent) {
|
||||
@@ -683,15 +713,28 @@ func rememberDownstream(session downstreamSession) {
|
||||
downstreamRegistry.Unlock()
|
||||
}
|
||||
|
||||
func rememberAccount(session downstreamSession) {
|
||||
if session.account == "" || session.conn == nil {
|
||||
return
|
||||
func rememberAccount(session *downstreamSession, maxConnections int) bool {
|
||||
if session == nil || session.account == "" || session.conn == nil {
|
||||
return false
|
||||
}
|
||||
if maxConnections <= 0 {
|
||||
maxConnections = 1
|
||||
}
|
||||
session.touchPresence("connected", false, false)
|
||||
downstreamRegistry.Lock()
|
||||
downstreamRegistry.byAccount[session.account] = &session
|
||||
downstreamRegistry.byConn[session.conn] = &session
|
||||
downstreamRegistry.Unlock()
|
||||
defer downstreamRegistry.Unlock()
|
||||
active := 0
|
||||
for _, current := range downstreamRegistry.byConn {
|
||||
if current != nil && current.account == session.account {
|
||||
active++
|
||||
}
|
||||
}
|
||||
if active >= maxConnections {
|
||||
return false
|
||||
}
|
||||
downstreamRegistry.byAccount[session.account] = session
|
||||
downstreamRegistry.byConn[session.conn] = session
|
||||
return true
|
||||
}
|
||||
|
||||
func forgetDownstream(session *downstreamSession) {
|
||||
|
||||
@@ -659,7 +659,9 @@ func TestRememberAndForgetAccountUpdatesPresenceStore(t *testing.T) {
|
||||
instanceID: "gateway-a",
|
||||
}
|
||||
|
||||
rememberAccount(session)
|
||||
if !rememberAccount(&session, 1) {
|
||||
t.Fatal("expected account session to be accepted")
|
||||
}
|
||||
snapshot, ok := store.snapshots["100001"]
|
||||
if !ok {
|
||||
t.Fatal("expected presence snapshot to be stored")
|
||||
@@ -674,6 +676,25 @@ func TestRememberAndForgetAccountUpdatesPresenceStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberAccountEnforcesConfiguredConnectionLimit(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
first := &downstreamSession{account: "100001", connectionID: "conn-1", conn: &cmpp.Conn{}, mu: &sync.Mutex{}}
|
||||
second := &downstreamSession{account: "100001", connectionID: "conn-2", conn: &cmpp.Conn{}, mu: &sync.Mutex{}}
|
||||
third := &downstreamSession{account: "100001", connectionID: "conn-3", conn: &cmpp.Conn{}, mu: &sync.Mutex{}}
|
||||
if !rememberAccount(first, 2) || !rememberAccount(second, 2) {
|
||||
t.Fatal("expected first two sessions to fit maxConnections=2")
|
||||
}
|
||||
if rememberAccount(third, 2) {
|
||||
t.Fatal("expected third session to be rejected by maxConnections=2")
|
||||
}
|
||||
forgetDownstream(first)
|
||||
if !rememberAccount(third, 2) {
|
||||
t.Fatal("expected a new session after a previous connection is released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
@@ -702,9 +723,12 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
conn := &cmpp.Conn{}
|
||||
rememberAccount(downstreamSession{
|
||||
session := &downstreamSession{
|
||||
account: "100001", protocol: "cmpp20", conn: conn, mu: &sync.Mutex{}, connectionID: "conn-1",
|
||||
})
|
||||
}
|
||||
if !rememberAccount(session, 1) {
|
||||
t.Fatal("expected account session to be accepted")
|
||||
}
|
||||
if session := findReceiptSession("MSG-NOT-REMEMBERED", "100001"); session != nil {
|
||||
t.Fatalf("receipt unexpectedly fell back to account session: %+v", session)
|
||||
}
|
||||
|
||||
Vendored
+14
-2
@@ -79,6 +79,7 @@ type Server struct {
|
||||
// If nil, logging goes to os.Stderr via the log package's
|
||||
// standard logger.
|
||||
ErrorLog *log.Logger
|
||||
OnClose func(*Conn)
|
||||
}
|
||||
|
||||
// A conn represents the server side of a Cmpp connection.
|
||||
@@ -393,7 +394,12 @@ func (c *conn) serve() {
|
||||
}
|
||||
}()
|
||||
|
||||
defer c.close()
|
||||
defer func() {
|
||||
c.close()
|
||||
if c.server.OnClose != nil {
|
||||
c.server.OnClose(c.Conn)
|
||||
}
|
||||
}()
|
||||
|
||||
// start a goroutine for sending active test.
|
||||
startActiveTest(c)
|
||||
@@ -468,6 +474,12 @@ func (srv *Server) listenAndServe() error {
|
||||
// ListenAndServe listens on the TCP network address addr
|
||||
// and then calls Serve with handler to handle requests.
|
||||
func ListenAndServe(addr string, typ Type, t time.Duration, n int32, logWriter io.Writer, handlers ...Handler) error {
|
||||
return ListenAndServeWithClose(addr, typ, t, n, logWriter, nil, handlers...)
|
||||
}
|
||||
|
||||
// ListenAndServeWithClose behaves like ListenAndServe and invokes onClose once
|
||||
// after an accepted client connection ends, including abrupt TCP disconnects.
|
||||
func ListenAndServeWithClose(addr string, typ Type, t time.Duration, n int32, logWriter io.Writer, onClose func(*Conn), handlers ...Handler) error {
|
||||
if addr == "" {
|
||||
return ErrEmptyServerAddr
|
||||
}
|
||||
@@ -492,7 +504,7 @@ func ListenAndServe(addr string, typ Type, t time.Duration, n int32, logWriter i
|
||||
}
|
||||
server := &Server{Addr: addr, Handler: handler, Typ: typ,
|
||||
T: t, N: n,
|
||||
ErrorLog: log.New(logWriter, "cmppserver: ", log.LstdFlags)}
|
||||
ErrorLog: log.New(logWriter, "cmppserver: ", log.LstdFlags), OnClose: onClose}
|
||||
return server.listenAndServe()
|
||||
}
|
||||
|
||||
|
||||
@@ -318,6 +318,7 @@ export type ClientSmsApplication = {
|
||||
sentToday?: number;
|
||||
deliveryRate?: number;
|
||||
cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive';
|
||||
interfaceEnabled?: boolean | null;
|
||||
cmppConnections?: CmppDownstreamConnection[];
|
||||
httpConfig?: HttpApiConfig | null;
|
||||
};
|
||||
@@ -919,6 +920,7 @@ export type EnterpriseApplication = {
|
||||
cmppMaxConnections?: number | null;
|
||||
cmppWindowSize?: number | null;
|
||||
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
|
||||
httpConfig?: HttpApiConfig | null;
|
||||
tenant?: TenantOption;
|
||||
sentToday?: number;
|
||||
deliveryRate?: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||
@@ -217,7 +218,8 @@ function ChannelFormModal({
|
||||
const channel = modal.channel;
|
||||
const [name, setName] = useState(channel?.name ?? '');
|
||||
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
||||
const [unitPrice, setUnitPrice] = useState(channel ? String(channel.unitPrice / 100) : '0.0300');
|
||||
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
|
||||
const [unitPriceError, setUnitPriceError] = useState('');
|
||||
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
|
||||
const [protocol, setProtocol] = useState('CMPP');
|
||||
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
||||
@@ -233,12 +235,16 @@ function ChannelFormModal({
|
||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||
|
||||
function submit() {
|
||||
if (!isValidMoneyInput(unitPrice)) {
|
||||
setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位');
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
|
||||
name: name || '新建短信通道',
|
||||
carrier,
|
||||
sendRegion: region,
|
||||
unitPrice: Number(unitPrice || 0) * 100,
|
||||
unitPrice: yuanToMoneyUnits(unitPrice),
|
||||
status: channel?.status ?? 'connecting',
|
||||
total: channel?.total ?? 0,
|
||||
successRate: channel?.successRate ?? 0,
|
||||
@@ -288,7 +294,7 @@ function ChannelFormModal({
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Input label="* 单价(元)" onChange={(event) => setUnitPrice(event.target.value)} value={unitPrice} />
|
||||
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} />
|
||||
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
|
||||
</div>
|
||||
</section>
|
||||
@@ -621,7 +627,7 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
<div className="sms-channel-carrier-price">
|
||||
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
|
||||
<strong>{channel.unitPrice.toFixed(2)} 分</strong>
|
||||
<strong>{formatCents(channel.unitPrice)} 元</strong>
|
||||
</div>
|
||||
<div className="sms-channel-status-cell">
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type EnterpriseForm = {
|
||||
name: string;
|
||||
@@ -63,7 +64,7 @@ function formFromTenant(tenant: TenantOption, creditCents = 0): EnterpriseForm {
|
||||
return {
|
||||
name: tenant.name,
|
||||
creditCode: profile?.creditCode ?? '',
|
||||
creditLimit: String(creditCents / 100),
|
||||
creditLimit: moneyUnitsToYuan(creditCents).toFixed(4),
|
||||
province: profile?.province ?? '',
|
||||
city: profile?.city ?? '',
|
||||
address: profile?.address ?? '',
|
||||
@@ -121,7 +122,7 @@ export function AdminCustomerFormPage() {
|
||||
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
|
||||
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
|
||||
const creditLimit = Number(form.creditLimit);
|
||||
if (!Number.isFinite(creditLimit)) nextErrors.creditLimit = '请填写有效的授信额度';
|
||||
if (!Number.isFinite(creditLimit) || !isValidMoneyInput(form.creditLimit, { allowNegative: true })) nextErrors.creditLimit = '授信额度最多支持小数点后 4 位';
|
||||
setErrors(nextErrors);
|
||||
return Object.keys(nextErrors).length === 0;
|
||||
}
|
||||
@@ -135,7 +136,7 @@ export function AdminCustomerFormPage() {
|
||||
? await adminApi.updateTenant(enterpriseId, payload)
|
||||
: await adminApi.createTenant(payload);
|
||||
await adminApi.updateCreditLimit(tenant.id, {
|
||||
creditCents: Math.round(Number(creditLimit) * 100),
|
||||
creditCents: yuanToMoneyUnits(creditLimit),
|
||||
remark: isEdit ? '企业编辑页调整授信额度' : '创建企业初始化授信额度',
|
||||
});
|
||||
navigate('/admin/customers');
|
||||
@@ -223,6 +224,7 @@ export function AdminCustomerFormPage() {
|
||||
label="授信额度(元)"
|
||||
onChange={(event) => updateForm('creditLimit', event.target.value)}
|
||||
required
|
||||
step="0.0001"
|
||||
type="number"
|
||||
value={form.creditLimit}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type AdminCustomersPageProps = {
|
||||
basePath?: string;
|
||||
@@ -120,7 +120,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
async function submitRecharge() {
|
||||
if (!rechargeTarget) return;
|
||||
const amount = Number(rechargeForm.amount);
|
||||
if (!Number.isFinite(amount) || amount === 0) {
|
||||
if (!Number.isFinite(amount) || !isValidMoneyInput(rechargeForm.amount, { allowNegative: true, allowZero: false })) {
|
||||
setRechargeError('请填写非 0 的充值金额,支持负数冲正');
|
||||
return;
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
try {
|
||||
await adminApi.createManualRecharge({
|
||||
tenantId: rechargeTarget.id,
|
||||
amountCents: Math.round(amount * 100),
|
||||
amountCents: yuanToMoneyUnits(rechargeForm.amount),
|
||||
remark: rechargeForm.remark,
|
||||
});
|
||||
setRechargeTarget(null);
|
||||
@@ -207,7 +207,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
<div className="admin-system-modal-form">
|
||||
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
||||
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
|
||||
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
|
||||
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
||||
</div>
|
||||
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
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 CmppDownstreamConnection, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
import { formatHttpApiParams } from '@/utils/interfaceParams';
|
||||
|
||||
type SmsApp = {
|
||||
id: string;
|
||||
@@ -18,6 +21,7 @@ type SmsApp = {
|
||||
cmppStatus: 'connected' | 'disconnected' | 'inactive';
|
||||
cmppConnections: CmppConnection[];
|
||||
cmppParams: CmppParams;
|
||||
httpEnabled: boolean;
|
||||
};
|
||||
|
||||
type CmppParams = {
|
||||
@@ -147,6 +151,7 @@ function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
|
||||
|
||||
function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
const paramsText = formatCmppParams(app, params);
|
||||
const host = params?.gatewayHost ?? app.cmppParams.host;
|
||||
const port = params?.gatewayPort ?? app.cmppParams.port;
|
||||
@@ -156,9 +161,14 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
|
||||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||||
|
||||
async function copyParams() {
|
||||
await navigator.clipboard.writeText(paramsText);
|
||||
try {
|
||||
await copyText(paramsText);
|
||||
setCopyError('');
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch (failure) {
|
||||
setCopyError(failure instanceof Error ? failure.message : '复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -189,11 +199,34 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
|
||||
<div><span>协议版本</span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
|
||||
</div>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpParamsModal({ app, params, onClose }: { app: SmsApp; params: HttpApiConfigResponse; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
const paramsText = formatHttpApiParams(params, window.location.origin);
|
||||
|
||||
async function copyParams() {
|
||||
try {
|
||||
await copyText(paramsText);
|
||||
setCopyError('');
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch (failure) {
|
||||
setCopyError(failure instanceof Error ? failure.message : '复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">关闭</Button><Button icon={<Copy size={15} />} onClick={() => void copyParams()}>{copied ? '已复制' : '一键复制'}</Button></>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>HTTP接口参数</h2><p>{app.enterprise} / {app.name}</p></div>}>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function CmppConnectionModal({
|
||||
app,
|
||||
onClose,
|
||||
@@ -252,6 +285,8 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
||||
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
||||
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
|
||||
const [httpParamsApp, setHttpParamsApp] = useState<SmsApp | null>(null);
|
||||
const [httpParamsDetail, setHttpParamsDetail] = useState<HttpApiConfigResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
@@ -324,8 +359,23 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
}
|
||||
|
||||
async function openParams(app: SmsApp) {
|
||||
try {
|
||||
setParamsApp(app);
|
||||
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
|
||||
} catch (failure) {
|
||||
setParamsApp(null);
|
||||
setError(failure instanceof Error ? failure.message : 'CMPP参数加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function openHttpParams(app: SmsApp) {
|
||||
try {
|
||||
setHttpParamsApp(app);
|
||||
setHttpParamsDetail(await adminApi.getApplicationHttpApiConfig(app.id));
|
||||
} catch (failure) {
|
||||
setHttpParamsApp(null);
|
||||
setError(failure instanceof Error ? failure.message : 'HTTP参数加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSmsApps = useMemo(
|
||||
@@ -340,7 +390,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)} 元` },
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: 'CMPP状态',
|
||||
@@ -355,8 +405,9 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
</button>
|
||||
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
|
||||
<Settings2 size={13} />
|
||||
参数
|
||||
CMPP参数
|
||||
</button>
|
||||
<button className="cmpp-status-cell__params" disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -453,6 +504,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
/>
|
||||
) : null}
|
||||
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
|
||||
{httpParamsApp && httpParamsDetail ? <HttpParamsModal app={httpParamsApp} params={httpParamsDetail} onClose={() => { setHttpParamsApp(null); setHttpParamsDetail(null); }} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -468,10 +520,11 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
|
||||
enabled: application.status === 'active',
|
||||
sentToday: application.sentToday ?? 0,
|
||||
deliveryRate: application.deliveryRate ?? 0,
|
||||
unitPrice: (application.customerUnitPrice ?? 0) / 100,
|
||||
unitPrice: moneyUnitsToYuan(application.customerUnitPrice),
|
||||
cmppStatus: application.interfaceEnabled === false ? 'inactive' : application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
|
||||
cmppParams: { host: '', port: 0, interfaceEnabled: application.interfaceEnabled !== false, interfaceType: application.interfaceType ?? 'cmpp20', enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: application.cmppMaxConnections ?? 1, heartbeatSeconds: 30, windowSize: application.cmppWindowSize ?? 16, protocolVersion: 'CMPP2.0' },
|
||||
cmppConnections: connections,
|
||||
httpEnabled: Boolean(application.httpConfig?.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -496,7 +496,7 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>企业签名只展示汇总结果;这里修改的是每个具体通道的报备任务,保存后会同步通道详情、报备任务和企业签名三网状态。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 220px', padding: 16 }}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
@@ -536,7 +536,7 @@ function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 220px', padding: 16 }}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/components/ui';
|
||||
import { adminApi, type DashboardResponse } from '@/api/adminApi';
|
||||
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
|
||||
type EnterpriseSpendRank = {
|
||||
id: string;
|
||||
@@ -35,10 +36,7 @@ const balanceTone = {
|
||||
} as const;
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
return formatAmount(value);
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
@@ -68,8 +66,8 @@ export function AdminHome() {
|
||||
return (dashboard?.accounts ?? []).map((account) => {
|
||||
const todaySpend = Math.abs(dashboard?.recentRecharges
|
||||
.filter((item) => item.tenantId === account.tenantId)
|
||||
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100;
|
||||
const availableBalance = (account.balanceCents + account.creditCents) / 100;
|
||||
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 10_000;
|
||||
const availableBalance = moneyUnitsToYuan(account.balanceCents + account.creditCents);
|
||||
return {
|
||||
id: account.tenantId,
|
||||
enterprise: account.tenant?.name ?? account.tenantId,
|
||||
@@ -82,7 +80,7 @@ export function AdminHome() {
|
||||
|
||||
const totalSend = dashboard?.today.sent ?? 0;
|
||||
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
||||
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
|
||||
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Database, ListFilter, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -18,6 +18,32 @@ type CarrierRule = DictionaryItem & {
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
const carrierTone: Record<string, 'success' | 'info' | 'warning' | 'neutral'> = {
|
||||
中国移动: 'success',
|
||||
中国联通: 'info',
|
||||
中国电信: 'warning',
|
||||
};
|
||||
|
||||
type PhoneSegmentSummaryProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function PhoneSegmentSummary({ icon, label, description, total }: PhoneSegmentSummaryProps) {
|
||||
return (
|
||||
<section className="phone-segment-summary" aria-label={label}>
|
||||
<span className="phone-segment-summary__icon">{icon}</span>
|
||||
<div className="phone-segment-summary__value">
|
||||
<span>{label}</span>
|
||||
<strong>{total.toLocaleString('zh-CN')}</strong>
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPhoneSegmentsPage() {
|
||||
const pageSize = 25;
|
||||
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
||||
@@ -121,12 +147,12 @@ export function AdminPhoneSegmentsPage() {
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
|
||||
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
|
||||
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
|
||||
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' },
|
||||
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' },
|
||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||
{ key: 'segment', title: '手机号段(前7位)', width: '190px', render: (record) => <strong className="phone-segment-prefix">{record.prefix}</strong> },
|
||||
{ key: 'carrier', title: '运营商', width: '130px', render: (record) => record.carrier ? <Tag tone={carrierTone[record.carrier] ?? 'neutral'}>{record.carrier}</Tag> : '-' },
|
||||
{ key: 'province', title: '省份', width: '110px', render: (record) => record.province ?? '-' },
|
||||
{ key: 'city', title: '城市', width: '110px', render: (record) => record.city ?? '-' },
|
||||
{ key: 'createdAt', title: '创建时间', width: '170px', render: (record) => formatDateTime(record.createdAt) },
|
||||
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button className="phone-segment-delete" icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="ghost">删除</Button> },
|
||||
], []);
|
||||
|
||||
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
|
||||
@@ -138,7 +164,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
|
||||
const queryPanel = (
|
||||
<div className="phone-segment-query">
|
||||
<Input label="关键词" onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '手机号段、运营商、省份或城市' : '运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
|
||||
<Input aria-label="关键词" onChange={(event) => setKeyword(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') query(); }} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
|
||||
<div className="phone-segment-query__actions">
|
||||
<Button icon={<Search size={16} />} onClick={query}>查询</Button>
|
||||
<Button icon={<RotateCcw size={16} />} onClick={reset} variant="ghost">重置</Button>
|
||||
@@ -149,9 +175,9 @@ export function AdminPhoneSegmentsPage() {
|
||||
return (
|
||||
<section className="page-stack admin-system-page phone-segment-workbench">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="phone-segment-heading">
|
||||
<Breadcrumb items={['系统管理', '手机号段库']} />
|
||||
<h1>手机号段库</h1>
|
||||
<p>维护号码前七位归属,用于运营商识别与路由判断</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
|
||||
{activeTab === 'segments' ? '新增号段' : '新增规则'}
|
||||
@@ -159,7 +185,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-system-table-card">
|
||||
<div className="surface admin-system-table-card phone-segment-panel">
|
||||
<Tabs
|
||||
className="phone-segment-workbench__tabs"
|
||||
onChange={(value) => {
|
||||
@@ -172,12 +198,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
value: 'segments',
|
||||
content: (
|
||||
<div className="phone-segment-tab-content">
|
||||
<div className="phone-segment-overview phone-segment-overview--single" aria-label="手机号段统计">
|
||||
<section>
|
||||
<span><Database size={20} /></span>
|
||||
<div><strong>{segmentTotal.toLocaleString('zh-CN')}</strong><p>已收录手机号段</p></div>
|
||||
</section>
|
||||
</div>
|
||||
<PhoneSegmentSummary description="当前库中可查询的号码前七位记录" icon={<Database size={22} />} label="已收录手机号段" total={segmentTotal} />
|
||||
{queryPanel}
|
||||
<Table columns={columns} data={segments} emptyText={loading ? '加载中...' : '暂无手机号段'} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
@@ -198,12 +219,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
value: 'rules',
|
||||
content: (
|
||||
<div className="phone-segment-tab-content">
|
||||
<div className="phone-segment-overview phone-segment-overview--single" aria-label="运营商区分规则统计">
|
||||
<section>
|
||||
<span><ListFilter size={20} /></span>
|
||||
<div><strong>{ruleTotal.toLocaleString('zh-CN')}</strong><p>运营商识别规则</p></div>
|
||||
</section>
|
||||
</div>
|
||||
<PhoneSegmentSummary description="按优先级匹配号码前缀的识别规则" icon={<ListFilter size={22} />} label="运营商识别规则" total={ruleTotal} />
|
||||
{queryPanel}
|
||||
<Table columns={ruleColumns} data={rules} emptyText={loading ? '加载中...' : '暂无运营商区分规则'} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AdminProfitReportsPage() {
|
||||
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag></div>
|
||||
</div>
|
||||
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(270px, 1.3fr) minmax(180px, .8fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
<div className="surface admin-report-filter-grid admin-report-filter-grid--profit">
|
||||
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||||
<Select label="统计维度" onChange={(event) => { setDimensionType(event.target.value as 'application' | 'channel'); setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1); }} options={[{ label: '按企业应用', value: 'application' }, { label: '按通道', value: 'channel' }]} value={dimensionType} />
|
||||
{dimensionType === 'application' ? <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} /> : <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} />}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function AdminQualityReportsPage() {
|
||||
|
||||
const reportPanel = (
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
<div className="surface admin-report-filter-grid admin-report-filter-grid--quality">
|
||||
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||||
{dimension === 'channel' ? <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} /> : <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />}
|
||||
{dimension === 'channel' ? <div /> : <Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Plus, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatAmount } from '@/utils/currency';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type ManualRechargeForm = {
|
||||
tenantId: string;
|
||||
@@ -91,7 +91,7 @@ export function AdminRechargeRecordsPage() {
|
||||
|
||||
async function submitManualRecharge() {
|
||||
const amount = Number(form.amount);
|
||||
if (!form.tenantId || !Number.isFinite(amount) || amount === 0) {
|
||||
if (!form.tenantId || !Number.isFinite(amount) || !isValidMoneyInput(form.amount, { allowNegative: true, allowZero: false })) {
|
||||
setManualError('请填写非 0 的充值金额;金额支持负数冲正。');
|
||||
return;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function AdminRechargeRecordsPage() {
|
||||
try {
|
||||
await adminApi.createManualRecharge({
|
||||
tenantId: form.tenantId,
|
||||
amountCents: Math.round(amount * 100),
|
||||
amountCents: yuanToMoneyUnits(form.amount),
|
||||
remark: form.remark,
|
||||
});
|
||||
await loadData();
|
||||
@@ -158,8 +158,8 @@ export function AdminRechargeRecordsPage() {
|
||||
<tr key={record.id}>
|
||||
<td><strong>{tenantName}</strong></td>
|
||||
<td>{formatDateTime(record.paidAt ?? record.createdAt)}</td>
|
||||
<td>¥{formatAmount(record.amountCents / 100)}</td>
|
||||
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`}</td>
|
||||
<td>¥{formatCents(record.amountCents)}</td>
|
||||
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatCents(record.balanceAfterCents)}`}</td>
|
||||
<td><Tag tone="warning">人工充值</Tag></td>
|
||||
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
||||
</tr>
|
||||
@@ -202,7 +202,7 @@ export function AdminRechargeRecordsPage() {
|
||||
required
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} />
|
||||
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={form.amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
{manualError ? <p className="form-error">{manualError}</p> : null}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function AdminReconciliationReportsPage() {
|
||||
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag></div>
|
||||
</div>
|
||||
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(200px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
<div className="surface admin-report-filter-grid admin-report-filter-grid--reconciliation">
|
||||
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||||
<Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />
|
||||
<Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Info, RadioTower, RefreshCw } from 'lucide-react';
|
||||
import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
|
||||
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type QueuePriority = 'normal' | 'priority';
|
||||
@@ -21,6 +22,15 @@ const deliveryModeOptions = [
|
||||
{ label: '不投递', value: 'none' },
|
||||
];
|
||||
|
||||
const httpCapabilityOptions: Array<{ key: keyof HttpApiConfig; label: string }> = [
|
||||
{ key: 'sendEnabled', label: '单条发送' },
|
||||
{ key: 'messageQueryEnabled', label: '状态查询' },
|
||||
{ key: 'receiptWebhookEnabled', label: '回执回调' },
|
||||
{ key: 'uplinkQueryEnabled', label: '上行查询' },
|
||||
{ key: 'uplinkWebhookEnabled', label: '上行回调' },
|
||||
{ key: 'credentialSelfServiceEnabled', label: '客户端自助密钥' },
|
||||
];
|
||||
|
||||
export function AdminSmsApplicationFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { enterpriseId, appId } = useParams();
|
||||
@@ -113,7 +123,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
setAppName(application.name);
|
||||
setScene(application.scene ?? '');
|
||||
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
|
||||
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(3));
|
||||
setCustomerUnitPrice(moneyUnitsToYuan(application.customerUnitPrice).toFixed(4));
|
||||
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
|
||||
setCmppAccount(application.cmppAccount ?? '');
|
||||
setApplicationExtension(application.cmppApplicationExtension ?? '');
|
||||
@@ -154,6 +164,10 @@ export function AdminSmsApplicationFormPage() {
|
||||
setError('请至少配置一个运营商通道组');
|
||||
return;
|
||||
}
|
||||
if (!isValidMoneyInput(customerUnitPrice)) {
|
||||
setError('客户单价必须是非负金额,且最多保留小数点后 4 位');
|
||||
return;
|
||||
}
|
||||
const normalizedExtension = applicationExtension.trim();
|
||||
const normalizedFillPrefix = accessNumberFillPrefix.trim();
|
||||
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
|
||||
@@ -176,7 +190,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
name: appName,
|
||||
scene,
|
||||
dailyLimit: Number(dailyLimit) || undefined,
|
||||
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
|
||||
customerUnitPrice: yuanToMoneyUnits(customerUnitPrice),
|
||||
queuePriority,
|
||||
cmppAccount: cmppAccount.trim() || undefined,
|
||||
cmppApplicationExtension: normalizedExtension,
|
||||
@@ -246,7 +260,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
|
||||
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.030" required value={customerUnitPrice} />
|
||||
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required step="0.0001" type="number" value={customerUnitPrice} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>发送队列</span>
|
||||
<div className="radio-row">
|
||||
@@ -279,59 +293,23 @@ export function AdminSmsApplicationFormPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>接口配置</h3>
|
||||
<p>CMPP 与 HTTP 可独立开通;回执和上行可按 CMPP、HTTP、双投或不投递配置。</p>
|
||||
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--cmpp">
|
||||
<div className="ui-detail-section__header admin-app-protocol-header">
|
||||
<div className="admin-app-protocol-heading">
|
||||
<span className="admin-app-protocol-icon"><RadioTower size={19} /></span>
|
||||
<div><h3>CMPP 接入配置</h3><p>管理客户端长连接、账号、接入号与下游回执投递。</p></div>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>CMPP 接口</span>
|
||||
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
|
||||
<span />
|
||||
{interfaceEnabled ? '开通' : '关闭'}
|
||||
{interfaceEnabled ? '已开通' : '未开通'}
|
||||
</button>
|
||||
</div>
|
||||
{interfaceEnabled ? (
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>CMPP 协议</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />
|
||||
CMPP2.0
|
||||
</label>
|
||||
<div className="radio-row"><label><input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />CMPP2.0</label></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>HTTP 接口</span>
|
||||
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => ({ ...current, enabled: !current.enabled }))} type="button"><span />{httpConfig.enabled ? '开通' : '关闭'}</button>
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>开启后仍需分别开通发送、状态查询、回执回调和上行查询/回调能力;访问密钥由客户端“接口对接”页面按权限创建。</span></div>
|
||||
</div>
|
||||
{httpConfig.enabled ? (
|
||||
<>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>HTTP 能力</span>
|
||||
<div className="radio-row">
|
||||
{([
|
||||
['sendEnabled', '单条发送'], ['messageQueryEnabled', '状态查询'], ['receiptWebhookEnabled', '回执回调'],
|
||||
['uplinkQueryEnabled', '上行查询'], ['uplinkWebhookEnabled', '上行回调'], ['credentialSelfServiceEnabled', '客户端自助密钥'],
|
||||
] as Array<[keyof HttpApiConfig, string]>).map(([key, label]) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
|
||||
</div>
|
||||
</div>
|
||||
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="独立于CMPP;多个IP/CIDR可换行填写,留空表示不限制" value={httpIpAddress} />
|
||||
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
|
||||
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
|
||||
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
|
||||
<Select label="回执投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, receiptDeliveryMode: event.target.value as HttpApiConfig['receiptDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.receiptDeliveryMode} />
|
||||
<Select label="上行投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} />
|
||||
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
|
||||
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide"><span>安全与重试</span><div className="radio-row">
|
||||
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" />生产回调强制 HTTPS</label>
|
||||
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook 自动重试</label>
|
||||
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" />允许客户端手工重投</label>
|
||||
</div></div>
|
||||
</>
|
||||
) : null}
|
||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
||||
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
|
||||
<Input
|
||||
@@ -343,54 +321,65 @@ export function AdminSmsApplicationFormPage() {
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>客户接入号填充</span>
|
||||
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button">
|
||||
<span />
|
||||
{accessNumberFillEnabled ? '开启' : '关闭'}
|
||||
</button>
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id 时去掉开头前缀,上游发送时只拼接真实应用扩展码。</span>
|
||||
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button"><span />{accessNumberFillEnabled ? '开启' : '关闭'}</button>
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id 时去掉开头前缀,上游发送时只拼接真实应用扩展码。</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{accessNumberFillEnabled ? (
|
||||
<Input
|
||||
hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。"
|
||||
label="填充前缀"
|
||||
onChange={(event) => setAccessNumberFillPrefix(event.target.value)}
|
||||
placeholder="例如 00"
|
||||
required
|
||||
value={accessNumberFillPrefix}
|
||||
/>
|
||||
) : null}
|
||||
{accessNumberFillEnabled ? <Input hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。" label="填充前缀" onChange={(event) => setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null}
|
||||
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
|
||||
<Input
|
||||
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
||||
label="接口密码"
|
||||
label="CMPP 接口密码"
|
||||
onChange={(event) => setPasswordCipher(event.target.value)}
|
||||
placeholder="16 位接口密码"
|
||||
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
|
||||
value={passwordCipher}
|
||||
/>
|
||||
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
||||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
||||
<Input label="CMPP IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>下游投递策略</span>
|
||||
<span>CMPP 下游投递策略</span>
|
||||
<div className="radio-row">
|
||||
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button">
|
||||
<span />
|
||||
回执自动重试:{downstreamReceiptRetryEnabled ? '开启' : '关闭'}
|
||||
</button>
|
||||
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button">
|
||||
<span />
|
||||
上行自动重试:{downstreamUplinkRetryEnabled ? '开启' : '关闭'}
|
||||
</button>
|
||||
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button"><span />回执自动重试:{downstreamReceiptRetryEnabled ? '开启' : '关闭'}</button>
|
||||
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button"><span />上行自动重试:{downstreamUplinkRetryEnabled ? '开启' : '关闭'}</button>
|
||||
</div>
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP 的消息不会自动重发,仍可在下游投递记录中手工重投。</span>
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP 的消息不会自动重发,仍可在下游投递记录中手工重投。</span></div>
|
||||
</div>
|
||||
</div>
|
||||
) : <div className="admin-app-protocol-empty">CMPP 接口未开通,账号、接入号和长连接参数已收起。</div>}
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--http">
|
||||
<div className="ui-detail-section__header admin-app-protocol-header">
|
||||
<div className="admin-app-protocol-heading">
|
||||
<span className="admin-app-protocol-icon"><Globe2 size={19} /></span>
|
||||
<div><h3>HTTP 接口配置</h3><p>管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。</p></div>
|
||||
</div>
|
||||
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => ({ ...current, enabled: !current.enabled }))} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
|
||||
</div>
|
||||
{httpConfig.enabled ? (
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>HTTP 能力</span>
|
||||
<div className="radio-row">
|
||||
{httpCapabilityOptions.map(({ key, label }) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
|
||||
</div>
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。</span></div>
|
||||
</div>
|
||||
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} />
|
||||
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
|
||||
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
|
||||
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
|
||||
<Select label="回执投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, receiptDeliveryMode: event.target.value as HttpApiConfig['receiptDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.receiptDeliveryMode} />
|
||||
<Select label="上行投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} />
|
||||
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
|
||||
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
|
||||
<div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP 安全与重试</span><div className="radio-row">
|
||||
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" />生产回调强制 HTTPS</label>
|
||||
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook 自动重试</label>
|
||||
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" />允许客户端手工重投</label>
|
||||
</div></div>
|
||||
</div>
|
||||
) : <div className="admin-app-protocol-empty">HTTP 接口未开通,能力、鉴权和 Webhook 参数已收起。</div>}
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
delivered: '发送成功',
|
||||
@@ -146,7 +147,7 @@ function downloadCsv(records: SmsMessageRecord[]) {
|
||||
record.province ?? '',
|
||||
getCarrierLabel(record.carrier),
|
||||
record.billingUnits,
|
||||
(record.amountCents / 100).toFixed(3),
|
||||
formatCents(record.amountCents),
|
||||
record.channel?.name ?? record.channelId ?? '',
|
||||
getStatusLabel(record.status),
|
||||
getTime(record.deliveredAt),
|
||||
@@ -434,7 +435,7 @@ export function AdminSmsRecordsPage() {
|
||||
<p className="admin-sms-record-content">{record.content}</p>
|
||||
<div className="admin-sms-record-card__meta">
|
||||
<div><span>接收号码</span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
|
||||
<div><span>计费</span><strong>{record.billingUnits} 条 / ¥{(record.amountCents / 100).toFixed(3)}</strong><small>{record.content.length} 字</small></div>
|
||||
<div><span>计费</span><strong>{record.billingUnits} 条 / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} 字</small></div>
|
||||
<div><span>发送通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small>回执 {getTime(record.deliveredAt)}</small></div>
|
||||
</div>
|
||||
<footer><button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button">查看发送详情</button></footer>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import { Button, Modal, Pagination, Tag } from '@/components/ui';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
|
||||
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
|
||||
|
||||
@@ -67,6 +68,7 @@ export function ClientApplicationsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [paramsError, setParamsError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
|
||||
function loadApplications() {
|
||||
setLoading(true);
|
||||
@@ -84,6 +86,7 @@ export function ClientApplicationsPage() {
|
||||
}, []);
|
||||
|
||||
function openParams(application: ClientSmsApplication) {
|
||||
if (application.interfaceEnabled === false) return;
|
||||
setSelectedApp(application);
|
||||
setParams(null);
|
||||
setParamsError('');
|
||||
@@ -113,7 +116,9 @@ export function ClientApplicationsPage() {
|
||||
return;
|
||||
}
|
||||
const text = selectedRows.map((item) => `${item.label}: ${item.value}`).join('\n');
|
||||
void navigator.clipboard.writeText(text).then(() => setCopied(true));
|
||||
void copyText(text)
|
||||
.then(() => { setCopied(true); setCopyError(''); })
|
||||
.catch((failure: Error) => setCopyError(failure.message || '复制失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -162,7 +167,7 @@ export function ClientApplicationsPage() {
|
||||
</div>
|
||||
<div><dt>HTTP接口</dt><dd><Tag tone={application.httpConfig?.enabled ? 'success' : 'info'}>{application.httpConfig?.enabled ? '已开通' : '未开通'}</Tag></dd></div>
|
||||
</dl>
|
||||
<div className="table-actions"><Button onClick={() => openParams(application)} variant="ghost">CMPP参数</Button><Button disabled={!application.httpConfig?.enabled} onClick={() => navigate('/client/http-api')} variant="ghost">HTTP接口对接</Button></div>
|
||||
<div className="table-actions"><Button disabled={application.interfaceEnabled === false} onClick={() => openParams(application)} variant="ghost">{application.interfaceEnabled === false ? 'CMPP未开通' : 'CMPP参数'}</Button><Button disabled={!application.httpConfig?.enabled} onClick={() => navigate('/client/http-api')} variant="ghost">HTTP接口对接</Button></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -197,6 +202,7 @@ export function ClientApplicationsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { clientApi, type DashboardResponse } from '@/api/adminApi';
|
||||
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatAmount, formatCents } from '@/utils/currency';
|
||||
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
|
||||
|
||||
type RecentTaskRow = {
|
||||
id: string;
|
||||
@@ -50,10 +50,10 @@ export function ClientHome() {
|
||||
}, []);
|
||||
|
||||
const account = dashboard?.accounts[0];
|
||||
const availableBalance = ((account?.balanceCents ?? 0) + (account?.creditCents ?? 0)) / 100;
|
||||
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
|
||||
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
|
||||
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0);
|
||||
const todayRefund = todayRefundCents / 100;
|
||||
const todayRefund = moneyUnitsToYuan(todayRefundCents);
|
||||
const balanceBaseline = Math.max(availableBalance + todaySpend - todayRefund, availableBalance, 1);
|
||||
const balancePercent = Math.min(100, Math.round((availableBalance / balanceBaseline) * 100));
|
||||
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
|
||||
import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi';
|
||||
import { Button, Input, Select, Tabs, Tag } from '@/components/ui';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
import { formatHttpApiParams } from '@/utils/interfaceParams';
|
||||
|
||||
export function ClientHttpApiPage() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
@@ -16,6 +18,19 @@ export function ClientHttpApiPage() {
|
||||
const [revealedSecret, setRevealedSecret] = useState<{ title: string; accessKey?: string; secret: string } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [paramsCopied, setParamsCopied] = useState(false);
|
||||
|
||||
async function copyHttpParams() {
|
||||
if (!config) return;
|
||||
try {
|
||||
await copyText(formatHttpApiParams(config, window.location.origin));
|
||||
setParamsCopied(true);
|
||||
setError('');
|
||||
window.setTimeout(() => setParamsCopied(false), 1600);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : 'HTTP接口参数复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
clientApi.listApplications().then((items) => {
|
||||
@@ -67,7 +82,7 @@ export function ClientHttpApiPage() {
|
||||
const api = config?.config;
|
||||
const overview = <div className="page-stack">
|
||||
{!api?.enabled ? <p className="form-error">当前应用尚未由运营端开通 HTTP 接口。</p> : null}
|
||||
<div className="surface" style={{ padding: 18 }}><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">基础地址:{window.location.origin}/api/openapi/v1</p><div className="table-actions">
|
||||
<div className="surface" style={{ padding: 18 }}><div className="section-heading"><div><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">基础地址:{window.location.origin}/api/openapi/v1</p></div><Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">{paramsCopied ? '已复制' : '复制HTTP参数'}</Button></div><div className="table-actions">
|
||||
<Tag tone={api?.sendEnabled ? 'success' : 'info'}>单条发送 {api?.sendEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.messageQueryEnabled ? 'success' : 'info'}>状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.uplinkQueryEnabled ? 'success' : 'info'}>上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'}</Tag>
|
||||
@@ -78,7 +93,7 @@ export function ClientHttpApiPage() {
|
||||
</div>;
|
||||
|
||||
const credentialPanel = <div className="page-stack"><div className="section-heading"><div><h3><KeyRound size={17} />访问凭据</h3><p className="muted">密钥只在创建时展示一次;建议轮换时先创建新凭据,完成切换后再吊销旧凭据。</p></div><Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void createCredential()}>创建凭据</Button></div>
|
||||
{credentials.map((item) => <div className="surface" key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr 100px 1fr auto', gap: 12, padding: 14, alignItems: 'center' }}><strong>{item.name}</strong><code>{item.accessKey}</code><span>****{item.secretLast4}</span><span className="muted">最近使用:{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}</span>{item.status === 'active' ? <Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void clientApi.revokeHttpApiCredential(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="danger">吊销</Button> : <Tag tone="info">已吊销</Tag>}</div>)}
|
||||
{credentials.map((item) => <div className="surface client-http-credential-row" key={item.id}><strong>{item.name}</strong><code>{item.accessKey}</code><span>****{item.secretLast4}</span><span className="muted">最近使用:{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}</span>{item.status === 'active' ? <Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void clientApi.revokeHttpApiCredential(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="danger">吊销</Button> : <Tag tone="info">已吊销</Tag>}</div>)}
|
||||
{credentials.length === 0 ? <p className="muted">暂无访问凭据。</p> : null}
|
||||
</div>;
|
||||
|
||||
@@ -104,7 +119,7 @@ SHA256(rawBody)`}</pre><p>使用访问密钥执行 HMAC-SHA256,输出小写十
|
||||
|
||||
return <section className="page-stack"><div className="page-heading"><div><h1>接口对接</h1><p>管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。</p></div><Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={applications.map((item) => ({ label: item.name, value: item.id }))} value={applicationId} /></div>
|
||||
{loading ? <p className="muted">正在加载接口配置...</p> : null}{error ? <p className="form-error">{error}</p> : null}
|
||||
{revealedSecret ? <div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}><strong>{revealedSecret.title}</strong>{revealedSecret.accessKey ? <p>Access Key:<code>{revealedSecret.accessKey}</code></p> : null}<p>Secret:<code>{revealedSecret.secret}</code></p><Button icon={<Copy size={14} />} onClick={() => void navigator.clipboard.writeText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n'))} size="sm">复制</Button></div> : null}
|
||||
{revealedSecret ? <div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}><strong>{revealedSecret.title}</strong>{revealedSecret.accessKey ? <p>Access Key:<code>{revealedSecret.accessKey}</code></p> : null}<p>Secret:<code>{revealedSecret.secret}</code></p><Button icon={<Copy size={14} />} onClick={() => void copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch((failure: Error) => setError(failure.message))} size="sm">复制</Button></div> : null}
|
||||
{!loading && applicationId ? <Tabs items={tabs} /> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据', pa
|
||||
<tr key={getRowKey(record)}>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
data-label={typeof column.title === 'string' ? column.title : undefined}
|
||||
key={column.key}
|
||||
style={{ minWidth: column.width, textAlign: column.align ?? 'left', width: column.width }}
|
||||
>
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
CircleHelp,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Menu,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import {
|
||||
clearSession,
|
||||
@@ -63,6 +65,7 @@ export function AppShell({
|
||||
auditNotifications = [],
|
||||
}: AppShellProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
@@ -85,6 +88,7 @@ export function AppShell({
|
||||
const reauthenticationReject = useRef<((error: Error) => void) | null>(null);
|
||||
const lockRequested = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
||||
const auditTotal = useMemo(
|
||||
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
|
||||
@@ -180,6 +184,19 @@ export function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMobileNavOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMobileNavOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [mobileNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const activityEvents = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const;
|
||||
const onActivity = () => markUserActivity();
|
||||
@@ -263,12 +280,17 @@ export function AppShell({
|
||||
}, [auditTotal, title]);
|
||||
|
||||
return (
|
||||
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : ''].filter(Boolean).join(' ')}>
|
||||
<aside className="sidebar">
|
||||
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : '', mobileNavOpen ? 'app-shell--mobile-nav-open' : ''].filter(Boolean).join(' ')}>
|
||||
<aside aria-label="应用导航" className={['sidebar', mobileNavOpen ? 'sidebar--mobile-open' : ''].filter(Boolean).join(' ')}>
|
||||
<div className="sidebar-brand-row">
|
||||
<div className="brand-block">
|
||||
<img alt={`${title} logo`} className="brand-logo brand-logo--full" src="/logo/logo1.png" />
|
||||
<img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" />
|
||||
</div>
|
||||
<button aria-label="关闭导航" className="icon-button mobile-nav-close" onClick={() => setMobileNavOpen(false)} type="button">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="side-nav" aria-label="主导航">
|
||||
{navSections.map((section) => (
|
||||
@@ -296,7 +318,7 @@ export function AppShell({
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<NavLink key={item.to} to={item.to} end title={item.pending ? `${item.label}(待开发)` : item.label}>
|
||||
<NavLink key={item.to} onClick={() => setMobileNavOpen(false)} to={item.to} end title={item.pending ? `${item.label}(待开发)` : item.label}>
|
||||
<Icon size={17} strokeWidth={2.1} />
|
||||
<span className="side-nav-label">
|
||||
<span className="side-nav-label-text">{item.label}</span>
|
||||
@@ -316,21 +338,35 @@ export function AppShell({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{mobileNavOpen ? <button aria-label="关闭导航遮罩" className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} type="button" /> : null}
|
||||
|
||||
<main className="main-area">
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button
|
||||
className="icon-button"
|
||||
className="icon-button desktop-nav-toggle"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
type="button"
|
||||
aria-label={collapsed ? '展开导航' : '收起导航'}
|
||||
>
|
||||
<ToggleIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
aria-expanded={mobileNavOpen}
|
||||
aria-label={mobileNavOpen ? '关闭导航' : '打开导航'}
|
||||
className="icon-button mobile-nav-toggle"
|
||||
onClick={() => setMobileNavOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="mobile-topbar-brand">
|
||||
<img alt={`${title} logo`} src="/logo/logo1.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<button className="icon-button" type="button" aria-label="帮助中心">
|
||||
<button className="icon-button topbar-help" type="button" aria-label="帮助中心">
|
||||
<CircleHelp size={18} />
|
||||
</button>
|
||||
<div className="notice-menu-wrap">
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Home,
|
||||
ImageIcon,
|
||||
MessageSquareText,
|
||||
PenLine,
|
||||
ReceiptText,
|
||||
@@ -52,17 +51,6 @@ export function ClientLayout() {
|
||||
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '彩信服务',
|
||||
items: [
|
||||
{ label: '签名报备', to: '/client/mms-signatures', icon: PenLine, pending: true },
|
||||
{ label: '彩信模板管理', to: '/client/mms-templates', icon: ImageIcon, pending: true },
|
||||
{ label: '发送彩信', to: '/client/mms-send', icon: MessageSquareText, pending: true },
|
||||
{ label: '查看批量任务', to: '/client/mms-batch-tasks', icon: ClipboardList, pending: true },
|
||||
{ label: '彩信发送详情', to: '/client/mms-send-detail', icon: ClipboardList, pending: true },
|
||||
{ label: '查看上行彩信', to: '/client/mms-uplink-messages', icon: ImageIcon, pending: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '账户',
|
||||
items: [
|
||||
|
||||
@@ -1260,3 +1260,15 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.ui-table-wrap {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ui-table {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
+797
-137
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
export async function copyText(text: string) {
|
||||
if (!text) throw new Error('没有可复制的内容');
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// HTTP deployments and restrictive browser policies may reject Clipboard API.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const copied = document.execCommand('copy');
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error('浏览器未允许写入剪贴板,请手工选择参数复制');
|
||||
}
|
||||
+21
-3
@@ -1,14 +1,32 @@
|
||||
export const MONEY_UNITS_PER_YUAN = 10_000;
|
||||
|
||||
export function formatAmount(value: number) {
|
||||
return value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCents(cents?: number | null) {
|
||||
return formatAmount((cents ?? 0) / 100);
|
||||
return formatAmount((cents ?? 0) / MONEY_UNITS_PER_YUAN);
|
||||
}
|
||||
|
||||
export function formatYuan(cents?: number | null) {
|
||||
return `¥${formatCents(cents)}`;
|
||||
}
|
||||
|
||||
export function yuanToMoneyUnits(value: number | string | null | undefined) {
|
||||
const amount = typeof value === 'string' ? Number(value) : (value ?? 0);
|
||||
return Math.round(amount * MONEY_UNITS_PER_YUAN);
|
||||
}
|
||||
|
||||
export function isValidMoneyInput(value: string, options: { allowNegative?: boolean; allowZero?: boolean } = {}) {
|
||||
const normalized = value.trim();
|
||||
const pattern = options.allowNegative ? /^-?\d+(?:\.\d{1,4})?$/ : /^\d+(?:\.\d{1,4})?$/;
|
||||
if (!pattern.test(normalized)) return false;
|
||||
return options.allowZero !== false || Number(normalized) !== 0;
|
||||
}
|
||||
|
||||
export function moneyUnitsToYuan(value?: number | null) {
|
||||
return (value ?? 0) / MONEY_UNITS_PER_YUAN;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { HttpApiConfigResponse } from '@/api/adminApi';
|
||||
|
||||
const capabilityLabels = [
|
||||
['sendEnabled', '单条发送'],
|
||||
['messageQueryEnabled', '状态查询'],
|
||||
['uplinkQueryEnabled', '上行查询'],
|
||||
['receiptWebhookEnabled', '回执回调'],
|
||||
['uplinkWebhookEnabled', '上行回调'],
|
||||
] as const;
|
||||
|
||||
export function formatHttpApiParams(response: HttpApiConfigResponse, origin: string) {
|
||||
const config = response.config;
|
||||
const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`;
|
||||
return [
|
||||
`应用名称: ${response.applicationName ?? response.applicationId}`,
|
||||
`HTTP接口: ${config?.enabled ? '开通' : '关闭'}`,
|
||||
`基础地址: ${baseUrl}`,
|
||||
`接口文档: ${origin.replace(/\/$/, '')}/api/client-docs`,
|
||||
`接口能力: ${capabilityLabels.filter(([key]) => config?.[key]).map(([, label]) => label).join('、') || '无'}`,
|
||||
`QPS限制: ${config?.qpsLimit ?? '-'}`,
|
||||
`签名时间容差: ${config?.timestampToleranceSeconds ?? '-'}秒`,
|
||||
`HTTP IP白名单: ${response.ipAllowlist.join('、') || '未限制'}`,
|
||||
`回执投递方式: ${config?.receiptDeliveryMode ?? '-'}`,
|
||||
`上行投递方式: ${config?.uplinkDeliveryMode ?? '-'}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -10,6 +10,8 @@ API_ENABLE_SEND_WORKER="${API_ENABLE_SEND_WORKER:-true}"
|
||||
API_SEND_WORKER_CONCURRENCY="${API_SEND_WORKER_CONCURRENCY:-50}"
|
||||
GATEWAY_CONTROL_ADDR="${GATEWAY_CONTROL_ADDR:-127.0.0.1:8090}"
|
||||
GATEWAY_CMPP_ADDR="${GATEWAY_CMPP_ADDR:-0.0.0.0:17890}"
|
||||
CMPP_PUBLIC_HOST="${CMPP_PUBLIC_HOST:-8.160.169.106}"
|
||||
CMPP_PUBLIC_PORT="${CMPP_PUBLIC_PORT:-${GATEWAY_CMPP_ADDR##*:}}"
|
||||
DB_NAME="${DB_NAME:-cmpp_platform}"
|
||||
DB_USER="${DB_USER:-cmpp}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -hex 24 | tr -d '\n')}"
|
||||
@@ -199,6 +201,8 @@ OBJECT_STORAGE_LOCAL_ROOT=${OBJECT_STORAGE_LOCAL_ROOT}
|
||||
GATEWAY_CONTROL_URL=http://127.0.0.1:8090
|
||||
GATEWAY_HEALTH_ADDR=${GATEWAY_CONTROL_ADDR}
|
||||
GATEWAY_CMPP_ADDR=${GATEWAY_CMPP_ADDR}
|
||||
CMPP_PUBLIC_HOST=${CMPP_PUBLIC_HOST}
|
||||
CMPP_PUBLIC_PORT=${CMPP_PUBLIC_PORT}
|
||||
API_BASE_URL=http://127.0.0.1:${API_PORT}/api
|
||||
EOF
|
||||
chmod 600 /etc/cmpp-platform/cmpp-platform.env
|
||||
|
||||
@@ -29,6 +29,11 @@ if [[ ! "${API_SEND_WORKER_CONCURRENCY:-}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${CMPP_PUBLIC_HOST:-}" || ! "${CMPP_PUBLIC_PORT:-}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "CMPP_PUBLIC_HOST and a positive CMPP_PUBLIC_PORT are required in $ENV_FILE; these are the customer-facing CMPP endpoint." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "[deploy] Installing dependencies"
|
||||
|
||||
Reference in New Issue
Block a user