fix: align channel copy and gateway connection state
This commit is contained in:
@@ -569,7 +569,7 @@ describe('ChannelsService', () => {
|
||||
|
||||
const copied = await service.copyChannel('channel-1', { operatorId: 'admin-1' });
|
||||
|
||||
expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本' }));
|
||||
expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本', status: 'disabled' }));
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
cmppVersion: source.cmppVersion,
|
||||
rateLimitPerSecond: source.rateLimitPerSecond,
|
||||
unitPrice: source.unitPrice,
|
||||
status: source.status,
|
||||
status: 'disabled',
|
||||
config: source.config as Prisma.InputJsonValue | undefined,
|
||||
reportFields: {
|
||||
create: source.reportFields.map((field) => ({
|
||||
@@ -396,6 +396,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
detail: {
|
||||
sourceChannelId: source.id,
|
||||
sourceCode: source.code,
|
||||
sourceStatus: source.status,
|
||||
copiedStatus: 'disabled',
|
||||
copiedReportFields: source.reportFields.length,
|
||||
copiedReportMaterials: reportMaterials.length,
|
||||
} as Prisma.InputJsonValue,
|
||||
|
||||
@@ -415,12 +415,12 @@
|
||||
|
||||
- 支持新增、编辑、删除、启用、停用短信通道。
|
||||
- 删除通道采用软删除或停用归档,不能破坏历史发送、报备、日志外键;删除、启用、停用、复制等高影响操作必须二次确认并写系统日志。
|
||||
- 支持复制通道:复制后新建一个通道,除 id/code 自动生成外,通道配置、CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段配置均需从源通道复制;名称默认追加“副本”。
|
||||
- 支持复制通道:复制后新建一个通道,除 id/code 自动生成外,通道配置、CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段配置均需从源通道复制;名称默认追加“副本”;若源通道为 active,新副本默认保存为 disabled,避免复制后立即占用上游连接。
|
||||
- 支持发送测试短信。
|
||||
- 支持查看通道成功率、未知率、失败率、累计发送量。
|
||||
- 支持进入通道报备详情。
|
||||
- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。
|
||||
- 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。
|
||||
- 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。通道连接状态、连接数和最近错误必须来自 Gateway 真实上游连接池回写,不能以一次性探测拨号成功代替长连接在线状态。
|
||||
- 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。
|
||||
|
||||
### 5.14 运营端通道组管理
|
||||
@@ -1331,7 +1331,7 @@
|
||||
### 1. 登录入口
|
||||
|
||||
- 客户端和运营端必须使用独立登录页面:客户端 `/client/login`,运营端 `/admin/login`。
|
||||
- 两端登录均输入邮箱或手机号、密码和图形验证码。
|
||||
- 两端登录均输入用户名、邮箱或手机号,外加密码和图形验证码。
|
||||
- 登录接口必须调用真实 NestJS API,不允许前端静态用户、localStorage mock 或纯前端验证码作为验收依据。
|
||||
- 运营端登录仅允许平台管理员;客户端登录仅允许已关联企业的企业管理员。
|
||||
|
||||
@@ -1347,7 +1347,7 @@
|
||||
- 运营端和客户端用户管理均需接入真实 API,支持添加、编辑、启用/禁用、删除、修改密码。
|
||||
- 启用/禁用、删除必须弹窗确认。
|
||||
- 删除采用软删除,历史系统日志、审核记录和业务记录仍可追溯;删除后用户不可登录且列表默认不展示。
|
||||
- 客户端用户字段必须包含邮箱和手机号,并支持邮箱或手机号登录。
|
||||
- 客户端用户字段必须包含邮箱和手机号,并支持用户名、邮箱或手机号登录。
|
||||
- 所有创建、编辑、启用、禁用、删除、修改密码动作必须写系统日志。
|
||||
|
||||
### 4. fail2ban
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
- 通道协议默认为 CMPP,CMPP 版本默认 2.0,且可选择 2.0 或 3.0。
|
||||
- 通道限速保存正确。
|
||||
- 通道真实保存 `desiredConnections/windowSize`,后续 Gateway `ConnectChannel` 与 `SubmitCommand.upstream` 使用该配置。
|
||||
- 通道列表和通道组页面展示的连接状态、连接数和最近错误来自 Gateway 真实上游连接池回写;一次性连接探测成功不能展示为 `connected`。
|
||||
- 通道测试短信必须调用真实 NestJS API,创建独立 `SmsMessageRecord` 和 `SmsSubmitRecord`,并向 Redis Stream `gateway.submit.commands` 写入 `SubmitCommand`;短信记录页面能查询到该测试短信。
|
||||
- 通道测试短信不得绑定企业、企业应用或 `SmsBatchTask` 发送任务;submit result 和 receipt 只更新短信记录、提交记录、回执记录和分片审计,不向客户侧推送 Deliver。
|
||||
- 若通道未启用或没有在线 CMPP 连接,测试短信 API 返回明确错误,不能只在前端假提示成功。
|
||||
@@ -442,6 +443,7 @@
|
||||
- 预期结果:
|
||||
- 后端创建新通道,新通道 id/code 与源通道不同,名称默认追加“副本”。
|
||||
- 通道配置、CMPP 参数、报备字段、签名/引流报备材料和个性化字段与源通道一致。
|
||||
- 若源通道为 active,新通道默认保存为 disabled,且不会立即触发上游真实连接。
|
||||
- 复制动作写入系统日志。
|
||||
- 复制后新通道可继续编辑、启停、删除,不影响源通道。
|
||||
|
||||
@@ -3090,13 +3092,13 @@ npm run verify:phase8
|
||||
|
||||
| 用例编号 | 操作 | 预期结果 |
|
||||
| --- | --- | --- |
|
||||
| TC-AUTH-001 | 打开 `/admin/login`,输入平台管理员邮箱或手机号、密码和正确图形验证码。 | 登录成功进入运营端;session 用户角色为 `platform_admin`;后续运营端 API 使用真实后端。 |
|
||||
| TC-AUTH-001 | 打开 `/admin/login`,输入平台管理员用户名、邮箱或手机号,外加密码和正确图形验证码。 | 登录成功进入运营端;session 用户角色为 `platform_admin`;后续运营端 API 使用真实后端。 |
|
||||
| TC-AUTH-002 | 使用企业管理员账号登录 `/admin/login`。 | 登录失败;返回“仅平台管理员可登录运营端”类错误;失败次数累计。 |
|
||||
| TC-AUTH-003 | 打开 `/client/login`,输入已关联企业的企业管理员邮箱或手机号、密码和正确图形验证码。 | 登录成功进入客户端;客户端 API 自动携带当前企业 `tenantId`;Dashboard、用户、日志等仅展示当前企业数据。 |
|
||||
| TC-AUTH-003 | 打开 `/client/login`,输入已关联企业的企业管理员用户名、邮箱或手机号,外加密码和正确图形验证码。 | 登录成功进入客户端;客户端 API 自动携带当前企业 `tenantId`;Dashboard、用户、日志等仅展示当前企业数据。 |
|
||||
| TC-AUTH-004 | 使用平台管理员或未关联企业的用户登录 `/client/login`。 | 登录失败;不进入客户端。 |
|
||||
| TC-AUTH-005 | 同一用户连续输错密码 5 次。 | 第 5 次后用户锁定 24 小时;锁定期内正确密码也被拒绝;系统记录失败次数和锁定时间。 |
|
||||
| TC-AUTH-006 | 输入错误或过期图形验证码登录。 | 返回 400 可读错误;必须刷新验证码后重试。 |
|
||||
| TC-USER-ADMIN-001 | 运营端新增平台管理员,填写邮箱或手机号、初始密码。 | 创建成功;用户无 `tenantId`;可登录运营端;写 `user.created` 日志。 |
|
||||
| TC-USER-ADMIN-001 | 运营端新增平台管理员,填写用户名/登录账号、邮箱或手机号、初始密码。 | 创建成功;用户无 `tenantId`;可登录运营端;写 `user.created` 日志。 |
|
||||
| TC-USER-ADMIN-002 | 运营端新增企业管理员但不选择企业。 | 返回 400;不创建用户。 |
|
||||
| TC-USER-ADMIN-003 | 运营端新增企业管理员并选择企业。 | 创建成功;用户关联企业;可登录客户端;客户端数据按该企业隔离。 |
|
||||
| TC-USER-ADMIN-004 | 运营端编辑用户、启用/禁用、删除、修改密码。 | 编辑和改密调用真实 API;启停/删除有确认弹窗;删除后列表不展示且不可登录;均写系统日志。 |
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
- 已执行:`npm --prefix api test -- channels.service.spec.ts --runInBand`、`npm --prefix api run build`、`npm run build`。待生产部署后用指定号码做一次真实发送验证,并回查 DB/短信记录。
|
||||
- 2026-07-09 追加:按产品边界收窄通道测试短信,`SmsMessageRecord/SmsSubmitRecord/SmsReceiptRecord/SmsMessageSegmentAudit` 支持 `tenantId/batchTaskId` 为空;通道测试只记录短信、提交结果和回执,不进入企业账务、发送任务进度、客户侧 Deliver 推送或业务补发。
|
||||
|
||||
## 2026-07-09 通道复制默认停用与真实连接池状态回写修复
|
||||
|
||||
- 生产验证发现当前 3 个赛邮行业通道中有 2 个由首个 active 通道复制而来;数据库 `CmppConnectionState` 曾显示 3 条通道都为 `connected/currentConnections=1`,但生产机 `ss/netstat` 与上游平台都只能看到 1 条真实 TCP 长连接。
|
||||
- 根因一:`POST /api/admin/channels/:id/copy` 会继承源通道 `status=active`,复制后的通道创建完成后立即参与 Gateway 连接流程,和“复制只是拷贝配置,不应自动上线”的产品预期不符。
|
||||
- 根因二:Gateway `/connections/connect` 之前只做一次性 `DialCMPP` 探测,探测成功后就把 `connected/currentConnections=desiredConnections` 回写给 NestJS;该探测连接随后立即断开,导致页面状态与真实上游连接池不一致。
|
||||
- 已修复:
|
||||
- 通道复制后统一保存为 `disabled`,复制日志补充 `sourceStatus` 和 `copiedStatus`,避免 active 通道副本自动占用上游连接。
|
||||
- Gateway `ConnectChannel` 改为直接建立/复用真实上游连接池,并由连接池回写 `connected/failed/disconnected`、`currentConnections`、最近错误和断开时间;连接丢失时状态随真实连接数变化更新。
|
||||
- 测试口径同步:
|
||||
- `TC-ADMIN-003` 增加“连接状态必须来自真实上游连接池回写”的要求。
|
||||
- `TC-ADMIN-016` 明确复制 active 通道后副本默认 `disabled`,且不会立即触发上游真实连接。
|
||||
- 已执行:`npm --prefix api test -- channels.service.spec.ts --runInBand`、`npm --prefix api run build`、`go test ./internal/control ./internal/upstream`、`go build -o ..\\dist\\cmpp-gateway .\\cmd\\gateway`。
|
||||
|
||||
## 2026-07-09 线上通道 CMPP 版本修复
|
||||
|
||||
- 线上生产验证发现 3 个赛邮行业通道配置均指向 `121.40.172.212:7890`,其中 2 个已触发 Gateway 真实连接并失败,`CmppConnectionState.lastError` 为 `packetWriter.ReadBytes error: ReadBytes reads 14 bytes, not equal to 16 we expected`。
|
||||
@@ -468,6 +481,7 @@ GET /api/admin/system-logs?page=1&pageSize=2
|
||||
|
||||
- 新增用户登录字段和 fail2ban 持久化字段:`email`、`phone`、`failedLoginCount`、`lockedUntil`、`lastLoginAt`、`deletedAt`。
|
||||
- 登录入口拆分为 `/client/login` 和 `/admin/login`,两端均调用真实验证码和登录 API。
|
||||
- 用户登录入口和用户表单统一文案为“用户名/登录账号”,提示可用用户名、邮箱或手机号登录。
|
||||
- 运营端登录仅允许 `platform_admin`;客户端登录仅允许已关联企业的 `enterprise_admin`。
|
||||
- 运营端用户管理接入真实 `/api/admin/users`,支持平台管理员和企业管理员的新增、编辑、启用/禁用、删除、改密;企业管理员必须关联企业。
|
||||
- 客户端用户管理接入真实 `/api/client/users`,所有操作继承当前登录企业 `tenantId`。
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/inbound"
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
"cmpp-platform/gateway/internal/upstream"
|
||||
|
||||
cmpp "github.com/bigwhite/gocmpp"
|
||||
)
|
||||
|
||||
const defaultConnectTimeout = 5 * time.Second
|
||||
|
||||
type DialFunc func(context.Context, ConnectChannelCommand) error
|
||||
type ConnectFunc func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error)
|
||||
|
||||
type ConnectChannelCommand struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
@@ -59,7 +53,7 @@ type ConnectionStateCallback struct {
|
||||
type Server struct {
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Dial DialFunc
|
||||
Connect ConnectFunc
|
||||
Upstream *upstream.Manager
|
||||
RecoveryCandidates func(context.Context) ([]inbound.DownstreamPresence, error)
|
||||
RecoveryStatuses func(context.Context) ([]inbound.DownstreamRecoveryStatus, error)
|
||||
@@ -74,12 +68,12 @@ func Register(mux *http.ServeMux, server Server) {
|
||||
if server.HTTPClient == nil {
|
||||
server.HTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
if server.Dial == nil {
|
||||
server.Dial = DialCMPP
|
||||
}
|
||||
if server.Upstream == nil {
|
||||
server.Upstream = &upstream.Manager{APIBaseURL: server.APIBaseURL, HTTPClient: server.HTTPClient}
|
||||
}
|
||||
if server.Connect == nil {
|
||||
server.Connect = server.connectChannel
|
||||
}
|
||||
mux.HandleFunc("/connections/connect", server.handleConnectChannel)
|
||||
mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit)
|
||||
mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt)
|
||||
@@ -105,26 +99,9 @@ func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
status := ConnectionStateCallback{
|
||||
ChannelID: command.ChannelID,
|
||||
ConnectionID: command.ConnectionID,
|
||||
DesiredConnections: desiredConnections(command.DesiredConnections),
|
||||
}
|
||||
if err := s.Dial(r.Context(), command); err != nil {
|
||||
status.Status = "failed"
|
||||
status.CurrentConnections = 0
|
||||
status.LastDisconnectedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
status.LastError = err.Error()
|
||||
} else {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
status.Status = "connected"
|
||||
status.CurrentConnections = status.DesiredConnections
|
||||
status.LastConnectedAt = now
|
||||
status.LastHeartbeatAt = now
|
||||
}
|
||||
|
||||
if err := s.postConnectionState(r.Context(), status); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to callback api: %v", err), http.StatusBadGateway)
|
||||
status, err := s.Connect(r.Context(), command)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to establish upstream pool: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -258,55 +235,43 @@ func (s Server) handleDownstreamRecoveryOverview(w http.ResponseWriter, r *http.
|
||||
})
|
||||
}
|
||||
|
||||
func DialCMPP(ctx context.Context, command ConnectChannelCommand) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
version := cmpp.V30
|
||||
if strings.HasPrefix(command.Channel.CMPPVersion, "2") {
|
||||
version = cmpp.V20
|
||||
}
|
||||
|
||||
client := cmpp.NewClient(version)
|
||||
defer client.Disconnect()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
addr := fmt.Sprintf("%s:%d", command.Channel.GatewayHost, command.Channel.GatewayPort)
|
||||
done <- client.Connect(addr, command.Channel.Account, command.Channel.PasswordCipher, defaultConnectTimeout)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s Server) postConnectionState(ctx context.Context, state ConnectionStateCallback) error {
|
||||
apiBaseURL := strings.TrimRight(s.APIBaseURL, "/")
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = "http://127.0.0.1:3000/api"
|
||||
}
|
||||
payload, err := json.Marshal(state)
|
||||
func (s Server) connectChannel(ctx context.Context, command ConnectChannelCommand) (ConnectionStateCallback, error) {
|
||||
state, err := s.Upstream.ConnectChannel(ctx, queue.ConnectChannelCommand{
|
||||
SchemaVersion: command.SchemaVersion,
|
||||
MessageType: queue.MessageTypeConnectChannel,
|
||||
TraceID: command.TraceID,
|
||||
ChannelID: command.ChannelID,
|
||||
ConnectionID: command.ConnectionID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Reason: command.Reason,
|
||||
DesiredConnections: command.DesiredConnections,
|
||||
Channel: queue.ConnectChannelConfig{
|
||||
Code: command.Channel.Code,
|
||||
Name: command.Channel.Name,
|
||||
GatewayHost: command.Channel.GatewayHost,
|
||||
GatewayPort: command.Channel.GatewayPort,
|
||||
Account: command.Channel.Account,
|
||||
PasswordCipher: command.Channel.PasswordCipher,
|
||||
SrcID: command.Channel.SrcID,
|
||||
CMPPVersion: command.Channel.CMPPVersion,
|
||||
RateLimitPerSecond: command.Channel.RateLimitPerSecond,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return ConnectionStateCallback{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/admin/gateway/connections", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api returned %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
return ConnectionStateCallback{
|
||||
ChannelID: state.ChannelID,
|
||||
ConnectionID: state.ConnectionID,
|
||||
Status: state.Status,
|
||||
DesiredConnections: state.DesiredConnections,
|
||||
CurrentConnections: state.CurrentConnections,
|
||||
LastConnectedAt: state.LastConnectedAt,
|
||||
LastDisconnectedAt: state.LastDisconnectedAt,
|
||||
LastHeartbeatAt: state.LastHeartbeatAt,
|
||||
ReconnectCount: state.ReconnectCount,
|
||||
LastError: state.LastError,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateConnectChannelCommand(command ConnectChannelCommand) error {
|
||||
|
||||
@@ -13,20 +13,16 @@ import (
|
||||
)
|
||||
|
||||
func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
||||
var callback ConnectionStateCallback
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/admin/gateway/connections" {
|
||||
t.Fatalf("unexpected callback path: %s", r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
||||
t.Fatalf("decode callback: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
handler := handlerWithDial(api.URL+"/api", func(context.Context, ConnectChannelCommand) error {
|
||||
return nil
|
||||
handler := handlerWithConnect(func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
||||
return ConnectionStateCallback{
|
||||
ChannelID: "channel-1",
|
||||
ConnectionID: "channel-1:primary",
|
||||
Status: "connected",
|
||||
DesiredConnections: 2,
|
||||
CurrentConnections: 2,
|
||||
LastConnectedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastHeartbeatAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}, nil
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
@@ -36,6 +32,10 @@ func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var callback ConnectionStateCallback
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &callback); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if callback.Status != "connected" || callback.CurrentConnections != 2 || callback.DesiredConnections != 2 {
|
||||
t.Fatalf("unexpected callback state: %+v", callback)
|
||||
}
|
||||
@@ -48,17 +48,16 @@ func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConnectChannelCallbacksFailedState(t *testing.T) {
|
||||
var callback ConnectionStateCallback
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
||||
t.Fatalf("decode callback: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
handler := handlerWithDial(api.URL+"/api", func(context.Context, ConnectChannelCommand) error {
|
||||
return errTestDial
|
||||
handler := handlerWithConnect(func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
||||
return ConnectionStateCallback{
|
||||
ChannelID: "channel-1",
|
||||
ConnectionID: "channel-1:primary",
|
||||
Status: "failed",
|
||||
DesiredConnections: 2,
|
||||
CurrentConnections: 0,
|
||||
LastDisconnectedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastError: errTestConnect.Error(),
|
||||
}, nil
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
@@ -68,14 +67,18 @@ func TestConnectChannelCallbacksFailedState(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var callback ConnectionStateCallback
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &callback); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if callback.Status != "failed" || callback.CurrentConnections != 0 || callback.LastError == "" {
|
||||
t.Fatalf("unexpected callback state: %+v", callback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectChannelRejectsInvalidCommand(t *testing.T) {
|
||||
handler := handlerWithDial("", func(context.Context, ConnectChannelCommand) error {
|
||||
return nil
|
||||
handler := handlerWithConnect(func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
||||
return ConnectionStateCallback{}, nil
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
@@ -180,16 +183,16 @@ func TestRecoveryOverviewEndpointReturnsCombinedView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type testDialError struct{}
|
||||
type testConnectError struct{}
|
||||
|
||||
func (testDialError) Error() string {
|
||||
return "dial failed"
|
||||
func (testConnectError) Error() string {
|
||||
return "connect failed"
|
||||
}
|
||||
|
||||
var errTestDial testDialError
|
||||
var errTestConnect testConnectError
|
||||
|
||||
func handlerWithDial(apiBaseURL string, dial DialFunc) http.Handler {
|
||||
return handlerWithServer(Server{APIBaseURL: apiBaseURL, Dial: dial})
|
||||
func handlerWithConnect(connect ConnectFunc) http.Handler {
|
||||
return handlerWithServer(Server{Connect: connect})
|
||||
}
|
||||
|
||||
func handlerWithServer(server Server) http.Handler {
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
|
||||
var reported ConnectionState
|
||||
conn := &connection{
|
||||
pending: make(map[uint32]chan submitPartResponse),
|
||||
pool: &connectionPool{
|
||||
channelID: "channel-1",
|
||||
connectionID: "channel-1:primary",
|
||||
config: normalizeUpstreamConfig(queueUpstreamConfigForTest()),
|
||||
reporter: func(_ context.Context, state ConnectionState) error {
|
||||
reported = state
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
conn.pool.conns = []*connection{conn}
|
||||
waiter := make(chan submitPartResponse, 1)
|
||||
conn.pending[7] = waiter
|
||||
|
||||
@@ -30,6 +42,9 @@ func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
|
||||
if len(conn.pending) != 0 {
|
||||
t.Fatalf("expected pending map to be reset, got %d entries", len(conn.pending))
|
||||
}
|
||||
if reported.Status != "disconnected" || reported.CurrentConnections != 0 {
|
||||
t.Fatalf("unexpected reported state: %+v", reported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryReadTimeoutDetection(t *testing.T) {
|
||||
|
||||
@@ -33,6 +33,19 @@ type Manager struct {
|
||||
conns map[string]*connectionPool
|
||||
}
|
||||
|
||||
type ConnectionState struct {
|
||||
ChannelID string `json:"channelId"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Status string `json:"status"`
|
||||
DesiredConnections int `json:"desiredConnections"`
|
||||
CurrentConnections int `json:"currentConnections"`
|
||||
LastConnectedAt string `json:"lastConnectedAt,omitempty"`
|
||||
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
|
||||
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
|
||||
ReconnectCount int `json:"reconnectCount,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) {
|
||||
if err := validateSubmitCommand(cmd); err != nil {
|
||||
result := submitResult(cmd, 0, "", "rejected", "INVALID_COMMAND", err.Error())
|
||||
@@ -64,28 +77,54 @@ func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.Su
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChannelCommand) (ConnectionState, error) {
|
||||
if err := validateConnectChannelCommand(command); err != nil {
|
||||
return ConnectionState{}, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ensureDefaultsLocked()
|
||||
pool := m.conns[command.ChannelID]
|
||||
config := normalizeUpstreamConfig(queue.UpstreamConfig{
|
||||
GatewayHost: command.Channel.GatewayHost,
|
||||
GatewayPort: command.Channel.GatewayPort,
|
||||
Account: command.Channel.Account,
|
||||
PasswordCipher: command.Channel.PasswordCipher,
|
||||
CMPPVersion: command.Channel.CMPPVersion,
|
||||
DesiredConnections: command.DesiredConnections,
|
||||
WindowSize: 16,
|
||||
})
|
||||
if pool == nil || !pool.matches(config) {
|
||||
if pool != nil {
|
||||
pool.close()
|
||||
}
|
||||
pool = m.newConnectionPool(command.ChannelID, command.ConnectionID, config)
|
||||
m.conns[command.ChannelID] = pool
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := pool.ensureConnected(); err != nil {
|
||||
_ = pool.reportState(ctx, "failed", err)
|
||||
m.mu.Lock()
|
||||
delete(m.conns, command.ChannelID)
|
||||
m.mu.Unlock()
|
||||
return pool.snapshotState("failed", err), nil
|
||||
}
|
||||
return pool.snapshotState("connected", nil), nil
|
||||
}
|
||||
|
||||
func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.HTTPClient == nil {
|
||||
m.HTTPClient = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
if m.conns == nil {
|
||||
m.conns = make(map[string]*connectionPool)
|
||||
}
|
||||
m.ensureDefaultsLocked()
|
||||
|
||||
pool := m.conns[cmd.ChannelID]
|
||||
if pool == nil || !pool.matches(cmd.Upstream) {
|
||||
if pool != nil {
|
||||
pool.close()
|
||||
}
|
||||
pool = &connectionPool{
|
||||
channelID: cmd.ChannelID,
|
||||
config: normalizeUpstreamConfig(cmd.Upstream),
|
||||
apiBaseURL: m.APIBaseURL,
|
||||
httpClient: m.HTTPClient,
|
||||
}
|
||||
pool = m.newConnectionPool(cmd.ChannelID, defaultChannelConnectionID(cmd.ChannelID), normalizeUpstreamConfig(cmd.Upstream))
|
||||
m.conns[cmd.ChannelID] = pool
|
||||
}
|
||||
if err := pool.ensureConnected(); err != nil {
|
||||
@@ -103,11 +142,35 @@ func (m *Manager) post(ctx context.Context, path string, payload any) error {
|
||||
return postJSON(ctx, client, m.APIBaseURL, path, payload)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureDefaultsLocked() {
|
||||
if m.HTTPClient == nil {
|
||||
m.HTTPClient = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
if m.conns == nil {
|
||||
m.conns = make(map[string]*connectionPool)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) newConnectionPool(channelID string, connectionID string, config queue.UpstreamConfig) *connectionPool {
|
||||
return &connectionPool{
|
||||
channelID: channelID,
|
||||
connectionID: connectionID,
|
||||
config: config,
|
||||
apiBaseURL: m.APIBaseURL,
|
||||
httpClient: m.HTTPClient,
|
||||
reporter: func(ctx context.Context, state ConnectionState) error {
|
||||
return m.post(ctx, "/admin/gateway/connections", state)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type connectionPool struct {
|
||||
channelID string
|
||||
config queue.UpstreamConfig
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
channelID string
|
||||
connectionID string
|
||||
config queue.UpstreamConfig
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
reporter func(context.Context, ConnectionState) error
|
||||
|
||||
mu sync.Mutex
|
||||
conns []*connection
|
||||
@@ -119,19 +182,23 @@ func (p *connectionPool) matches(config queue.UpstreamConfig) bool {
|
||||
}
|
||||
|
||||
func (p *connectionPool) ensureConnected() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
desired := p.config.DesiredConnections
|
||||
if desired <= 0 {
|
||||
desired = 1
|
||||
}
|
||||
for len(p.conns) < desired {
|
||||
connectedAny := false
|
||||
for {
|
||||
p.mu.Lock()
|
||||
if len(p.conns) >= desired {
|
||||
p.mu.Unlock()
|
||||
break
|
||||
}
|
||||
index := len(p.conns)
|
||||
conn := &connection{
|
||||
channelID: p.channelID,
|
||||
config: p.config,
|
||||
index: index,
|
||||
pool: p,
|
||||
apiBaseURL: p.apiBaseURL,
|
||||
httpClient: p.httpClient,
|
||||
window: make(chan struct{}, p.config.WindowSize),
|
||||
@@ -139,12 +206,22 @@ func (p *connectionPool) ensureConnected() error {
|
||||
tracker: make(map[uint64]queue.SubmitCommand),
|
||||
longUplink: make(map[string]*longUplinkAssembly),
|
||||
}
|
||||
if err := conn.ensureConnected(); err != nil {
|
||||
p.mu.Unlock()
|
||||
|
||||
connected, err := conn.ensureConnected()
|
||||
if err != nil {
|
||||
conn.close()
|
||||
p.closeLocked()
|
||||
p.close()
|
||||
return err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.conns = append(p.conns, conn)
|
||||
p.mu.Unlock()
|
||||
connectedAny = connectedAny || connected
|
||||
}
|
||||
if connectedAny {
|
||||
_ = p.reportState(context.Background(), "connected", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -198,7 +275,7 @@ func (p *connectionPool) acquireConnection(ctx context.Context) (*connection, fu
|
||||
|
||||
for {
|
||||
if conn, release := p.tryAcquireConnection(); conn != nil {
|
||||
if err := conn.ensureConnected(); err != nil {
|
||||
if connected, err := conn.ensureConnected(); err != nil {
|
||||
release()
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
@@ -206,6 +283,8 @@ func (p *connectionPool) acquireConnection(ctx context.Context) (*connection, fu
|
||||
case <-ticker.C:
|
||||
continue
|
||||
}
|
||||
} else if connected {
|
||||
_ = p.reportState(context.Background(), "connected", nil)
|
||||
}
|
||||
return conn, release, nil
|
||||
}
|
||||
@@ -236,21 +315,66 @@ func (p *connectionPool) tryAcquireConnection() (*connection, func()) {
|
||||
|
||||
func (p *connectionPool) close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.closeLocked()
|
||||
}
|
||||
|
||||
func (p *connectionPool) closeLocked() {
|
||||
for _, conn := range p.conns {
|
||||
conns := p.conns
|
||||
p.conns = nil
|
||||
p.mu.Unlock()
|
||||
for _, conn := range conns {
|
||||
conn.close()
|
||||
}
|
||||
p.conns = nil
|
||||
}
|
||||
|
||||
func (p *connectionPool) reportState(ctx context.Context, status string, stateErr error) error {
|
||||
if p.reporter == nil {
|
||||
return nil
|
||||
}
|
||||
return p.reporter(ctx, p.snapshotState(status, stateErr))
|
||||
}
|
||||
|
||||
func (p *connectionPool) snapshotState(status string, stateErr error) ConnectionState {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
state := ConnectionState{
|
||||
ChannelID: p.channelID,
|
||||
ConnectionID: p.connectionID,
|
||||
Status: status,
|
||||
DesiredConnections: p.config.DesiredConnections,
|
||||
CurrentConnections: p.countActiveConnections(),
|
||||
}
|
||||
if state.DesiredConnections <= 0 {
|
||||
state.DesiredConnections = 1
|
||||
}
|
||||
switch status {
|
||||
case "connected":
|
||||
state.LastConnectedAt = now
|
||||
state.LastHeartbeatAt = now
|
||||
case "disconnected", "failed":
|
||||
state.LastDisconnectedAt = now
|
||||
}
|
||||
if stateErr != nil {
|
||||
state.LastError = stateErr.Error()
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func (p *connectionPool) countActiveConnections() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
count := 0
|
||||
for _, conn := range p.conns {
|
||||
conn.mu.Lock()
|
||||
active := conn.client != nil && !conn.closed
|
||||
conn.mu.Unlock()
|
||||
if active {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type connection struct {
|
||||
channelID string
|
||||
config queue.UpstreamConfig
|
||||
index int
|
||||
pool *connectionPool
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -274,23 +398,23 @@ func (c *connection) matches(config queue.UpstreamConfig) bool {
|
||||
return c.config == normalizeUpstreamConfig(config)
|
||||
}
|
||||
|
||||
func (c *connection) ensureConnected() error {
|
||||
func (c *connection) ensureConnected() (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client != nil && !c.closed {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
client := cmpp.NewClient(protocolVersion(c.config.CMPPVersion))
|
||||
addr := fmt.Sprintf("%s:%d", c.config.GatewayHost, c.config.GatewayPort)
|
||||
if err := client.Connect(addr, c.config.Account, c.config.PasswordCipher, defaultConnectTimeout); err != nil {
|
||||
client.Disconnect()
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
c.client = client
|
||||
c.closed = false
|
||||
go c.readLoop()
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, part submitPart) (uint32, string, queue.SubmitResult, error) {
|
||||
@@ -540,6 +664,13 @@ func (c *connection) handleConnectionLoss(err error) {
|
||||
default:
|
||||
}
|
||||
}
|
||||
if c.pool != nil {
|
||||
status := "disconnected"
|
||||
if c.pool.countActiveConnections() > 0 {
|
||||
status = "connected"
|
||||
}
|
||||
_ = c.pool.reportState(context.Background(), status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func submitResult(cmd queue.SubmitCommand, sequenceID uint32, gatewayMessageID string, status string, code string, message string) queue.SubmitResult {
|
||||
@@ -599,6 +730,26 @@ func validateSubmitCommand(cmd queue.SubmitCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConnectChannelCommand(command queue.ConnectChannelCommand) error {
|
||||
if command.MessageType != queue.MessageTypeConnectChannel {
|
||||
return fmt.Errorf("unsupported messageType %q", command.MessageType)
|
||||
}
|
||||
if command.ChannelID == "" || command.ConnectionID == "" {
|
||||
return fmt.Errorf("channelId and connectionId are required")
|
||||
}
|
||||
if command.Channel.GatewayHost == "" || command.Channel.GatewayPort <= 0 {
|
||||
return fmt.Errorf("gatewayHost and gatewayPort are required")
|
||||
}
|
||||
if command.Channel.Account == "" || command.Channel.PasswordCipher == "" {
|
||||
return fmt.Errorf("account and passwordCipher are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultChannelConnectionID(channelID string) string {
|
||||
return fmt.Sprintf("%s:primary", channelID)
|
||||
}
|
||||
|
||||
func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig {
|
||||
if config.DesiredConnections <= 0 {
|
||||
config.DesiredConnections = 1
|
||||
|
||||
@@ -73,7 +73,7 @@ export function LoginPage({ portal }: LoginPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-form">
|
||||
<Input label="邮箱或手机号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入邮箱或手机号" value={login} />
|
||||
<Input label="用户名/登录账号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} />
|
||||
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
|
||||
<div className="login-captcha-row">
|
||||
<Input label="图形验证码" onChange={(event) => setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} />
|
||||
|
||||
@@ -195,7 +195,7 @@ export function AdminUsersPage() {
|
||||
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} value={form.displayName} />
|
||||
<Input label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
|
||||
<Input label="手机号" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
||||
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
<Select
|
||||
label="用户类型"
|
||||
onChange={(event) => updateField('roleCode', event.target.value as UserForm['roleCode'])}
|
||||
|
||||
@@ -168,7 +168,7 @@ export function ClientUsersPage() {
|
||||
<Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
|
||||
<Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
|
||||
<Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
|
||||
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user