# Cool-Admin v8.x 中使用 Midway Socket.IO 的实践

## 目标与结构

- 读者:需要在 Cool-Admin v8.x 项目中实现实时推送(公告、通知、状态同步等)的开发者。
- 核心内容:
  1. 依赖与基础配置
  2. WebSocket 控制器(鉴权、房间管理)
  3. Service 广播流程
  4. 客户端接入与调试建议

> 下文所有示例基于 `cool-admin-midway` 工程。

## 1. 依赖与基础配置

### 1.1 安装依赖

在 `cool-admin-midway/package.json` 中新增:

```json
"dependencies": {
  "@midwayjs/socketio": "^3.20.11",
  "@socket.io/redis-adapter": "^8.3.0",
  "ioredis": "^5.8.1"
}
```

安装完成后执行 `npm run dev` 或 `npm run start`,Midway 会自动加载 Socket.IO 组件。

### 1.2 框架入口启用 Socket.IO

`src/configuration.ts` 中引入模块:

```ts
import * as socketio from '@midwayjs/socketio';

@Configuration({
  imports: [
    // ...其他组件
    socketio,
  ],
})
export class MainConfiguration {}
```

### 1.3 Redis 适配器配置

`src/config/config.default.ts` 中统一维护 Redis 与 socket 配置,确保多进程/多实例时广播可跨进程同步:

```ts
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';

const socketRedisOptions = {
  host: '127.0.0.1',
  port: 6379,
  password: '***',
  db: 2,
};

const socketRedisPubClient = new Redis(socketRedisOptions);
const socketRedisSubClient = socketRedisPubClient.duplicate();

export default {
  socketIO: {
    cors: { origin: '*' },
    transports: ['websocket'],
    adapter: createAdapter(socketRedisPubClient, socketRedisSubClient),
  },
  // ...其余 Cool 配置
};
```

> 提示:如果项目只跑单进程,可暂时省略 adapter;但上线后为了支持 PM2/容器集群,建议始终启用。

## 2. WebSocket 控制器:鉴权与房间管理

按照 Cool-Admin 模块化约定,可在业务模块下新建 `controller/ws/announcement.ts`:

```ts
@Provide()
@WSController('/operation/announcement')
export class OperationAnnouncementWSController {
  @Inject() ctx: Context;
  @Inject() logger: ILogger;
  @Config('module.user.jwt') userJwtConfig;

  private verifyClientToken() {
    const handshake: any = this.ctx.handshake || {};
    const raw =
      handshake.auth?.token ||
      handshake.headers?.authorization ||
      handshake.headers?.Authorization ||
      handshake.query?.token;

    if (!raw) throw new CoolCommException('未携带 token');
    const token = String(raw).replace(/^Bearer\s+/i, '').trim();
    const payload: any = jwt.verify(token, this.userJwtConfig.secret);
    if (payload?.isRefresh) throw new CoolCommException('登录失效');
    (this.ctx as any).user = payload;
    return payload;
  }

  @OnWSConnection()
  async onConnection() {
    const user = this.verifyClientToken();
    this.logger.info('[announcement-ws] client connected', {
      socketId: this.ctx.id,
      userId: user.id,
    });
    this.ctx.join('operation:announcement:all');
    this.ctx.emit('announcement:connected', { socketId: this.ctx.id, user });
  }

  @OnWSDisConnection()
  async onDisconnect(reason: string) {
    this.logger.info('[announcement-ws] client disconnected', {
      socketId: this.ctx.id,
      reason,
    });
  }
}
```

关键点:
- **握手鉴权**:直接复用 `module.user.jwt.secret`,确保与 App REST 接口一致的 Token 体系。
- **房间命名**:使用 `operation:announcement:all` 等可读性强的房间名,方便后续服务层广播时定位。
- **日志可观测性**:在连接/断开/鉴权失败处输出日志,便于排查 token 或网络问题。

## 3. Service 层广播示例

在公告服务中注入 Socket 应用实例即可广播数据:

