fix: polish channel groups and add production deployment
This commit is contained in:
@@ -11,6 +11,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
logs/
|
||||
dump.rdb
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "SmsChannelGroup" ADD COLUMN "retryTimeLimitMinutes" INTEGER NOT NULL DEFAULT 720;
|
||||
|
||||
UPDATE "SmsChannelGroup"
|
||||
SET "retryTimeLimitMinutes" = LEAST(GREATEST("retryTimeLimitHours", 1), 72) * 60;
|
||||
@@ -536,6 +536,7 @@ model SmsChannelGroup {
|
||||
status String @default("active")
|
||||
retryEnabled Boolean @default(true)
|
||||
retryTimeLimitHours Int @default(72)
|
||||
retryTimeLimitMinutes Int @default(720)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BillingActionDto,
|
||||
CreateManualRechargeDto,
|
||||
CreateRechargeOrderDto,
|
||||
CreateAccountTransactionDto,
|
||||
CreateBillingPlanDto,
|
||||
CreateBillingRuleDto,
|
||||
CreateSmsBillingRecordDto,
|
||||
@@ -39,16 +38,6 @@ export class BillingController {
|
||||
return this.billing.createAccount(body);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
listTransactions(@TenantId() tenantId?: string) {
|
||||
return this.billing.listTransactions(tenantId);
|
||||
}
|
||||
|
||||
@Post('transactions')
|
||||
createTransaction(@Body() body: CreateAccountTransactionDto) {
|
||||
return this.billing.createTransaction(body);
|
||||
}
|
||||
|
||||
@Get('recharges')
|
||||
listRechargeOrders(@TenantId() tenantId?: string) {
|
||||
return this.billing.listRechargeOrders(tenantId);
|
||||
@@ -135,11 +124,6 @@ export class ClientBillingController {
|
||||
return this.billing.listPlans();
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
listTransactions(@TenantId() tenantId?: string) {
|
||||
return this.billing.listTransactions(tenantId);
|
||||
}
|
||||
|
||||
@Post('orders')
|
||||
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
|
||||
return this.billing.createRechargeOrder(body);
|
||||
|
||||
@@ -124,28 +124,6 @@ export class BillingService {
|
||||
return this.prisma.tenantAccount.create({ data: createData });
|
||||
}
|
||||
|
||||
listTransactions(tenantId?: string) {
|
||||
return this.prisma.accountTransaction.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
createTransaction(data: CreateAccountTransactionDto) {
|
||||
const createData: Prisma.AccountTransactionUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
transactionType: data.transactionType,
|
||||
amountCents: data.amountCents ?? 0,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
balanceAfter: data.balanceAfter ?? 0,
|
||||
relatedType: data.relatedType,
|
||||
relatedId: data.relatedId,
|
||||
remark: data.remark,
|
||||
};
|
||||
return this.prisma.accountTransaction.create({ data: createData });
|
||||
}
|
||||
|
||||
listRechargeOrders(tenantId?: string) {
|
||||
return this.prisma.rechargeOrder.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
|
||||
@@ -67,7 +67,7 @@ function createPrismaMock() {
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||
},
|
||||
@@ -156,7 +156,7 @@ describe('ChannelsService', () => {
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
});
|
||||
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 });
|
||||
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
|
||||
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
|
||||
|
||||
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
|
||||
@@ -188,7 +188,7 @@ describe('ChannelsService', () => {
|
||||
body: expect.stringContaining('"messageType":"ConnectChannel"'),
|
||||
}));
|
||||
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
|
||||
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
|
||||
});
|
||||
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
@@ -331,7 +331,7 @@ describe('ChannelsService', () => {
|
||||
name: '移动组更新',
|
||||
carrier: 'mobile',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 24,
|
||||
retryTimeLimitMinutes: 750,
|
||||
items: [
|
||||
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 },
|
||||
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
|
||||
@@ -340,6 +340,20 @@ describe('ChannelsService', () => {
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
const transactionCallback = prisma.$transaction.mock.calls[0][0];
|
||||
const tx = {
|
||||
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1' }),
|
||||
},
|
||||
};
|
||||
await transactionCallback(tx);
|
||||
expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({
|
||||
where: { id: 'group-1' },
|
||||
data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
|
||||
});
|
||||
|
||||
await expect(service.updateGroup('group-1', {
|
||||
carrier: 'mobile',
|
||||
items: [
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CreateChannelGroupDto {
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
}
|
||||
|
||||
export interface CreateChannelGroupItemDto {
|
||||
@@ -54,6 +55,7 @@ export interface UpdateChannelGroupDto {
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
|
||||
}
|
||||
|
||||
@@ -175,7 +177,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
|
||||
return this.prisma.smsChannel.findMany({
|
||||
include: { connectionStates: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
@@ -574,17 +580,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
createGroup(data: CreateChannelGroupDto) {
|
||||
const retryTimeLimitHours = data.retryTimeLimitHours ?? 72;
|
||||
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
|
||||
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
|
||||
}
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720);
|
||||
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||
return this.prisma.smsChannelGroup.create({
|
||||
data: {
|
||||
@@ -594,7 +597,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
description: data.description,
|
||||
status: data.status ?? 'active',
|
||||
retryEnabled: data.retryEnabled ?? true,
|
||||
retryTimeLimitHours,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -659,10 +663,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!current) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const retryTimeLimitHours = data.retryTimeLimitHours ?? current.retryTimeLimitHours;
|
||||
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
|
||||
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
|
||||
}
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(
|
||||
data.retryTimeLimitMinutes,
|
||||
data.retryTimeLimitHours,
|
||||
current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60,
|
||||
);
|
||||
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
|
||||
const items = data.items ?? [];
|
||||
const channelIds = [...new Set(items.map((item) => item.channelId))];
|
||||
@@ -681,7 +686,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
description: data.description,
|
||||
status: data.status ?? current.status,
|
||||
retryEnabled: data.retryEnabled ?? current.retryEnabled,
|
||||
retryTimeLimitHours,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
if (items.length > 0) {
|
||||
@@ -1220,6 +1226,14 @@ function deriveReceiptStatus(rowCount: number, successCount: number, failedCount
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
|
||||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeBusinessCarrier(carrier?: string | null) {
|
||||
const normalized = normalizeChannelCarrier(carrier);
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
|
||||
|
||||
@@ -49,6 +49,7 @@ function createPrismaMock() {
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 72,
|
||||
retryTimeLimitMinutes: 4320,
|
||||
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
|
||||
},
|
||||
};
|
||||
@@ -379,7 +380,7 @@ describe('SendChainService', () => {
|
||||
applicationId: 'app-1',
|
||||
groupId: 'group-1',
|
||||
carrier: 'mobile',
|
||||
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] },
|
||||
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] },
|
||||
});
|
||||
|
||||
await service.handleSubmitResult({
|
||||
@@ -400,6 +401,53 @@ describe('SendChainService', () => {
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
});
|
||||
|
||||
it('stops failed receipt retry after the configured minute limit', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBillingRecord.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' });
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue({
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
status: 'submitted',
|
||||
amountCents: 3,
|
||||
billingUnits: 1,
|
||||
unitPrice: 3,
|
||||
queuedAt: new Date(Date.now() - 90 * 60_000),
|
||||
});
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
id: 'route-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupId: 'group-1',
|
||||
carrier: 'mobile',
|
||||
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 2, retryTimeLimitMinutes: 75, items: [] },
|
||||
});
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'UNDELIV',
|
||||
});
|
||||
|
||||
expect(prisma.smsSubmitRecord.create).not.toHaveBeenCalled();
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let stale failed receipts overwrite a later delivered message', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue({
|
||||
@@ -467,6 +515,7 @@ describe('SendChainService', () => {
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 72,
|
||||
retryTimeLimitMinutes: 4320,
|
||||
items: [
|
||||
{
|
||||
id: 'wrong-item',
|
||||
|
||||
@@ -751,12 +751,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
take: 200,
|
||||
});
|
||||
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
|
||||
const ageHours = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 3_600_000;
|
||||
if (ageHours >= 72) {
|
||||
const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
|
||||
if (ageMinutes >= 72 * 60) {
|
||||
return null;
|
||||
}
|
||||
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber));
|
||||
if (!route.group.retryEnabled || ageHours >= Math.min(route.group.retryTimeLimitHours, 72)) {
|
||||
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
||||
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
本文基于当前前端设计原型整理,用于交给 Codex 或开发团队执行第一版落地开发。
|
||||
|
||||
当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、账单流水进入第一版开发范围。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。
|
||||
当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、充值记录进入第一版开发范围,账单流水页面和公开交易查询 API 暂不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。
|
||||
|
||||
## 1. 项目目标
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 支持通道级限速、失败重试、回执同步和发送记录追踪。
|
||||
- 支持企业、应用、签名、模板、通道、通道组、报备任务等核心配置数据的后台维护。
|
||||
- 彩信功能仅保留菜单占位或隐藏,不进入第一版开发范围。
|
||||
- 账户计费、充值套餐、账单流水进入第一版范围,并与发送记录形成可对账闭环。
|
||||
- 账户计费、充值套餐、充值记录进入第一版范围,并与发送记录形成可对账闭环;账单流水页面暂不验收。
|
||||
|
||||
## 2. 角色与权限
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
19. 新建或启用通道后,系统应立即创建/更新默认 CMPP 连接状态为 connecting 并通知 Go Gateway 发起连接;如果 connecting 超过 30 秒仍未收到 Gateway 回写 connected 或 failed,API 后台兜底任务必须将该连接标记为 failed,写入 `lastError=Gateway connection request timed out after 30 seconds` 和连接日志。默认扫描间隔 5 秒,可通过环境变量调整。
|
||||
20. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪。
|
||||
21. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。
|
||||
22. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。
|
||||
22. 通道组补发时间上限由运营端配置,交互为“小时 + 分钟”,默认 12 小时 0 分钟,最小 1 分钟,最大不得超过 72 小时;真实发送链路按分钟级上限判断是否继续补发。本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。
|
||||
23. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。
|
||||
|
||||
### 4.7 通道签名报备
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# CMPP 平台生产部署手册
|
||||
|
||||
## 本次生产端口
|
||||
|
||||
- 运营端、客户端页面:`http://8.160.169.106:12026`
|
||||
- API:仅本机 `127.0.0.1:3000`,由 Nginx `/api/` 反向代理。
|
||||
- Redis:仅本机 `127.0.0.1:6379`。
|
||||
- PostgreSQL:仅本机 `127.0.0.1:5432`。
|
||||
- MinIO:仅本机 `127.0.0.1:9000/9001`,页面上传下载通过 API 转发。
|
||||
- Gateway 控制服务:仅本机 `127.0.0.1:8090`。
|
||||
- CMPP 入站端口:`17890` 已作为部署变量保留;当前 Go Gateway 代码尚未实现完整入站 CMPP Server,不要把 HTTP 控制服务误当作 CMPP 监听。
|
||||
|
||||
## 首次部署
|
||||
|
||||
1. 本机提交并推送 `main`。
|
||||
2. 使用 root 登录服务器,或先放置免密 SSH key。
|
||||
3. 在服务器执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL http://175.27.255.91:3000/hectorzhao/lislgosms/raw/branch/main/tools/deploy/production-bootstrap.sh -o /root/production-bootstrap.sh
|
||||
bash /root/production-bootstrap.sh
|
||||
```
|
||||
|
||||
可覆盖的环境变量:
|
||||
|
||||
```bash
|
||||
APP_DIR=/opt/cmpp-platform
|
||||
REPO_URL=http://175.27.255.91:3000/hectorzhao/lislgosms.git
|
||||
BRANCH=main
|
||||
PUBLIC_HTTP_PORT=12026
|
||||
API_PORT=3000
|
||||
GATEWAY_CMPP_ADDR=0.0.0.0:17890
|
||||
PROD_ADMIN_EMAIL=admin@example.com
|
||||
PROD_ADMIN_USERNAME=prod_admin
|
||||
PROD_ADMIN_PASSWORD='change-me'
|
||||
```
|
||||
|
||||
脚本会安装 Node.js、Go、PostgreSQL、Redis、MinIO、Nginx,创建 systemd 服务,执行 Prisma migrate,构建前端/API/Gateway,并创建平台管理员。
|
||||
|
||||
## 后续发布
|
||||
|
||||
```bash
|
||||
cd /opt/cmpp-platform
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
bash tools/deploy/production-deploy.sh
|
||||
```
|
||||
|
||||
## 账号和密钥
|
||||
|
||||
- 生产管理员账号写入 `/root/cmpp-platform-admin.txt`。
|
||||
- 首次部署汇总凭据写入 `/root/cmpp-platform-credentials.txt`。
|
||||
- 这两个文件权限为 `600`,不要提交到 Git。
|
||||
- root 密码后续可修改;SSH 私钥放入 `/root/.ssh/authorized_keys` 后即可免密登录。
|
||||
|
||||
## 日志
|
||||
|
||||
```bash
|
||||
journalctl -u cmpp-api -f
|
||||
journalctl -u cmpp-gateway -f
|
||||
journalctl -u cmpp-minio -f
|
||||
tail -f /opt/cmpp-platform/logs/api/stderr.log
|
||||
tail -f /opt/cmpp-platform/logs/gateway/stderr.log
|
||||
```
|
||||
|
||||
## 健康检查
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/api/health
|
||||
curl http://127.0.0.1:8090/health
|
||||
curl http://127.0.0.1:12026/
|
||||
redis-cli -h 127.0.0.1 -p 6379 ping
|
||||
pg_isready -d "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)"
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
1. 数据库备份:
|
||||
|
||||
```bash
|
||||
pg_dump "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)" > /opt/cmpp-platform/backups/cmpp-$(date +%F-%H%M%S).sql
|
||||
```
|
||||
|
||||
2. 回滚代码:
|
||||
|
||||
```bash
|
||||
cd /opt/cmpp-platform
|
||||
git reset --hard <上一版提交>
|
||||
bash tools/deploy/production-deploy.sh
|
||||
```
|
||||
|
||||
3. 如迁移造成不可兼容故障,先停服务,再恢复数据库备份。
|
||||
@@ -42,7 +42,7 @@
|
||||
| CMPP 连接闭环 | 通道连接状态、登录认证、active test、断线重连、连接异常对路由/发送影响、状态监控和日志告警。 |
|
||||
| Dashboard 闭环 | 客户端/运营端指标口径、数据范围、时间筛选、状态统计、账务统计、通道连接指标和明细跳转一致。 |
|
||||
| 内容校验闭环 | 非法字符展示、敏感词、控制字符、内容清洗或拒绝、计费不被非法字符干扰、错误原因可见。 |
|
||||
| 计费闭环 | 预估、余额校验、冻结、扣费、释放、退款、短信计费记录、账单流水、对账。 |
|
||||
| 计费闭环 | 预估、余额校验、冻结、扣费、释放、退款、短信计费记录、充值记录、对账。账单流水页面和公开交易查询 API 暂不纳入第一版验收。 |
|
||||
| 系统日志闭环 | 登录、创建、修改、删除、审核、导入导出、密钥重置、发送、报备、账务动作均可查询和定位操作者。 |
|
||||
|
||||
## 4. 客户端功能用例
|
||||
@@ -50,10 +50,10 @@
|
||||
### TC-CLIENT-001 登录与租户隔离
|
||||
|
||||
- 优先级:P0
|
||||
- 前置条件:存在 `tenant-a` 和 `tenant-b`,两个租户各有发送任务和账单流水。
|
||||
- 前置条件:存在 `tenant-a` 和 `tenant-b`,两个租户各有发送任务和充值/计费记录。
|
||||
- 步骤:
|
||||
1. 使用 `tenant-a` 企业管理员登录客户端。
|
||||
2. 打开工作台、批量任务、发送详情、上行短信、账单流水。
|
||||
2. 打开工作台、批量任务、发送详情、上行短信、充值套餐。
|
||||
3. 使用查询条件搜索 `tenant-b` 的任务编号或手机号。
|
||||
- 预期结果:
|
||||
- 登录成功,返回当前租户上下文。
|
||||
@@ -257,14 +257,17 @@
|
||||
5. 尝试在移动通道组中添加山东省网 item,但引用发送地区为河南的通道。
|
||||
6. 在同一通道组中为山东省重复添加第二个省网通道。
|
||||
7. 添加主通道优先级 10、备用全国通道优先级 20,并尝试添加另一个优先级 20 的全国通道。
|
||||
8. 创建租户/应用维度路由规则。
|
||||
9. 创建发送任务触发送链路。
|
||||
8. 配置补发时间上限为 12 小时 30 分钟并保存,再重新打开编辑页。
|
||||
9. 创建租户/应用维度路由规则。
|
||||
10. 创建发送任务触发送链路。
|
||||
- 预期结果:
|
||||
- 只能创建 mobile/unicom/telecom 通道组,三网通道组被拒绝。
|
||||
- 通道组明细 carrier 必须等于通道组运营商。
|
||||
- 通道组明细引用通道时必须满足通道本体能力兼容:移动组只允许 mobile/all,联通组只允许 unicom/all,电信组只允许 telecom/all。
|
||||
- 省网 item 的省份必须与通道发送地区一致。
|
||||
- 同一通道组内同一省份只能配置一个通道,全国通道可配置多个但优先级不能重复。
|
||||
- 省网通道和全国通道在添加/编辑页以表格展示,通道状态文案为“链接正常/通道停用”,且“链接正常”来自真实 CMPP 连接状态。
|
||||
- 补发时间上限按分钟级真实保存,12 小时 30 分钟回填为 12 小时 30 分钟。
|
||||
- 路由优先命中租户/应用规则。
|
||||
- 主通道 active 时选择主通道。
|
||||
- 主通道 disabled 时选择备用 active 通道。
|
||||
@@ -809,13 +812,13 @@
|
||||
1. 模拟普通 failed 回执,确认触发补发。
|
||||
2. 模拟 unknown 状态,执行 72 小时超时补偿。
|
||||
3. 将消息提交时间调整为超过 72 小时。
|
||||
4. 将消息提交时间调整为超过通道组补发时间上限。
|
||||
4. 将消息提交时间调整为超过通道组分钟级补发时间上限。
|
||||
5. 关闭通道组失败补发后再次模拟 failed。
|
||||
- 预期结果:
|
||||
- 普通 failed 在限制内触发补发。
|
||||
- unknown 不触发补发。
|
||||
- 超过 72 小时不触发补发。
|
||||
- 超过通道组补发时间上限不触发补发。
|
||||
- 超过通道组分钟级补发时间上限不触发补发。
|
||||
- 通道组关闭失败补发时不触发补发。
|
||||
|
||||
### TC-SEND-019 迟到旧通道回执不覆盖最终成功
|
||||
|
||||
@@ -704,3 +704,76 @@ git diff --check
|
||||
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
|
||||
|
||||
## 2026-07-07 手机号段 Tab 和通道组补发上限
|
||||
|
||||
### 本轮修复
|
||||
|
||||
- 运营端手机号段库页面移除自定义卡片式 Tab,改用通用 `Tabs` 控件,与企业应用管理页面“短信应用/彩信应用”交互一致。
|
||||
- 通道组添加/编辑页面新增“补发时间上限(小时)”输入控件,编辑时回填 `retryTimeLimitHours`,保存时写入真实通道组接口。
|
||||
- 补发时间上限按后端现有校验限制为 1 到 72 小时。
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
|
||||
|
||||
## 2026-07-07 账单流水页面移除和列表分页
|
||||
|
||||
### 本轮修复
|
||||
|
||||
- 删除客户端账单流水页面和运营端账单流水页面,移除对应路由、菜单、占位映射和首页跳转入口。
|
||||
- 移除公开交易查询/创建接口:`GET/POST /api/admin/billing/transactions` 和 `GET /api/client/billing/transactions`。
|
||||
- 保留内部 `AccountTransaction` 写入能力,人工充值、扣费、释放、退款等真实计费动作仍可写入内部账务记录;本期不作为独立账单流水页面验收。
|
||||
- 通用 `Table` 组件新增内置分页,默认每页 10 条;服务端分页页面关闭内置分页,避免双分页。
|
||||
- 补齐手写列表和卡片列表分页:通道管理、通道组、充值记录、客户端应用、客户端充值套餐、客户端签名、客户端模板、客户端批量任务、客户端发送详情、运营端短信任务进度、运营端企业签名。
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm --prefix api run build
|
||||
npm --prefix api test -- billing.service.spec.ts --runInBand
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
- API build 通过。
|
||||
- BillingService 单测通过:1 个 test suite、6 个测试通过。
|
||||
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
|
||||
|
||||
## 2026-07-07 通道组表格和分钟级补发上限
|
||||
|
||||
### 本轮修复
|
||||
|
||||
- 通道组添加/编辑页的省网分流、全国通道配置从卡片改为通用表格展示,行内保留编辑、删除操作。
|
||||
- 通道状态文案改为设计锚点口径“链接正常/通道停用”;“链接正常”必须来自真实 CMPP 连接状态 connected 且当前连接数大于 0,新建但未连接的 active 通道不再显示为链接正常。
|
||||
- 通道组补发时间上限从整小时升级为分钟级配置,页面交互为“小时 + 分钟”,默认 12 小时 0 分钟;后端新增 `retryTimeLimitMinutes` 持久化字段,并保留 `retryTimeLimitHours` 兼容旧调用。
|
||||
- 发送链路按分钟级上限判断是否继续补发,超过配置分钟数、超过 72 小时或关闭补发时均不再补发。
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm --prefix api run prisma:generate
|
||||
npm --prefix api test -- channels.service.spec.ts send-chain.service.spec.ts --runInBand
|
||||
npm --prefix api run build
|
||||
npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- Prisma Client 已根据新 schema 生成。
|
||||
- ChannelsService 和 SendChainService 定向单测通过:2 个 test suites、35 个测试通过。
|
||||
- API build 通过。
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
|
||||
|
||||
+3
-18
@@ -171,19 +171,6 @@ export type RechargeOrder = {
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
export type AccountTransaction = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
transactionType: string;
|
||||
amountCents: number;
|
||||
smsUnits: number;
|
||||
balanceAfter: number;
|
||||
relatedType?: string | null;
|
||||
relatedId?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type BillingPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -339,6 +326,7 @@ export type ChannelGroup = DictionaryItem & {
|
||||
description?: string | null;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
items?: ChannelGroupItem[];
|
||||
};
|
||||
|
||||
@@ -555,7 +543,6 @@ export const adminApi = {
|
||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
||||
listTransactions: (tenantId?: string) => request<AccountTransaction[]>(withQuery('/admin/billing/transactions', { tenantId })),
|
||||
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) =>
|
||||
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -644,9 +631,9 @@ export const adminApi = {
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
|
||||
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) =>
|
||||
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) =>
|
||||
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; items?: Array<Record<string, unknown>> }) =>
|
||||
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array<Record<string, unknown>> }) =>
|
||||
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deleteChannelGroup: (id: string) =>
|
||||
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
|
||||
@@ -758,8 +745,6 @@ export const clientApi = {
|
||||
}),
|
||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||
listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
|
||||
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||
listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Breadcrumb, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type TenantAccount } from '@/api/adminApi';
|
||||
|
||||
const columns: Array<TableColumn<TenantAccount>> = [
|
||||
{ key: 'id', title: '账户编号', render: (record) => record.id },
|
||||
{ key: 'name', title: '客户名称', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'balance', title: '现金余额', render: (record) => `¥${(record.balanceCents / 100).toLocaleString('zh-CN')}` },
|
||||
{ key: 'smsUnits', title: '短信余量', render: (record) => `${record.smsUnits.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'creditCents', title: '授信额度', render: (record) => `¥${(record.creditCents / 100).toLocaleString('zh-CN')}` },
|
||||
{
|
||||
key: 'status',
|
||||
title: '账户状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'active' ? 'success' : 'danger'}>
|
||||
{record.status === 'active' ? '正常' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminBillingPage() {
|
||||
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listAccounts()
|
||||
.then((items) => {
|
||||
setAccounts(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '账务账户加载失败'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['账单流水']} />
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={accounts} emptyText="暂无账户数据" rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type ChannelStatus = 'normal' | 'stopped';
|
||||
@@ -49,7 +49,7 @@ const carrierLabels: Record<Carrier, string> = {
|
||||
};
|
||||
|
||||
const statusLabels: Record<ChannelStatus, string> = {
|
||||
normal: '通道启用',
|
||||
normal: '链接正常',
|
||||
stopped: '通道停用',
|
||||
};
|
||||
|
||||
@@ -67,45 +67,18 @@ function isCarrierCompatible(channelCarrier: string | null | undefined, carrier:
|
||||
}
|
||||
|
||||
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
|
||||
return channel?.status === 'active' ? 'normal' : 'stopped';
|
||||
if (channel?.status !== 'active') return 'stopped';
|
||||
return (channel.connectionStates ?? []).some((connection) =>
|
||||
connection.status === 'connected'
|
||||
&& connection.desiredConnections > 0
|
||||
&& connection.currentConnections > 0
|
||||
) ? 'normal' : 'stopped';
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: ChannelStatus }) {
|
||||
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
title,
|
||||
subtitle,
|
||||
channel,
|
||||
status,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
channel?: AdminChannel;
|
||||
status: ChannelStatus;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="channel-route-card">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<span>{subtitle}</span>
|
||||
</div>
|
||||
<p>{channel?.name ?? '未命名通道'}</p>
|
||||
<small>{channel?.sendRegion ?? '全国'} / {channel?.carrier ?? '未标记'}</small>
|
||||
<StatusTag status={status} />
|
||||
<footer>
|
||||
<button onClick={onEdit} type="button"><Pencil size={15} />编辑</button>
|
||||
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={15} />删除</button>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteConfigModal({
|
||||
channels,
|
||||
carrier,
|
||||
@@ -135,7 +108,7 @@ function RouteConfigModal({
|
||||
const channelOptions = [
|
||||
{ label: '请选择', value: '' },
|
||||
...selectableChannels.map((channel) => ({
|
||||
label: `${channel.name} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
|
||||
label: `${channel.name}(${channel.code}) / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
|
||||
value: channel.id,
|
||||
})),
|
||||
];
|
||||
@@ -173,6 +146,7 @@ function RouteConfigModal({
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
|
||||
>
|
||||
<div className="channel-route-modal">
|
||||
@@ -187,7 +161,7 @@ function RouteConfigModal({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Select label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
|
||||
<Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -201,6 +175,8 @@ export function AdminChannelGroupFormPage() {
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [carrier, setCarrier] = useState<Carrier>('mobile');
|
||||
const [retryEnabled, setRetryEnabled] = useState(true);
|
||||
const [retryLimitHours, setRetryLimitHours] = useState('12');
|
||||
const [retryLimitMinutes, setRetryLimitMinutes] = useState('0');
|
||||
const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]);
|
||||
const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]);
|
||||
const [modal, setModal] = useState<RouteModalState | null>(null);
|
||||
@@ -209,11 +185,66 @@ export function AdminChannelGroupFormPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]);
|
||||
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
|
||||
{ key: 'province', title: '省份', width: '160px', render: (route) => route.province },
|
||||
{
|
||||
key: 'channel',
|
||||
title: '通道名称',
|
||||
width: '280px',
|
||||
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '通道状态',
|
||||
width: '140px',
|
||||
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '160px',
|
||||
render: (route) => (
|
||||
<div className="channel-group-row-actions">
|
||||
<button onClick={() => setModal({ type: 'province', mode: 'edit', route })} type="button"><Pencil size={15} />编辑</button>
|
||||
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} />删除</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [channelById]);
|
||||
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
|
||||
{ key: 'priority', title: '优先级', width: '140px', render: (route) => route.priority },
|
||||
{
|
||||
key: 'channel',
|
||||
title: '通道名称',
|
||||
width: '280px',
|
||||
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '通道状态',
|
||||
width: '140px',
|
||||
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '160px',
|
||||
render: (route) => (
|
||||
<div className="channel-group-row-actions">
|
||||
<button onClick={() => setModal({ type: 'national', mode: 'edit', route })} type="button"><Pencil size={15} />编辑</button>
|
||||
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} />删除</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [channelById]);
|
||||
|
||||
function applyGroup(group: ChannelGroup) {
|
||||
setGroupName(group.name);
|
||||
setCarrier(group.carrier);
|
||||
setRetryEnabled(group.retryEnabled ?? true);
|
||||
const retryMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
|
||||
setRetryLimitHours(String(Math.floor(retryMinutes / 60)));
|
||||
setRetryLimitMinutes(String(retryMinutes % 60));
|
||||
setProvinceRoutes((group.items ?? [])
|
||||
.filter((item) => item.province)
|
||||
.map((item) => ({
|
||||
@@ -294,12 +325,24 @@ export function AdminChannelGroupFormPage() {
|
||||
setError('请输入通道组名称');
|
||||
return;
|
||||
}
|
||||
const retryHours = Number(retryLimitHours);
|
||||
const retryMinutes = Number(retryLimitMinutes);
|
||||
if (!Number.isInteger(retryHours) || retryHours < 0 || retryHours > 72 || !Number.isInteger(retryMinutes) || retryMinutes < 0 || retryMinutes > 59) {
|
||||
setError('补发时间上限需为 0 到 72 小时、0 到 59 分钟的整数');
|
||||
return;
|
||||
}
|
||||
const retryTimeLimitMinutes = retryHours * 60 + retryMinutes;
|
||||
if (retryTimeLimitMinutes < 1 || retryTimeLimitMinutes > 72 * 60) {
|
||||
setError('补发时间上限需大于 0 分钟且不超过 72 小时');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: groupName.trim(),
|
||||
carrier,
|
||||
status: 'active',
|
||||
retryEnabled,
|
||||
retryTimeLimitHours: 72,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
items: buildItems(),
|
||||
};
|
||||
setSaving(true);
|
||||
@@ -312,7 +355,8 @@ export function AdminChannelGroupFormPage() {
|
||||
carrier,
|
||||
status: 'active',
|
||||
retryEnabled,
|
||||
retryTimeLimitHours: 72,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
}).then((group) => adminApi.updateChannelGroup(group.id, payload));
|
||||
|
||||
request
|
||||
@@ -351,25 +395,34 @@ export function AdminChannelGroupFormPage() {
|
||||
<i />
|
||||
</button>
|
||||
</div>
|
||||
<div className="channel-group-retry-limit">
|
||||
<span>补发时间上限</span>
|
||||
<Input
|
||||
disabled={!retryEnabled}
|
||||
max="72"
|
||||
min="0"
|
||||
onChange={(event) => setRetryLimitHours(event.target.value)}
|
||||
suffix="小时"
|
||||
type="number"
|
||||
value={retryLimitHours}
|
||||
/>
|
||||
<Input
|
||||
disabled={!retryEnabled}
|
||||
max="59"
|
||||
min="0"
|
||||
onChange={(event) => setRetryLimitMinutes(event.target.value)}
|
||||
suffix="分钟"
|
||||
type="number"
|
||||
value={retryLimitMinutes}
|
||||
/>
|
||||
<small>后端限制 1 分钟到 72 小时;默认 12 小时 0 分钟。</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>省网分流配置</h2>
|
||||
<div className="channel-route-card-grid">
|
||||
{provinceRoutes.map((route) => (
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
channel={channelById.get(route.channelId)}
|
||||
onDelete={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))}
|
||||
onEdit={() => setModal({ type: 'province', mode: 'edit', route })}
|
||||
status={route.status}
|
||||
subtitle="省网优先路由"
|
||||
title={route.province}
|
||||
/>
|
||||
))}
|
||||
{provinceRoutes.length === 0 ? <p className="channel-route-empty">暂无省网通道</p> : null}
|
||||
</div>
|
||||
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" pagination={false} rowKey="id" />
|
||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
@@ -377,20 +430,7 @@ export function AdminChannelGroupFormPage() {
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>全国通道配置</h2>
|
||||
<div className="channel-route-card-grid">
|
||||
{nationalRoutes.map((route) => (
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
channel={channelById.get(route.channelId)}
|
||||
onDelete={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))}
|
||||
onEdit={() => setModal({ type: 'national', mode: 'edit', route })}
|
||||
status={route.status}
|
||||
subtitle="全国补发路由"
|
||||
title={`优先级 ${route.priority}`}
|
||||
/>
|
||||
))}
|
||||
{nationalRoutes.length === 0 ? <p className="channel-route-empty">暂无全国通道</p> : null}
|
||||
</div>
|
||||
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" pagination={false} rowKey="id" />
|
||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Layers3, Pencil, Plus, Search, Trash2, UsersRound } from 'lucide-react';
|
||||
import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui';
|
||||
import { adminApi, type ChannelGroup } from '@/api/adminApi';
|
||||
|
||||
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
|
||||
@@ -12,12 +12,32 @@ const carrierLabels: Record<GroupCarrier, string> = {
|
||||
telecom: '电信',
|
||||
};
|
||||
|
||||
function formatRetryLimit(group: ChannelGroup) {
|
||||
if (group.retryEnabled === false) return '已关闭';
|
||||
const totalMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
|
||||
return `${Math.floor(totalMinutes / 60)}小时${totalMinutes % 60}分钟`;
|
||||
}
|
||||
|
||||
function getGroupSummary(group: ChannelGroup) {
|
||||
const items = group.items ?? [];
|
||||
const provinceCount = items.filter((item) => item.province).length;
|
||||
const nationalCount = items.length - provinceCount;
|
||||
const connectedCount = items.filter((item) => (item.channel?.connectionStates ?? []).some((connection) =>
|
||||
connection.status === 'connected'
|
||||
&& connection.desiredConnections > 0
|
||||
&& connection.currentConnections > 0
|
||||
)).length;
|
||||
return { connectedCount, nationalCount, provinceCount, totalCount: items.length };
|
||||
}
|
||||
|
||||
export function AdminChannelGroupsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groups, setGroups] = useState<ChannelGroup[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState('');
|
||||
const pageSize = 10;
|
||||
|
||||
function loadData() {
|
||||
adminApi.listChannelGroups()
|
||||
@@ -33,6 +53,13 @@ export function AdminChannelGroupsPage() {
|
||||
}, []);
|
||||
|
||||
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
|
||||
const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleGroups = filteredGroups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [groupName, groups.length]);
|
||||
|
||||
function deleteGroup() {
|
||||
if (!deleteTarget) return;
|
||||
@@ -65,36 +92,74 @@ export function AdminChannelGroupsPage() {
|
||||
</section>
|
||||
|
||||
<section className="surface channel-group-list">
|
||||
<div className="channel-group-grid">
|
||||
{filteredGroups.map((group) => (
|
||||
<article className="channel-group-card" key={group.id}>
|
||||
<header>
|
||||
<div>
|
||||
<Layers3 size={18} />
|
||||
<strong>{group.name}</strong>
|
||||
<div className="channel-group-config-list">
|
||||
{visibleGroups.map((group) => {
|
||||
const summary = getGroupSummary(group);
|
||||
const previewItems = (group.items ?? []).slice(0, 4);
|
||||
return (
|
||||
<article className="channel-group-config-item" key={group.id}>
|
||||
<div className="channel-group-config-item__identity">
|
||||
<span className="channel-group-config-item__icon"><Layers3 size={18} /></span>
|
||||
<div>
|
||||
<strong>{group.name}</strong>
|
||||
<span>{carrierLabels[group.carrier] ?? group.carrier}通道组</span>
|
||||
</div>
|
||||
</div>
|
||||
<span title="运营商">{carrierLabels[group.carrier] ?? group.carrier}</span>
|
||||
<span title="包含通道数"><UsersRound size={16} />{group.items?.length ?? 0}</span>
|
||||
</header>
|
||||
<div className="channel-group-card__body">
|
||||
{(group.items ?? []).slice(0, 5).map((item, index) => {
|
||||
const channel = item.channel as { name?: string } | undefined;
|
||||
return <p key={`${group.id}-${index}`}>{channel?.name ?? '未命名通道'}</p>;
|
||||
})}
|
||||
{(group.items?.length ?? 0) === 0 ? <p className="muted">暂无绑定通道</p> : null}
|
||||
</div>
|
||||
<footer>
|
||||
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
|
||||
<Pencil size={15} />编辑
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
|
||||
<Trash2 size={15} />删除
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
|
||||
<div className="channel-group-config-item__metrics" aria-label="通道组配置摘要">
|
||||
<div>
|
||||
<span>省网</span>
|
||||
<strong>{summary.provinceCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>全国</span>
|
||||
<strong>{summary.nationalCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>链接正常</span>
|
||||
<strong>{summary.connectedCount}/{summary.totalCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-group-config-item__policy">
|
||||
<Tag tone={group.retryEnabled === false ? 'neutral' : 'success'}>
|
||||
{group.retryEnabled === false ? '补发关闭' : '补发开启'}
|
||||
</Tag>
|
||||
<span><Clock3 size={15} />{formatRetryLimit(group)}</span>
|
||||
<span><RadioTower size={15} />{summary.totalCount ? `${summary.totalCount} 个通道` : '暂无通道'}</span>
|
||||
</div>
|
||||
|
||||
<div className="channel-group-config-item__channels">
|
||||
{previewItems.map((item) => (
|
||||
<span key={item.id}>
|
||||
{item.province ? `${item.province} / ` : `P${item.priority} / `}
|
||||
{item.channel?.name ?? '未命名通道'}
|
||||
</span>
|
||||
))}
|
||||
{summary.totalCount > previewItems.length ? <span>+{summary.totalCount - previewItems.length}</span> : null}
|
||||
{summary.totalCount === 0 ? <span>暂无绑定通道</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="channel-group-config-item__actions">
|
||||
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
|
||||
<Pencil size={15} />编辑
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
|
||||
<Trash2 size={15} />删除
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination nextDisabled={false} page={1} total={filteredGroups.length} />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredGroups.length}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||
@@ -362,6 +362,8 @@ export function AdminChannelsPage() {
|
||||
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
||||
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
function loadChannels() {
|
||||
adminApi.listChannels()
|
||||
@@ -389,6 +391,13 @@ export function AdminChannelsPage() {
|
||||
}),
|
||||
[carrier, channels, keyword, status],
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(filteredChannels.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleChannels = filteredChannels.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [carrier, channels.length, keyword, status]);
|
||||
|
||||
async function upsertChannel(nextChannel: SmsChannel) {
|
||||
try {
|
||||
@@ -495,7 +504,7 @@ export function AdminChannelsPage() {
|
||||
<span>今日发送质量</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{filteredChannels.map((channel) => (
|
||||
{visibleChannels.map((channel) => (
|
||||
<article className="sms-channel-table__row" key={channel.id}>
|
||||
<div className="sms-channel-identity">
|
||||
<strong>{channel.name}</strong>
|
||||
@@ -531,13 +540,14 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div className="sms-channel-pagination">
|
||||
<Select options={[{ label: '10 条/页', value: '10' }, { label: '20 条/页', value: '20' }]} value="10" />
|
||||
<Button disabled size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">1</Button>
|
||||
<Button size="sm" variant="ghost">2</Button>
|
||||
<Button size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredChannels.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{modal ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||
|
||||
@@ -510,6 +510,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
@@ -537,6 +538,14 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
|
||||
&& (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword));
|
||||
}), [enterpriseKeyword, signatureKeyword, signatures]);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [enterpriseKeyword, filteredSignatures.length, signatureKeyword]);
|
||||
|
||||
async function saveSignature(state: SignatureFormState) {
|
||||
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
|
||||
@@ -609,7 +618,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
|
||||
const smsSignatureContent = (
|
||||
<div className="signature-list admin-enterprise-signature-list">
|
||||
{filteredSignatures.map((signature) => {
|
||||
{visibleSignatures.map((signature) => {
|
||||
const payload = readDrainagePayload(signature);
|
||||
const expanded = expandedSignatureId === signature.id;
|
||||
return (
|
||||
@@ -672,6 +681,14 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredSignatures.length}
|
||||
/>
|
||||
{filteredSignatures.length === 0 ? <div className="ui-table__empty">暂无企业签名</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -261,7 +261,7 @@ export function AdminHome() {
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost">关闭</Button>
|
||||
<Button onClick={() => navigate('/admin/billing')}>查看账单流水</Button>
|
||||
<Button onClick={() => navigate('/admin/recharge-records')}>查看充值记录</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setSelectedEnterprise(null)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, RadioTower, Search, Smartphone } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
type PhoneSegment = DictionaryItem & {
|
||||
@@ -123,25 +123,6 @@ export function AdminPhoneSegmentsPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="surface phone-segment-tabs">
|
||||
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button">
|
||||
<Smartphone size={18} />
|
||||
<span>
|
||||
<strong>手机号段</strong>
|
||||
<small>按号码前 7 位维护省份与城市</small>
|
||||
</span>
|
||||
<em>{segments.length}</em>
|
||||
</button>
|
||||
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button">
|
||||
<RadioTower size={18} />
|
||||
<span>
|
||||
<strong>运营商区分规则</strong>
|
||||
<small>按前缀正则识别移动、联通、电信</small>
|
||||
</span>
|
||||
<em>{rules.length}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar phone-segment-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
|
||||
@@ -150,9 +131,14 @@ export function AdminPhoneSegmentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card">
|
||||
{activeTab === 'segments'
|
||||
? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
|
||||
: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" />}
|
||||
<Tabs
|
||||
onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
|
||||
value={activeTab}
|
||||
items={[
|
||||
{ label: `手机号段 ${segments.length}`, value: 'segments', content: <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" /> },
|
||||
{ label: `运营商区分规则 ${rules.length}`, value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { adminApi, type AccountTransaction, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||
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 TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||
|
||||
type ManualRechargeForm = {
|
||||
tenantId: string;
|
||||
@@ -37,12 +37,12 @@ function RemarkCell({ value }: { value?: string }) {
|
||||
export function AdminRechargeRecordsPage() {
|
||||
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
||||
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
|
||||
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -50,16 +50,14 @@ export function AdminRechargeRecordsPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [nextTenants, nextRecords, nextAccounts, nextTransactions] = await Promise.all([
|
||||
const [nextTenants, nextRecords, nextAccounts] = await Promise.all([
|
||||
adminApi.listTenants(),
|
||||
adminApi.listManualRecharges(),
|
||||
adminApi.listAccounts(),
|
||||
adminApi.listTransactions(),
|
||||
]);
|
||||
setTenants(nextTenants);
|
||||
setRecords(nextRecords);
|
||||
setAccounts(nextAccounts);
|
||||
setTransactions(nextTransactions);
|
||||
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
||||
@@ -84,6 +82,14 @@ export function AdminRechargeRecordsPage() {
|
||||
}),
|
||||
[dateRange.end, dateRange.start, enterpriseKeyword, records, tenants],
|
||||
);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [dateRange.end, dateRange.start, enterpriseKeyword, records.length]);
|
||||
|
||||
function resetFilters() {
|
||||
setEnterpriseKeyword('');
|
||||
@@ -151,16 +157,15 @@ export function AdminRechargeRecordsPage() {
|
||||
<tr><td className="ui-table__empty" colSpan={7}>正在加载真实充值记录...</td></tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={7}>暂无真实充值记录</td></tr>
|
||||
) : filteredRows.map((record) => {
|
||||
) : visibleRows.map((record) => {
|
||||
const account = accounts.find((item) => item.tenantId === record.tenantId);
|
||||
const transaction = transactions.find((item) => item.relatedId === record.id);
|
||||
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td><strong>{tenantName}</strong></td>
|
||||
<td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td>
|
||||
<td>{formatAmount(record.amountCents / 100)}</td>
|
||||
<td>{formatAmount((transaction?.balanceAfter ?? account?.balanceCents ?? 0) / 100)}</td>
|
||||
<td>{formatAmount((account?.balanceCents ?? 0) / 100)}</td>
|
||||
<td><Tag tone="warning">人工充值</Tag></td>
|
||||
<td>{record.operatorId || '运营'}</td>
|
||||
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
||||
@@ -171,18 +176,14 @@ export function AdminRechargeRecordsPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="admin-recharge-pagination">
|
||||
<Select options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }]} value="10" />
|
||||
<div>
|
||||
<Button icon={<ChevronLeft size={16} />} iconOnly variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="ghost">24</Button>
|
||||
<Button size="sm">25</Button>
|
||||
<Button size="sm" variant="ghost">26</Button>
|
||||
<span>...</span>
|
||||
<Button size="sm" variant="ghost">63</Button>
|
||||
<Button icon={<ChevronRight size={16} />} iconOnly variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredRows.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{manualOpen ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
delivered: '发送成功',
|
||||
@@ -104,7 +104,6 @@ export function AdminSmsRecordsPage() {
|
||||
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" />
|
||||
<Pagination total={filteredRows.length} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -329,6 +329,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
|
||||
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -371,6 +372,14 @@ export function AdminSmsTaskProgressPage() {
|
||||
}),
|
||||
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks],
|
||||
);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
|
||||
|
||||
function resetFilters() {
|
||||
setKeyword('');
|
||||
@@ -439,7 +448,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
<tr><td className="ui-table__empty" colSpan={8}>正在加载真实短信任务...</td></tr>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>暂无短信任务</td></tr>
|
||||
) : filteredTasks.map((record) => {
|
||||
) : visibleTasks.map((record) => {
|
||||
const progress = getProgress(record);
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
|
||||
@@ -518,7 +527,14 @@ export function AdminSmsTaskProgressPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredTasks.length} />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredTasks.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Table,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
@@ -225,7 +224,6 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
|
||||
<div className="surface admin-uplink-table-card">
|
||||
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
|
||||
<Pagination total={filteredMessages.length} />
|
||||
</div>
|
||||
|
||||
{selectedMessage ? (
|
||||
|
||||
@@ -127,7 +127,7 @@ export function AdminSystemLogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" />
|
||||
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ClipboardCopy, FileText } from 'lucide-react';
|
||||
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
|
||||
import { Button, Modal, Tag } from '@/components/ui';
|
||||
import { Button, Modal, Pagination, Tag } from '@/components/ui';
|
||||
|
||||
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
|
||||
|
||||
@@ -61,6 +61,7 @@ export function ClientApplicationsPage() {
|
||||
const [params, setParams] = useState<ApplicationCmppParams | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [paramsLoading, setParamsLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState('');
|
||||
const [paramsError, setParamsError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -96,6 +97,14 @@ export function ClientApplicationsPage() {
|
||||
}
|
||||
|
||||
const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(applications.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleApplications = applications.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [applications.length]);
|
||||
|
||||
function copyParams() {
|
||||
if (selectedRows.length === 0) {
|
||||
@@ -123,7 +132,7 @@ export function ClientApplicationsPage() {
|
||||
) : null}
|
||||
|
||||
<div className="sms-app-grid">
|
||||
{applications.map((application) => {
|
||||
{visibleApplications.map((application) => {
|
||||
const linkStatus = normalizeStatus(application);
|
||||
return (
|
||||
<article className="sms-app-card" key={application.id}>
|
||||
@@ -155,6 +164,14 @@ export function ClientApplicationsPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={applications.length}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
footer={<Button disabled={!params} icon={<ClipboardCopy size={16} />} onClick={copyParams}>{copied ? '已复制' : '复制参数'}</Button>}
|
||||
|
||||
@@ -103,6 +103,7 @@ export function ClientBatchTasksPage() {
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
|
||||
|
||||
function loadTasks() {
|
||||
@@ -136,6 +137,14 @@ export function ClientBatchTasksPage() {
|
||||
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
|
||||
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [application, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
|
||||
|
||||
function terminateTask(id: string) {
|
||||
const source = tasks.find((item) => item.id === id);
|
||||
@@ -253,7 +262,7 @@ export function ClientBatchTasksPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.map((record, index) => {
|
||||
{visibleTasks.map((record, index) => {
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
|
||||
return (
|
||||
@@ -284,7 +293,14 @@ export function ClientBatchTasksPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredTasks.length} />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredTasks.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { Button, Tag } from '@/components/ui';
|
||||
import { Button, Pagination, Tag } from '@/components/ui';
|
||||
import { clientApi, type BillingPlan } from '@/api/adminApi';
|
||||
|
||||
export function ClientBillingPage() {
|
||||
const [plans, setPlans] = useState<BillingPlan[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(plans.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visiblePlans = plans.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -19,6 +24,10 @@ export function ClientBillingPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [plans.length]);
|
||||
|
||||
function createOrder(plan: BillingPlan) {
|
||||
clientApi.createOrder({ planId: plan.id, amountCents: plan.amountCents, smsUnits: plan.smsUnits, payMethod: 'manual' })
|
||||
.catch((reason: Error) => setError(reason.message || '充值订单创建失败'));
|
||||
@@ -36,7 +45,7 @@ export function ClientBillingPage() {
|
||||
{loading ? <p className="muted">正在加载充值套餐...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="plan-grid">
|
||||
{plans.map((plan) => (
|
||||
{visiblePlans.map((plan) => (
|
||||
<article className={['plan-card', plan.smsUnits >= 100000 ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
|
||||
<div className="section-heading">
|
||||
<h2>{plan.name}</h2>
|
||||
@@ -50,6 +59,14 @@ export function ClientBillingPage() {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={plans.length}
|
||||
/>
|
||||
{!loading && !error && plans.length === 0 ? <p className="muted">暂无可用充值套餐。</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { clientApi, type AccountTransaction, type RechargeOrder } from '@/api/adminApi';
|
||||
|
||||
type Invoice = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: number;
|
||||
amount: number;
|
||||
createdAt: string;
|
||||
status: 'paid' | 'pending' | 'failed';
|
||||
};
|
||||
|
||||
const statusToneMap: Record<Invoice['status'], 'success' | 'info' | 'danger'> = {
|
||||
paid: 'success',
|
||||
pending: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<Invoice['status'], string> = {
|
||||
paid: '已支付',
|
||||
pending: '处理中',
|
||||
failed: '支付失败',
|
||||
};
|
||||
|
||||
const columns: Array<TableColumn<Invoice>> = [
|
||||
{ key: 'id', title: '流水号', render: (record) => record.id },
|
||||
{ key: 'title', title: '项目', render: (record) => record.title },
|
||||
{ key: 'messages', title: '短信条数', render: (record) => `${record.messages.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'amount', title: '金额', render: (record) => `¥${record.amount.toLocaleString('zh-CN')}` },
|
||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
];
|
||||
|
||||
export function ClientInvoicesPage() {
|
||||
const [orders, setOrders] = useState<RechargeOrder[]>([]);
|
||||
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listOrders(), clientApi.listTransactions()])
|
||||
.then(([orderItems, transactionItems]) => {
|
||||
setOrders(orderItems);
|
||||
setTransactions(transactionItems);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '账单流水加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const rows = useMemo<Invoice[]>(() => [
|
||||
...orders.map((item) => ({
|
||||
id: item.orderNo,
|
||||
title: item.payMethod === 'manual_topup' ? '人工充值' : '充值订单',
|
||||
messages: item.smsUnits,
|
||||
amount: item.amountCents / 100,
|
||||
createdAt: item.createdAt,
|
||||
status: item.status === 'paid' ? 'paid' as const : item.status === 'failed' ? 'failed' as const : 'pending' as const,
|
||||
})),
|
||||
...transactions.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.remark ?? item.transactionType,
|
||||
messages: item.smsUnits,
|
||||
amount: item.amountCents / 100,
|
||||
createdAt: item.createdAt,
|
||||
status: 'paid' as const,
|
||||
})),
|
||||
].sort((left, right) => right.createdAt.localeCompare(left.createdAt)), [orders, transactions]);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">账户</p>
|
||||
<h1>账单流水</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
{loading ? <p className="muted">正在加载账单流水...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Table columns={columns} data={rows} emptyText="暂无账单流水" rowKey="id" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import {
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Pagination,
|
||||
QueryPanel,
|
||||
Select,
|
||||
Tag,
|
||||
@@ -58,6 +59,7 @@ export function ClientSendDetailPage() {
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -100,6 +102,14 @@ export function ClientSendDetailPage() {
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
return matchesStartDate && matchesEndDate && matchesContent;
|
||||
});
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [contentKeyword, dateRange.end, dateRange.start, records.length]);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
@@ -166,7 +176,7 @@ export function ClientSendDetailPage() {
|
||||
<tr><td className="ui-table__empty" colSpan={9}>正在加载真实发送记录...</td></tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
||||
) : filteredRows.map((record) => {
|
||||
) : visibleRows.map((record) => {
|
||||
const receipt = getReceipt(record);
|
||||
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
|
||||
const region = record.channel?.sendRegion ?? '-';
|
||||
@@ -214,6 +224,14 @@ export function ClientSendDetailPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredRows.length}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, FileActions, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { Button, FileActions, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
@@ -37,6 +37,7 @@ export function ClientSignaturesPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
@@ -57,6 +58,14 @@ export function ClientSignaturesPage() {
|
||||
const filteredSignatures = useMemo(() => signatures.filter((item) => (
|
||||
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
|
||||
)), [keyword, signatures]);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [filteredSignatures.length, keyword]);
|
||||
|
||||
async function createSignature() {
|
||||
try {
|
||||
@@ -114,7 +123,7 @@ export function ClientSignaturesPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="signature-list">
|
||||
{filteredSignatures.map((signature) => (
|
||||
{visibleSignatures.map((signature) => (
|
||||
<article className="signature-card signature-card--green" key={signature.id}>
|
||||
<div className="signature-summary">
|
||||
<div>
|
||||
@@ -143,6 +152,14 @@ export function ClientSignaturesPage() {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredSignatures.length}
|
||||
/>
|
||||
{!loading && !error && filteredSignatures.length === 0 ? <p className="muted">暂无签名记录。</p> : null}
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ClientSystemLogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" />
|
||||
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
|
||||
type TemplateVariable = {
|
||||
@@ -180,6 +180,7 @@ export function ClientTemplatesPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
@@ -201,6 +202,14 @@ export function ClientTemplatesPage() {
|
||||
const filteredTemplates = useMemo(() => templates.filter((item) => (
|
||||
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
|
||||
)), [keyword, templates]);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [filteredTemplates.length, keyword]);
|
||||
|
||||
async function saveTemplate(state: TemplateFormState) {
|
||||
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
|
||||
@@ -254,7 +263,7 @@ export function ClientTemplatesPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="template-card-grid">
|
||||
{filteredTemplates.map((template) => {
|
||||
{visibleTemplates.map((template) => {
|
||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
|
||||
return (
|
||||
<article className="template-card template-card--green" key={template.id}>
|
||||
@@ -283,6 +292,14 @@ export function ClientTemplatesPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredTemplates.length}
|
||||
/>
|
||||
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted">暂无短信模板。</p> : null}
|
||||
|
||||
{modalTemplate ? (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
||||
import { readSession } from '@/api/session';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type UserForm = {
|
||||
displayName: string;
|
||||
@@ -154,7 +154,6 @@ export function ClientUsersPage() {
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
<Pagination total={filteredUsers.length} page={1} />
|
||||
</div>
|
||||
|
||||
{(creating || editingUser) ? (
|
||||
|
||||
@@ -5,7 +5,6 @@ const pageTitleMap: Record<string, string> = {
|
||||
'/client/templates': '模板管理',
|
||||
'/client/signatures': '签名与引流信息',
|
||||
'/client/billing': '充值套餐',
|
||||
'/client/invoices': '账单流水',
|
||||
'/client/settings': '账号设置',
|
||||
'/admin/monitor': '发送监控',
|
||||
'/admin/analytics': '数据统计',
|
||||
@@ -13,7 +12,6 @@ const pageTitleMap: Record<string, string> = {
|
||||
'/admin/templates': '模板审核',
|
||||
'/admin/signatures': '签名审核',
|
||||
'/admin/channels': '通道管理',
|
||||
'/admin/billing': '账单流水',
|
||||
'/admin/settings': '系统配置',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pagination } from './PagePrimitives';
|
||||
|
||||
export type TableColumn<T> = {
|
||||
key: string;
|
||||
@@ -13,13 +15,26 @@ type TableProps<T> = {
|
||||
data: T[];
|
||||
rowKey: keyof T | ((record: T) => string);
|
||||
emptyText?: string;
|
||||
pagination?: boolean;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }: TableProps<T>) {
|
||||
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据', pageSize = 10, pagination = true }: TableProps<T>) {
|
||||
const [page, setPage] = useState(1);
|
||||
const minimumTableWidth = columns.reduce((sum, column) => {
|
||||
const match = column.width?.match(/^(\d+)px$/);
|
||||
return sum + (match ? Number(match[1]) : 0);
|
||||
}, 0);
|
||||
const totalPages = Math.max(1, Math.ceil(data.length / pageSize));
|
||||
const activePage = Math.min(page, totalPages);
|
||||
const visibleData = useMemo(
|
||||
() => pagination ? data.slice((activePage - 1) * pageSize, activePage * pageSize) : data,
|
||||
[activePage, data, pageSize, pagination],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [data, pageSize]);
|
||||
|
||||
function getRowKey(record: T) {
|
||||
if (typeof rowKey === 'function') {
|
||||
@@ -50,14 +65,14 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
{visibleData.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={columns.length}>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((record, index) => (
|
||||
visibleData.map((record, index) => (
|
||||
<tr key={getRowKey(record)}>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
@@ -72,6 +87,16 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{pagination && data.length > pageSize ? (
|
||||
<Pagination
|
||||
nextDisabled={activePage >= totalPages}
|
||||
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={activePage}
|
||||
previousDisabled={activePage <= 1}
|
||||
total={data.length}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,6 @@ export function AdminLayout() {
|
||||
{ label: '彩信记录', to: '/admin/mms-records', icon: ImageIcon, pending: true },
|
||||
{ label: '短信上行记录', to: '/admin/sms-uplink-records', icon: MessageSquare },
|
||||
{ label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText },
|
||||
{ label: '账单流水', to: '/admin/billing', icon: ReceiptText },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -65,7 +65,6 @@ export function ClientLayout() {
|
||||
title: '账户',
|
||||
items: [
|
||||
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign },
|
||||
{ label: '账单流水', to: '/client/invoices', icon: ReceiptText },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AdminAnalyticsPage } from '@/apps/admin/AdminAnalyticsPage';
|
||||
import { AdminBillingPage } from '@/apps/admin/AdminBillingPage';
|
||||
import { AdminChannelGroupFormPage } from '@/apps/admin/AdminChannelGroupFormPage';
|
||||
import { AdminChannelGroupsPage } from '@/apps/admin/AdminChannelGroupsPage';
|
||||
import { AdminChannelsPage } from '@/apps/admin/AdminChannelsPage';
|
||||
@@ -35,7 +34,6 @@ import { ClientBatchTasksPage } from '@/apps/client/ClientBatchTasksPage';
|
||||
import { ClientBillingPage } from '@/apps/client/ClientBillingPage';
|
||||
import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage';
|
||||
import { ClientHome } from '@/apps/client/ClientHome';
|
||||
import { ClientInvoicesPage } from '@/apps/client/ClientInvoicesPage';
|
||||
import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage';
|
||||
import { ClientSendPage } from '@/apps/client/ClientSendPage';
|
||||
import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage';
|
||||
@@ -70,7 +68,6 @@ export function AppRoutes() {
|
||||
<Route path="mms-send-detail" element={<PagePlaceholder />} />
|
||||
<Route path="mms-uplink-messages" element={<PagePlaceholder />} />
|
||||
<Route path="billing" element={<ClientBillingPage />} />
|
||||
<Route path="invoices" element={<ClientInvoicesPage />} />
|
||||
<Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} />
|
||||
<Route path="users" element={<ClientUsersPage />} />
|
||||
<Route path="system-logs" element={<ClientSystemLogsPage />} />
|
||||
@@ -120,7 +117,6 @@ export function AppRoutes() {
|
||||
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
|
||||
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
|
||||
<Route path="system-logs" element={<AdminSystemLogsPage />} />
|
||||
<Route path="billing" element={<AdminBillingPage />} />
|
||||
<Route path="*" element={<PagePlaceholder />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+158
-82
@@ -6828,105 +6828,141 @@ h3 {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.channel-group-grid {
|
||||
.channel-group-config-list {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.channel-group-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(210px, 1fr) auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-group-card header {
|
||||
align-items: center;
|
||||
background: #e8f2ff;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.channel-group-card header > div,
|
||||
.channel-group-card header > span {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-group-card header strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-group-card header > span {
|
||||
color: var(--color-text-muted);
|
||||
flex: 0 0 auto;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.channel-group-card__body {
|
||||
color: var(--color-text-muted);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.channel-group-card__body p {
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-group-card__body small {
|
||||
color: var(--color-selected);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.channel-group-card footer {
|
||||
align-items: center;
|
||||
background: var(--color-surface-subtle);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.channel-group-card footer button {
|
||||
.channel-group-config-item {
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(180px, 1.15fr) minmax(210px, 0.9fr) minmax(200px, 0.95fr) minmax(220px, 1.2fr) minmax(82px, auto);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.channel-group-config-item__identity {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-group-config-item__icon {
|
||||
align-items: center;
|
||||
background: #eef5ff;
|
||||
border: 1px solid #cfe2ff;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-selected);
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
height: 38px;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
.channel-group-config-item__identity div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-group-config-item__identity strong {
|
||||
color: var(--color-text-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-group-config-item__identity span:last-child,
|
||||
.channel-group-config-item__metrics span,
|
||||
.channel-group-config-item__policy span,
|
||||
.channel-group-config-item__channels span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.channel-group-config-item__metrics {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-group-config-item__metrics div {
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.channel-group-config-item__metrics strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.channel-group-config-item__policy {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-group-config-item__policy span {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-group-config-item__channels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-group-config-item__channels span {
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-group-config-item__actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
justify-self: end;
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.channel-group-config-item__actions button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-selected);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
gap: var(--space-1);
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.channel-group-card footer button.is-danger {
|
||||
.channel-group-config-item__actions button.is-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.channel-group-card footer button:hover {
|
||||
background: var(--color-accent-soft);
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.channel-group-form-page {
|
||||
min-width: 1040px;
|
||||
}
|
||||
@@ -6950,6 +6986,7 @@ h3 {
|
||||
}
|
||||
|
||||
.channel-group-base-form > .ui-field,
|
||||
.channel-group-retry-limit,
|
||||
.channel-group-radio-row,
|
||||
.channel-group-switch-row {
|
||||
grid-column: 2;
|
||||
@@ -7010,11 +7047,14 @@ h3 {
|
||||
}
|
||||
|
||||
.channel-group-row-actions button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-selected);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
gap: var(--space-1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -7022,6 +7062,23 @@ h3 {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.channel-group-retry-limit {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: 150px 150px minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
.channel-group-retry-limit > span {
|
||||
color: var(--color-text-strong);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.channel-group-retry-limit small {
|
||||
align-self: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.channel-route-card-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
@@ -7104,6 +7161,25 @@ h3 {
|
||||
.channel-route-modal {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-route-modal__channel-select {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.channel-route-modal .ui-select__dropdown {
|
||||
max-height: min(360px, calc(100vh - 360px));
|
||||
}
|
||||
|
||||
.channel-route-modal .ui-select__dropdown button {
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
line-height: 1.45;
|
||||
min-height: 42px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.channel-route-modal__note {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { PrismaPg } from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
|
||||
import { PrismaClient } from '../../api/node_modules/@prisma/client/index.js';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg(databaseUrl) });
|
||||
const username = process.env.PROD_ADMIN_USERNAME || 'prod_admin';
|
||||
const email = process.env.PROD_ADMIN_EMAIL || 'admin@example.com';
|
||||
const password = process.env.PROD_ADMIN_PASSWORD || randomBytes(18).toString('base64url');
|
||||
const credentialFile = process.env.PROD_ADMIN_CREDENTIAL_FILE;
|
||||
|
||||
function hashPassword(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { code: 'platform_admin' },
|
||||
update: { name: '平台管理员', scope: 'platform' },
|
||||
create: { code: 'platform_admin', name: '平台管理员', scope: 'platform' },
|
||||
});
|
||||
const user = await prisma.user.upsert({
|
||||
where: { username },
|
||||
update: {
|
||||
email,
|
||||
displayName: '生产平台管理员',
|
||||
passwordHash: hashPassword(password),
|
||||
status: 'active',
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
deletedAt: null,
|
||||
tenantId: null,
|
||||
},
|
||||
create: {
|
||||
username,
|
||||
email,
|
||||
displayName: '生产平台管理员',
|
||||
passwordHash: hashPassword(password),
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
await prisma.userRole.upsert({
|
||||
where: { userId_roleId: { userId: user.id, roleId: role.id } },
|
||||
update: {},
|
||||
create: { userId: user.id, roleId: role.id },
|
||||
});
|
||||
|
||||
const message = [
|
||||
'CMPP production admin account',
|
||||
`username=${username}`,
|
||||
`email=${email}`,
|
||||
`password=${password}`,
|
||||
`generatedAt=${new Date().toISOString()}`,
|
||||
'',
|
||||
].join('\n');
|
||||
if (credentialFile) {
|
||||
writeFileSync(credentialFile, message, { mode: 0o600 });
|
||||
}
|
||||
console.log(`Production admin is ready: ${email}`);
|
||||
if (!credentialFile) {
|
||||
console.log(`Temporary password: ${password}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.finally(() => prisma.$disconnect())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/cmpp-platform}"
|
||||
REPO_URL="${REPO_URL:-http://175.27.255.91:3000/hectorzhao/lislgosms.git}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
PUBLIC_HTTP_PORT="${PUBLIC_HTTP_PORT:-12026}"
|
||||
API_PORT="${API_PORT:-3000}"
|
||||
GATEWAY_CONTROL_ADDR="${GATEWAY_CONTROL_ADDR:-127.0.0.1:8090}"
|
||||
GATEWAY_CMPP_ADDR="${GATEWAY_CMPP_ADDR:-0.0.0.0:17890}"
|
||||
DB_NAME="${DB_NAME:-cmpp_platform}"
|
||||
DB_USER="${DB_USER:-cmpp}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -base64 24 | tr -d '\n')}"
|
||||
MINIO_ROOT_USER="${MINIO_ROOT_USER:-cmpp_minio}"
|
||||
MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-$(openssl rand -base64 32 | tr -d '\n')}"
|
||||
MINIO_BUCKET="${MINIO_BUCKET:-cmpp-platform}"
|
||||
PROD_ADMIN_EMAIL="${PROD_ADMIN_EMAIL:-admin@example.com}"
|
||||
PROD_ADMIN_USERNAME="${PROD_ADMIN_USERNAME:-prod_admin}"
|
||||
PROD_ADMIN_PASSWORD="${PROD_ADMIN_PASSWORD:-$(openssl rand -base64 18 | tr -d '\n')}"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
|
||||
install_packages() {
|
||||
log "Installing OS packages"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg git nginx redis-server postgresql postgresql-contrib build-essential tar gzip openssl
|
||||
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v | sed 's/^v//' | cut -d. -f1)" -lt 22 ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
fi
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y ca-certificates curl git nginx redis postgresql-server postgresql-contrib gcc gcc-c++ make tar gzip openssl
|
||||
if [[ ! -d /var/lib/pgsql/data/base ]]; then
|
||||
postgresql-setup --initdb
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v | sed 's/^v//' | cut -d. -f1)" -lt 22 ]]; then
|
||||
curl -fsSL https://rpm.nodesource.com/setup_22.x | bash -
|
||||
dnf install -y nodejs
|
||||
fi
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y ca-certificates curl git nginx redis postgresql-server postgresql-contrib gcc gcc-c++ make tar gzip openssl
|
||||
if [[ ! -d /var/lib/pgsql/data/base ]]; then
|
||||
postgresql-setup initdb
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v | sed 's/^v//' | cut -d. -f1)" -lt 22 ]]; then
|
||||
curl -fsSL https://rpm.nodesource.com/setup_22.x | bash -
|
||||
yum install -y nodejs
|
||||
fi
|
||||
else
|
||||
echo "Unsupported Linux distribution: apt-get/dnf/yum not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_go() {
|
||||
local version="${GO_VERSION:-1.26.0}"
|
||||
if command -v go >/dev/null 2>&1 && [[ "$(go version | awk '{print $3}' | sed 's/go//')" == "$version" ]]; then
|
||||
return
|
||||
fi
|
||||
log "Installing Go ${version}"
|
||||
curl -fL "https://go.dev/dl/go${version}.linux-amd64.tar.gz" -o /tmp/go.tar.gz
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||
}
|
||||
|
||||
install_minio() {
|
||||
log "Installing MinIO"
|
||||
curl -fL https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio
|
||||
chmod +x /usr/local/bin/minio
|
||||
useradd --system --home /var/lib/minio --shell /usr/sbin/nologin minio 2>/dev/null || true
|
||||
mkdir -p /var/lib/minio
|
||||
chown -R minio:minio /var/lib/minio
|
||||
}
|
||||
|
||||
start_infra() {
|
||||
log "Starting PostgreSQL and Redis"
|
||||
systemctl enable --now postgresql || systemctl enable --now postgresql.service
|
||||
systemctl enable --now redis-server 2>/dev/null || systemctl enable --now redis
|
||||
runuser -u postgres -- psql <<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${DB_USER}') THEN
|
||||
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}';
|
||||
ELSE
|
||||
ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${DB_PASSWORD}';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\\gexec
|
||||
ALTER DATABASE ${DB_NAME} OWNER TO ${DB_USER};
|
||||
SQL
|
||||
}
|
||||
|
||||
write_env() {
|
||||
log "Writing production environment"
|
||||
mkdir -p /etc/cmpp-platform "$APP_DIR" "$APP_DIR/logs/api" "$APP_DIR/logs/gateway" "$APP_DIR/backups"
|
||||
cat >/etc/cmpp-platform/cmpp-platform.env <<EOF
|
||||
NODE_ENV=production
|
||||
API_PORT=${API_PORT}
|
||||
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@127.0.0.1:5432/${DB_NAME}?schema=public
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
MINIO_ENDPOINT=127.0.0.1:9000
|
||||
MINIO_ACCESS_KEY=${MINIO_ROOT_USER}
|
||||
MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD}
|
||||
MINIO_BUCKET=${MINIO_BUCKET}
|
||||
OBJECT_STORAGE_DRIVER=minio
|
||||
GATEWAY_CONTROL_URL=http://127.0.0.1:8090
|
||||
GATEWAY_HEALTH_ADDR=${GATEWAY_CONTROL_ADDR}
|
||||
GATEWAY_CMPP_ADDR=${GATEWAY_CMPP_ADDR}
|
||||
API_BASE_URL=http://127.0.0.1:${API_PORT}/api
|
||||
EOF
|
||||
chmod 600 /etc/cmpp-platform/cmpp-platform.env
|
||||
cat >/etc/cmpp-platform/minio.env <<EOF
|
||||
MINIO_ROOT_USER=${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
|
||||
EOF
|
||||
chmod 600 /etc/cmpp-platform/minio.env
|
||||
}
|
||||
|
||||
write_services() {
|
||||
log "Writing systemd and nginx configuration"
|
||||
cat >/etc/systemd/system/cmpp-minio.service <<'EOF'
|
||||
[Unit]
|
||||
Description=CMPP MinIO object storage
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=minio
|
||||
Group=minio
|
||||
EnvironmentFile=/etc/cmpp-platform/minio.env
|
||||
ExecStart=/usr/local/bin/minio server /var/lib/minio --address 127.0.0.1:9000 --console-address 127.0.0.1:9001
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat >/etc/systemd/system/cmpp-api.service <<EOF
|
||||
[Unit]
|
||||
Description=CMPP Platform API
|
||||
After=network.target postgresql.service redis.service cmpp-minio.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=${APP_DIR}/api
|
||||
EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env
|
||||
ExecStart=/usr/bin/node dist/main.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${APP_DIR}/logs/api/stdout.log
|
||||
StandardError=append:${APP_DIR}/logs/api/stderr.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat >/etc/systemd/system/cmpp-gateway.service <<EOF
|
||||
[Unit]
|
||||
Description=CMPP Gateway control service
|
||||
After=network.target cmpp-api.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=${APP_DIR}
|
||||
EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env
|
||||
ExecStart=${APP_DIR}/dist/cmpp-gateway
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${APP_DIR}/logs/gateway/stdout.log
|
||||
StandardError=append:${APP_DIR}/logs/gateway/stderr.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat >/etc/nginx/conf.d/cmpp-platform.conf <<EOF
|
||||
server {
|
||||
listen ${PUBLIC_HTTP_PORT};
|
||||
server_name _;
|
||||
root ${APP_DIR}/dist;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:${API_PORT}/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
nginx -t
|
||||
systemctl enable nginx
|
||||
}
|
||||
|
||||
checkout_code() {
|
||||
log "Checking out application code"
|
||||
if [[ -d "$APP_DIR/.git" ]]; then
|
||||
git -C "$APP_DIR" fetch origin "$BRANCH"
|
||||
git -C "$APP_DIR" checkout "$BRANCH"
|
||||
git -C "$APP_DIR" reset --hard "origin/$BRANCH"
|
||||
else
|
||||
rm -rf "$APP_DIR"
|
||||
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
run_deploy() {
|
||||
log "Building and deploying application"
|
||||
PROD_ADMIN_EMAIL="$PROD_ADMIN_EMAIL" \
|
||||
PROD_ADMIN_USERNAME="$PROD_ADMIN_USERNAME" \
|
||||
PROD_ADMIN_PASSWORD="$PROD_ADMIN_PASSWORD" \
|
||||
bash "$APP_DIR/tools/deploy/production-deploy.sh"
|
||||
}
|
||||
|
||||
install_packages
|
||||
install_go
|
||||
install_minio
|
||||
start_infra
|
||||
write_env
|
||||
write_services
|
||||
checkout_code
|
||||
run_deploy
|
||||
|
||||
cat >/root/cmpp-platform-credentials.txt <<EOF
|
||||
CMPP production credentials
|
||||
admin_url=http://$(hostname -I | awk '{print $1}'):${PUBLIC_HTTP_PORT}/admin/login
|
||||
admin_username=${PROD_ADMIN_USERNAME}
|
||||
admin_email=${PROD_ADMIN_EMAIL}
|
||||
admin_password=${PROD_ADMIN_PASSWORD}
|
||||
database=postgresql://${DB_USER}:***@127.0.0.1:5432/${DB_NAME}
|
||||
minio_user=${MINIO_ROOT_USER}
|
||||
minio_password=${MINIO_ROOT_PASSWORD}
|
||||
EOF
|
||||
chmod 600 /root/cmpp-platform-credentials.txt
|
||||
|
||||
log "Production bootstrap finished"
|
||||
echo "Credentials saved to /root/cmpp-platform-credentials.txt"
|
||||
echo "Frontend: http://<server-ip>:${PUBLIC_HTTP_PORT}"
|
||||
echo "Gateway control health: http://127.0.0.1:8090/health"
|
||||
echo "CMPP inbound port variable reserved: ${GATEWAY_CMPP_ADDR}"
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/cmpp-platform}"
|
||||
ENV_FILE="${ENV_FILE:-/etc/cmpp-platform/cmpp-platform.env}"
|
||||
ADMIN_CREDENTIAL_FILE="${ADMIN_CREDENTIAL_FILE:-/root/cmpp-platform-admin.txt}"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Missing environment file: $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "[deploy] Installing dependencies"
|
||||
npm ci
|
||||
npm --prefix api ci
|
||||
|
||||
echo "[deploy] Generating Prisma client and applying migrations"
|
||||
npm --prefix api run prisma:generate
|
||||
npm --prefix api run prisma:migrate:deploy
|
||||
|
||||
echo "[deploy] Building frontend, API and gateway"
|
||||
npm run build
|
||||
npm --prefix api run build
|
||||
(cd gateway && /usr/local/bin/go build -o "$APP_DIR/dist/cmpp-gateway" ./cmd/gateway)
|
||||
|
||||
echo "[deploy] Ensuring production admin"
|
||||
PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-production-admin.mjs
|
||||
chmod 600 "$ADMIN_CREDENTIAL_FILE" || true
|
||||
|
||||
echo "[deploy] Restarting services"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cmpp-minio cmpp-api cmpp-gateway nginx
|
||||
systemctl restart cmpp-minio
|
||||
systemctl restart cmpp-api
|
||||
systemctl restart cmpp-gateway
|
||||
systemctl restart nginx
|
||||
|
||||
echo "[deploy] Health checks"
|
||||
sleep 3
|
||||
curl -fsS "http://127.0.0.1:${API_PORT:-3000}/api/health" >/dev/null
|
||||
curl -fsS "http://127.0.0.1:8090/health" >/dev/null
|
||||
redis-cli -h "${REDIS_HOST:-127.0.0.1}" -p "${REDIS_PORT:-6379}" ping >/dev/null
|
||||
pg_isready -d "$DATABASE_URL" >/dev/null
|
||||
|
||||
echo "[deploy] Done"
|
||||
Reference in New Issue
Block a user