```ts
@Provide()
export class AnnouncementService extends BaseService {
  @App('socketIO')
  socketApp: Application;

  @Inject()
  logger: ILogger;

  async add(data: any) {
    const result = (await super.add(data)) as { id?: number | number[] };
    const ids = (Array.isArray(result?.id) ? result.id : [result?.id]).filter(Boolean);
    if (!ids.length || !this.socketApp) {
      return result;
    }

    const nsp = this.socketApp.of('/operation/announcement');
    const payloads = [];

    for (const id of ids) {
      const info = await this.info(id);
      if (info) payloads.push(info);
    }

    if (payloads.length) {
      nsp.to('operation:announcement:all').emit(
        'announcement:new',
        payloads.length === 1 ? payloads[0] : payloads
      );
      this.logger.info('[announcement-service] emit payload', payloads);
    }

    return result;
  }
}
```

要点:
- `@App('socketIO')` 直接注入 Socket Server,避免手动维护单例。
- 通过 `this.info(id)` 获取数据库最新数据,保证推送内容与 REST 接口一致。
- 在日志中记录 emit 情况,方便定位“接口成功但前端没收到”的问题。

## 4. 客户端接入与调试建议

### 4.1 握手与事件约定

- 连接地址:`http(s)://{host}:{port}/operation/announcement`。
- 握手携带 token(与 App REST 接口一致):
  ```ts
  const socket = io('http://127.0.0.1:8001/operation/announcement', {
    transports: ['websocket'],
    auth: { token: 'Bearer <JWT>' },
  });
  ```
- 默认事件:
  - `announcement:connected`:服务端确认连接成功。
  - `announcement:new`:公告新增/更新后推送的数据。
  - `announcement:error`:鉴权失败或其他异常。

### 4.2 Node/Mac 终端快速测试

```bash
npm install socket.io-client
cat <<'EOF' > socket-test.mjs
import { io } from 'socket.io-client';

const token = 'Bearer 你的APP_TOKEN';
const socket = io('http://127.0.0.1:8001/operation/announcement', {
  transports: ['websocket'],
  auth: { token },
});

socket.on('connect', () => console.log('[client] connected', socket.id));
socket.on('announcement:connected', payload => console.log('connected', payload));
socket.on('announcement:new', payload => console.log('announcement:new', payload));
socket.on('announcement:error', err => console.error('error', err));
socket.on('disconnect', reason => console.log('disconnect', reason));
EOF
node socket-test.mjs
```

此脚本可验证本地广播链路是否正常,适用于 CI 或调试场景。

### 4.3 Apifox / Postman

- Apifox 需选择 Socket.IO 协议,并在 Header 中添加 `Authorization: Bearer <JWT>`。
- 当前版本的消息面板不会自动监听事件,可手动添加 `announcement:new` 监听,或转而使用上述 Node 脚本。

### 4.4 多进程部署验证

- 当使用 PM2/K8s 等多实例部署时,可借助 `redis-cli monitor` 查看频道(如 `socket.io#/#operation/announcement#`)是否有消息,确认 adapter 正常工作。
- 若只在单实例上测试,可以先关闭 adapter,验证通过后再开启,避免干扰。

## 5. 小结与最佳实践

1. **统一配置中心**:把 socket、Redis、JWT 配置集中在 `config/*.ts` 中,便于多环境切换与团队协作。
2. **模块解耦**:每个业务模块自建 `controller/ws` 与服务,保持 HTTP 与 WebSocket 代码结构一致,降低维护成本。
3. **无状态鉴权**:沿用 App 端的 JWT 不入库策略,必要时在 payload 中增加版本号或设备标识,避免额外的 Redis 写入。
4. **日志与监控**:在控制器、Service、Redis adapter 层都输出关键日志,结合 `redis-cli monitor`、`socket.io-client` 测试脚本,快速定位“接口成功但无广播”的问题。
5. **客户端策略**:小程序/前端如需节省连接,可在 `onHide`/`onUnload` 时断开,在 `onShow` 或登录成功后重连,并加上重连提示。

通过以上步骤,即可在 Cool-Admin v8.x 项目中快速实现基于 Midway 官方 Socket.IO 的实时推送,同时保持与框架既有能力(模块化、权限、Redis 配置等)的一致性。