docs: 补充NapCat WebUI Gateway成熟库方案
This commit is contained in:
parent
571db8ecea
commit
788761698f
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**Architecture:** The API service remains the Admin-authenticated authority that resolves a QQBot account to its active NapCat container and requests a short-lived Gateway session. The new `kt-napcat-webui-gateway` process owns session storage, one-time bootstrap tickets, NapCat WebUI credential exchange, HTTP/static/API/WebSocket proxying, and audit events. Admin opens `/qqbot/account/:accountId/napcat-webui`, creates a route-bound session, heartbeats while mounted, and revokes on route leave.
|
**Architecture:** The API service remains the Admin-authenticated authority that resolves a QQBot account to its active NapCat container and requests a short-lived Gateway session. The new `kt-napcat-webui-gateway` process owns session storage, one-time bootstrap tickets, NapCat WebUI credential exchange, HTTP/static/API/WebSocket proxying, and audit events. Admin opens `/qqbot/account/:accountId/napcat-webui`, creates a route-bound session, heartbeats while mounted, and revokes on route leave.
|
||||||
|
|
||||||
**Tech Stack:** NestJS 11, Express adapter, TypeORM/MySQL, Redis through `ioredis`, `http-proxy-middleware` for Express/WebSocket proxying, Vue 3 TSX, Vben Admin, antdv-next, K8s, Jenkins, Caddy/Admin domain route.
|
**Tech Stack:** NestJS 11, Express adapter, TypeORM/MySQL, Redis through `@nestjs-modules/ioredis` + `ioredis`, `http-proxy-middleware` for Express/WebSocket proxying, Vue 3 TSX, VueUse `useIntervalFn`, Vben Admin, antdv-next, K8s, Jenkins, Caddy/Admin domain route.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -15,7 +15,10 @@
|
|||||||
- Spec: `docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.md`
|
- Spec: `docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.md`
|
||||||
- Chinese spec: `docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.zh-CN.md`
|
- Chinese spec: `docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.zh-CN.md`
|
||||||
- `http-proxy-middleware` supports Express proxy middleware and WebSocket upgrades through `ws: true` and upgrade handling: <https://github.com/chimurai/http-proxy-middleware>
|
- `http-proxy-middleware` supports Express proxy middleware and WebSocket upgrades through `ws: true` and upgrade handling: <https://github.com/chimurai/http-proxy-middleware>
|
||||||
- Redis documents `ioredis` as a Node Redis client option, and the package has async Redis primitives suitable for TTL leases: <https://redis.io/tutorials/develop/node/gettingstarted/>
|
- `http-proxy-middleware` WebSocket recipe documents `ws: true`, manual `server.on('upgrade', proxy.upgrade)`, multiple targets, and path rewriting: <https://github.com/chimurai/http-proxy-middleware/blob/master/recipes/websocket.md>
|
||||||
|
- `@nestjs-modules/ioredis` provides Nest `RedisModule.forRoot` and `@InjectRedis()` over `ioredis`: <https://github.com/nest-modules/ioredis>
|
||||||
|
- `connect-redis` is intentionally not used because it is an Express session store, while this Gateway needs API-created sessions, one-time tickets, target metadata, concurrent-session revocation, Credential cache, and audit events: <https://github.com/tj/connect-redis>
|
||||||
|
- VueUse `useIntervalFn` wraps `setInterval` with pause/resume controls and is already available in Admin: <https://vueuse.org/shared/useintervalfn/>
|
||||||
|
|
||||||
## Scope Check
|
## Scope Check
|
||||||
|
|
||||||
@ -25,9 +28,9 @@ This is one deployable workstream even though it spans API, Gateway, Admin, and
|
|||||||
|
|
||||||
### API repository: `D:\MyFiles\KT\Node\kt-template-online-api`
|
### API repository: `D:\MyFiles\KT\Node\kt-template-online-api`
|
||||||
|
|
||||||
- Modify `package.json` and `pnpm-lock.yaml`: add `ioredis` and `http-proxy-middleware`, plus gateway start scripts.
|
- Modify `package.json` and `pnpm-lock.yaml`: add `@nestjs-modules/ioredis`, `ioredis`, and `http-proxy-middleware`, plus gateway start scripts.
|
||||||
- Create `src/apps/napcat-webui-gateway/main.ts`: standalone Nest bootstrap on port `48086`.
|
- Create `src/apps/napcat-webui-gateway/main.ts`: standalone Nest bootstrap on port `48086`.
|
||||||
- Create `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`: Gateway module imports config, logger, TypeORM, and gateway providers/controllers.
|
- Create `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`: Gateway module imports config, logger, TypeORM, `RedisModule`, and gateway providers/controllers.
|
||||||
- Create `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts`: reads Gateway env with bounded defaults.
|
- Create `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts`: reads Gateway env with bounded defaults.
|
||||||
- Create `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`: session, audit, target, and proxy types.
|
- Create `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`: session, audit, target, and proxy types.
|
||||||
- Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`: Redis-backed session and ticket store.
|
- Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`: Redis-backed session and ticket store.
|
||||||
@ -572,10 +575,10 @@ git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat W
|
|||||||
Run:
|
Run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api add ioredis http-proxy-middleware
|
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api add @nestjs-modules/ioredis ioredis http-proxy-middleware
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: `package.json` and `pnpm-lock.yaml` update. Keep `ws` unchanged because it already exists.
|
Expected: `package.json` and `pnpm-lock.yaml` update. Keep `ws` unchanged because it already exists. Do not add `connect-redis` because Gateway sessions are domain sessions, not Express login sessions.
|
||||||
|
|
||||||
- [ ] **Step 2: Add package scripts**
|
- [ ] **Step 2: Add package scripts**
|
||||||
|
|
||||||
@ -774,7 +777,26 @@ The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, and
|
|||||||
|
|
||||||
- [ ] **Step 7: Implement Redis store and ticket service**
|
- [ ] **Step 7: Implement Redis store and ticket service**
|
||||||
|
|
||||||
Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`. Use `ioredis` `set(key, value, 'PX', ttlMs)`, `get`, and a secondary index key:
|
Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`. Inject Redis through `@nestjs-modules/ioredis`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NapcatWebuiGatewayRedisStore
|
||||||
|
implements NapcatWebuiGatewaySessionStore
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Initializes the Redis-backed Gateway session store.
|
||||||
|
* @param redis Shared Gateway Redis client managed by Nest RedisModule.
|
||||||
|
*/
|
||||||
|
constructor(@InjectRedis() private readonly redis: Redis) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use Redis `set(key, value, 'PX', ttlMs)`, `get`, and a secondary index key:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
napcat:webui:session:{sessionId}
|
napcat:webui:session:{sessionId}
|
||||||
@ -791,7 +813,31 @@ Ticket TTL must be 60 seconds or less. Redemption deletes the ticket key before
|
|||||||
|
|
||||||
- [ ] **Step 8: Add Gateway module and internal controller**
|
- [ ] **Step 8: Add Gateway module and internal controller**
|
||||||
|
|
||||||
Create `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts` and `presentation/internal-session.controller.ts`. Internal controller paths:
|
Create `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts` and `presentation/internal-session.controller.ts`. The module must use the community Redis module rather than a custom Redis provider:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { RedisModule } from '@nestjs-modules/ioredis';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
RedisModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'single',
|
||||||
|
url:
|
||||||
|
config.get<string>('NAPCAT_WEBUI_GATEWAY_REDIS_URL') ||
|
||||||
|
`redis://${config.get<string>('NAPCAT_WEBUI_GATEWAY_REDIS_HOST') || '127.0.0.1'}:${config.get<number>('NAPCAT_WEBUI_GATEWAY_REDIS_PORT') || 6379}`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class NapcatWebuiGatewayModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Internal controller paths:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /internal/sessions
|
POST /internal/sessions
|
||||||
@ -853,6 +899,7 @@ git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat W
|
|||||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`
|
- Create: `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`
|
||||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`
|
- Create: `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`
|
||||||
- Create: `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`
|
- Create: `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`
|
||||||
|
- Modify: `src/apps/napcat-webui-gateway/main.ts`
|
||||||
- Modify: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`
|
- Modify: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: Write proxy rewrite tests**
|
- [ ] **Step 1: Write proxy rewrite tests**
|
||||||
@ -861,8 +908,8 @@ Create `test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts`:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import {
|
import {
|
||||||
|
buildGatewayCookiePathRewrite,
|
||||||
rewriteNapcatLocationHeader,
|
rewriteNapcatLocationHeader,
|
||||||
rewriteNapcatSetCookieHeader,
|
|
||||||
sanitizeGatewayProxyPath,
|
sanitizeGatewayProxyPath,
|
||||||
} from '../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service';
|
} from '../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service';
|
||||||
|
|
||||||
@ -887,12 +934,10 @@ describe('NapCat WebUI Gateway proxy rewriting', () => {
|
|||||||
).toBe('/napcat-webui/session/session-1/webui/webui/login');
|
).toBe('/napcat-webui/session/session-1/webui/webui/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scopes cookies to the active session path', () => {
|
it('delegates cookie path rewriting to http-proxy-middleware options', () => {
|
||||||
expect(
|
expect(buildGatewayCookiePathRewrite({ sessionId: 'session-1' })).toEqual({
|
||||||
rewriteNapcatSetCookieHeader('napcat=value; Path=/; HttpOnly', {
|
'*': '/napcat-webui/session/session-1',
|
||||||
sessionId: 'session-1',
|
});
|
||||||
}),
|
|
||||||
).toContain('Path=/napcat-webui/session/session-1');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@ -913,7 +958,7 @@ Do not log `webuiToken`, hash, or Credential.
|
|||||||
|
|
||||||
- [ ] **Step 4: Implement proxy helpers**
|
- [ ] **Step 4: Implement proxy helpers**
|
||||||
|
|
||||||
Create pure helper exports in `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`:
|
Create pure helper exports in `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`. Keep cookie rewriting as a `http-proxy-middleware` option instead of rewriting `Set-Cookie` by hand:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export function sanitizeGatewayProxyPath(rawPath: string): string {
|
export function sanitizeGatewayProxyPath(rawPath: string): string {
|
||||||
@ -932,14 +977,10 @@ export function rewriteNapcatLocationHeader(
|
|||||||
return `/napcat-webui/session/${input.sessionId}/webui/${location.replace(/^\/+/, '')}`;
|
return `/napcat-webui/session/${input.sessionId}/webui/${location.replace(/^\/+/, '')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rewriteNapcatSetCookieHeader(
|
export function buildGatewayCookiePathRewrite(input: { sessionId: string }) {
|
||||||
value: string,
|
return {
|
||||||
input: { sessionId: string },
|
'*': `/napcat-webui/session/${input.sessionId}`,
|
||||||
) {
|
};
|
||||||
return value.replace(
|
|
||||||
/Path=[^;]*/i,
|
|
||||||
`Path=/napcat-webui/session/${input.sessionId}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -950,6 +991,13 @@ Use `createProxyMiddleware` from `http-proxy-middleware` with:
|
|||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
cookiePathRewrite: buildGatewayCookiePathRewrite({ sessionId }),
|
||||||
|
on: {
|
||||||
|
proxyReq: handleProxyReq,
|
||||||
|
proxyReqWs: handleProxyReqWs,
|
||||||
|
proxyRes: handleProxyRes,
|
||||||
|
},
|
||||||
|
pathRewrite: (_path, req) => sanitizeGatewayProxyPath(req.params[0] || ''),
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
selfHandleResponse: false,
|
selfHandleResponse: false,
|
||||||
@ -967,6 +1015,28 @@ Before proxying:
|
|||||||
|
|
||||||
On first successful upstream response, call `sessionService.markActive(sessionId)`.
|
On first successful upstream response, call `sessionService.markActive(sessionId)`.
|
||||||
|
|
||||||
|
For WebSocket upgrade, do not tunnel frames through MQTT and do not hand-roll a WebSocket bridge. Expose a method on `NapcatWebuiProxyService` and bind HPM's upgrade handler from `main.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Binds NapCat WebUI WebSocket upgrades to the same proxy middleware.
|
||||||
|
* @param server HTTP server created by Nest's Express adapter.
|
||||||
|
*/
|
||||||
|
bindWebSocketUpgrade(server: import('http').Server) {
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
if (!req.url?.startsWith('/napcat-webui/session/')) return;
|
||||||
|
this.proxy.upgrade(req, socket, head);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Modify `src/apps/napcat-webui-gateway/main.ts` after `app.listen()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(server);
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Implement public controller**
|
- [ ] **Step 6: Implement public controller**
|
||||||
|
|
||||||
Create `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`:
|
Create `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`:
|
||||||
@ -978,7 +1048,7 @@ ALL /napcat-webui/session/:sessionId/webui/*
|
|||||||
|
|
||||||
Bootstrap must redeem `ticket`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`.
|
Bootstrap must redeem `ticket`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`.
|
||||||
|
|
||||||
Proxy route delegates to `NapcatWebuiProxyService`.
|
Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling.
|
||||||
|
|
||||||
- [ ] **Step 7: Run tests and typecheck**
|
- [ ] **Step 7: Run tests and typecheck**
|
||||||
|
|
||||||
@ -1445,6 +1515,7 @@ Expected: FAIL because the page does not exist.
|
|||||||
Create `useNapcatWebuiGatewaySession.ts` with:
|
Create `useNapcatWebuiGatewaySession.ts` with:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
import { useIntervalFn } from '@vueuse/core';
|
||||||
import { onBeforeUnmount, ref } from 'vue';
|
import { onBeforeUnmount, ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
createQqbotNapcatWebuiSession,
|
createQqbotNapcatWebuiSession,
|
||||||
@ -1465,42 +1536,43 @@ export function useNapcatWebuiGatewaySession(accountId: string) {
|
|||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const session = ref<QqbotNapcatApi.WebuiGatewaySession>();
|
const session = ref<QqbotNapcatApi.WebuiGatewaySession>();
|
||||||
const state = ref<NapcatWebuiSessionState>('idle');
|
const state = ref<NapcatWebuiSessionState>('idle');
|
||||||
let heartbeatTimer: number | undefined;
|
|
||||||
|
|
||||||
|
const { pause: pauseHeartbeat, resume: resumeHeartbeat } = useIntervalFn(
|
||||||
|
() => {
|
||||||
|
const sessionId = session.value?.sessionId;
|
||||||
|
if (!sessionId) return;
|
||||||
|
void heartbeatQqbotNapcatWebuiSession(sessionId).catch(() => {
|
||||||
|
errorMessage.value = 'NapCat WebUI 会话心跳失败,请重新打开';
|
||||||
|
state.value = 'error';
|
||||||
|
pauseHeartbeat();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Gateway session and starts the route-bound heartbeat.
|
||||||
|
*/
|
||||||
async function open() {
|
async function open() {
|
||||||
state.value = 'loading';
|
state.value = 'loading';
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
try {
|
try {
|
||||||
session.value = await createQqbotNapcatWebuiSession(accountId);
|
session.value = await createQqbotNapcatWebuiSession(accountId);
|
||||||
state.value = 'ready';
|
state.value = 'ready';
|
||||||
startHeartbeat();
|
resumeHeartbeat();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorMessage.value = error?.message || 'NapCat WebUI 会话创建失败';
|
errorMessage.value = error?.message || 'NapCat WebUI 会话创建失败';
|
||||||
state.value = 'error';
|
state.value = 'error';
|
||||||
|
pauseHeartbeat();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHeartbeat() {
|
/**
|
||||||
stopHeartbeat();
|
* Revokes the current Gateway session and stops heartbeat traffic.
|
||||||
heartbeatTimer = window.setInterval(() => {
|
*/
|
||||||
const sessionId = session.value?.sessionId;
|
|
||||||
if (!sessionId) return;
|
|
||||||
void heartbeatQqbotNapcatWebuiSession(sessionId).catch(() => {
|
|
||||||
errorMessage.value = 'NapCat WebUI 会话心跳失败,请重新打开';
|
|
||||||
state.value = 'error';
|
|
||||||
stopHeartbeat();
|
|
||||||
});
|
|
||||||
}, 20_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHeartbeat() {
|
|
||||||
if (!heartbeatTimer) return;
|
|
||||||
window.clearInterval(heartbeatTimer);
|
|
||||||
heartbeatTimer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revoke() {
|
async function revoke() {
|
||||||
stopHeartbeat();
|
pauseHeartbeat();
|
||||||
const sessionId = session.value?.sessionId;
|
const sessionId = session.value?.sessionId;
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
await revokeQqbotNapcatWebuiSession(sessionId).catch(() => undefined);
|
await revokeQqbotNapcatWebuiSession(sessionId).catch(() => undefined);
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**架构:** API 继续作为 Admin 鉴权和 QQBot 账号绑定解析的权威,只负责创建短期 Gateway session。新增 `kt-napcat-webui-gateway` 进程负责 session 存储、一次性 bootstrap ticket、NapCat WebUI Credential 交换、HTTP/静态资源/API/WebSocket 代理和审计。Admin 打开 `/qqbot/account/:accountId/napcat-webui`,页面 mounted 创建 session,mounted 期间 heartbeat,路由离开时 revoke。
|
**架构:** API 继续作为 Admin 鉴权和 QQBot 账号绑定解析的权威,只负责创建短期 Gateway session。新增 `kt-napcat-webui-gateway` 进程负责 session 存储、一次性 bootstrap ticket、NapCat WebUI Credential 交换、HTTP/静态资源/API/WebSocket 代理和审计。Admin 打开 `/qqbot/account/:accountId/napcat-webui`,页面 mounted 创建 session,mounted 期间 heartbeat,路由离开时 revoke。
|
||||||
|
|
||||||
**技术栈:** NestJS 11、Express adapter、TypeORM/MySQL、Redis via `ioredis`、`http-proxy-middleware` 代理 Express/WebSocket、Vue 3 TSX、Vben Admin、antdv-next、K8s、Jenkins、Caddy/Admin 域路由。
|
**技术栈:** NestJS 11、Express adapter、TypeORM/MySQL、Redis via `@nestjs-modules/ioredis` + `ioredis`、`http-proxy-middleware` 代理 Express/WebSocket、Vue 3 TSX、VueUse `useIntervalFn`、Vben Admin、antdv-next、K8s、Jenkins、Caddy/Admin 域路由。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -15,7 +15,10 @@
|
|||||||
- 设计文档:`docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.md`
|
- 设计文档:`docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.md`
|
||||||
- 中文设计:`docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.zh-CN.md`
|
- 中文设计:`docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.zh-CN.md`
|
||||||
- `http-proxy-middleware` 支持 Express proxy middleware 和 WebSocket upgrade:<https://github.com/chimurai/http-proxy-middleware>
|
- `http-proxy-middleware` 支持 Express proxy middleware 和 WebSocket upgrade:<https://github.com/chimurai/http-proxy-middleware>
|
||||||
- Redis 官方 Node 入门文档列出 `ioredis`,适合 Redis TTL 租约:<https://redis.io/tutorials/develop/node/gettingstarted/>
|
- `http-proxy-middleware` WebSocket recipe 说明了 `ws: true`、手动 `server.on('upgrade', proxy.upgrade)`、多 target 和 path rewrite:<https://github.com/chimurai/http-proxy-middleware/blob/master/recipes/websocket.md>
|
||||||
|
- `@nestjs-modules/ioredis` 提供 Nest `RedisModule.forRoot` 和 `@InjectRedis()`,底层使用 `ioredis`:<https://github.com/nest-modules/ioredis>
|
||||||
|
- 明确不使用 `connect-redis`,因为它是 Express session store,而 Gateway 需要 API 预创建 session、一次性 ticket、target 元数据、同账号并发撤销、Credential 缓存和审计事件:<https://github.com/tj/connect-redis>
|
||||||
|
- VueUse `useIntervalFn` 是带 pause/resume 控制的 `setInterval` wrapper,Admin 已有该依赖:<https://vueuse.org/shared/useintervalfn/>
|
||||||
|
|
||||||
## 范围检查
|
## 范围检查
|
||||||
|
|
||||||
@ -25,9 +28,9 @@
|
|||||||
|
|
||||||
### API 仓库:`D:\MyFiles\KT\Node\kt-template-online-api`
|
### API 仓库:`D:\MyFiles\KT\Node\kt-template-online-api`
|
||||||
|
|
||||||
- 修改 `package.json` 和 `pnpm-lock.yaml`:加入 `ioredis`、`http-proxy-middleware` 和 Gateway 启动脚本。
|
- 修改 `package.json` 和 `pnpm-lock.yaml`:加入 `@nestjs-modules/ioredis`、`ioredis`、`http-proxy-middleware` 和 Gateway 启动脚本。
|
||||||
- 新增 `src/apps/napcat-webui-gateway/main.ts`:Gateway 独立 Nest bootstrap,端口 `48086`。
|
- 新增 `src/apps/napcat-webui-gateway/main.ts`:Gateway 独立 Nest bootstrap,端口 `48086`。
|
||||||
- 新增 `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`:Gateway module,导入配置、日志、TypeORM 和 Gateway provider/controller。
|
- 新增 `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`:Gateway module,导入配置、日志、TypeORM、`RedisModule` 和 Gateway provider/controller。
|
||||||
- 新增 `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts`:读取 Gateway env 和安全默认值。
|
- 新增 `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts`:读取 Gateway env 和安全默认值。
|
||||||
- 新增 `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`:session、audit、target、proxy 类型。
|
- 新增 `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`:session、audit、target、proxy 类型。
|
||||||
- 新增 `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`:Redis session/ticket store。
|
- 新增 `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`:Redis session/ticket store。
|
||||||
@ -259,10 +262,10 @@ git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat W
|
|||||||
- [ ] **Step 1:增加依赖**
|
- [ ] **Step 1:增加依赖**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api add ioredis http-proxy-middleware
|
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api add @nestjs-modules/ioredis ioredis http-proxy-middleware
|
||||||
```
|
```
|
||||||
|
|
||||||
期望:更新 `package.json` 和 `pnpm-lock.yaml`。保留已有 `ws`。
|
期望:更新 `package.json` 和 `pnpm-lock.yaml`。保留已有 `ws`。不要加 `connect-redis`,因为 Gateway session 是领域 session,不是 Express 登录 session。
|
||||||
|
|
||||||
- [ ] **Step 2:增加启动脚本**
|
- [ ] **Step 2:增加启动脚本**
|
||||||
|
|
||||||
@ -303,7 +306,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
|
|||||||
|
|
||||||
- [ ] **Step 7:实现 Redis store 和 ticket service**
|
- [ ] **Step 7:实现 Redis store 和 ticket service**
|
||||||
|
|
||||||
Redis key:
|
使用 `@nestjs-modules/ioredis` 的 `@InjectRedis()` 注入 Redis client,不自写 Redis provider。Redis key:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
napcat:webui:session:{sessionId}
|
napcat:webui:session:{sessionId}
|
||||||
@ -315,6 +318,30 @@ ticket TTL 不超过 60 秒,redeem 时先删除 ticket 再返回 session id。
|
|||||||
|
|
||||||
- [ ] **Step 8:增加 Gateway module 和内部 controller**
|
- [ ] **Step 8:增加 Gateway module 和内部 controller**
|
||||||
|
|
||||||
|
`napcat-webui-gateway.module.ts` 必须通过 `RedisModule.forRootAsync` 接入 Redis:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { RedisModule } from '@nestjs-modules/ioredis';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
RedisModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'single',
|
||||||
|
url:
|
||||||
|
config.get<string>('NAPCAT_WEBUI_GATEWAY_REDIS_URL') ||
|
||||||
|
`redis://${config.get<string>('NAPCAT_WEBUI_GATEWAY_REDIS_HOST') || '127.0.0.1'}:${config.get<number>('NAPCAT_WEBUI_GATEWAY_REDIS_PORT') || 6379}`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class NapcatWebuiGatewayModule {}
|
||||||
|
```
|
||||||
|
|
||||||
内部路径:
|
内部路径:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -355,6 +382,7 @@ git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat W
|
|||||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`
|
- Create: `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`
|
||||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`
|
- Create: `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`
|
||||||
- Create: `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`
|
- Create: `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`
|
||||||
|
- Modify: `src/apps/napcat-webui-gateway/main.ts`
|
||||||
- Modify: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`
|
- Modify: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`
|
||||||
|
|
||||||
- [ ] **Step 1:写代理重写测试**
|
- [ ] **Step 1:写代理重写测试**
|
||||||
@ -365,7 +393,7 @@ git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat W
|
|||||||
- 禁止 `../api/auth/login`。
|
- 禁止 `../api/auth/login`。
|
||||||
- `api/QQLogin/CheckLoginStatus` 规范化成 `/api/QQLogin/CheckLoginStatus`。
|
- `api/QQLogin/CheckLoginStatus` 规范化成 `/api/QQLogin/CheckLoginStatus`。
|
||||||
- `Location: /webui/login` 重写到 `/napcat-webui/session/:sessionId/webui/webui/login`。
|
- `Location: /webui/login` 重写到 `/napcat-webui/session/:sessionId/webui/webui/login`。
|
||||||
- `Set-Cookie` 的 Path 改写到当前 session。
|
- `buildGatewayCookiePathRewrite({ sessionId })` 返回 `http-proxy-middleware` 的 `cookiePathRewrite` 配置,把 cookie path 限定到当前 session。
|
||||||
|
|
||||||
- [ ] **Step 2:运行测试确认 RED**
|
- [ ] **Step 2:运行测试确认 RED**
|
||||||
|
|
||||||
@ -381,7 +409,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
|
|||||||
|
|
||||||
- [ ] **Step 4:实现 proxy helper**
|
- [ ] **Step 4:实现 proxy helper**
|
||||||
|
|
||||||
导出 `sanitizeGatewayProxyPath`、`rewriteNapcatLocationHeader`、`rewriteNapcatSetCookieHeader`,逻辑与英文计划一致。
|
导出 `sanitizeGatewayProxyPath`、`rewriteNapcatLocationHeader`、`buildGatewayCookiePathRewrite`,逻辑与英文计划一致。Cookie path 改写交给 `http-proxy-middleware` 的 `cookiePathRewrite`,不要手写 `Set-Cookie` 字符串替换。
|
||||||
|
|
||||||
- [ ] **Step 5:实现 proxy service**
|
- [ ] **Step 5:实现 proxy service**
|
||||||
|
|
||||||
@ -390,6 +418,13 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
|
|||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
cookiePathRewrite: buildGatewayCookiePathRewrite({ sessionId }),
|
||||||
|
on: {
|
||||||
|
proxyReq: handleProxyReq,
|
||||||
|
proxyReqWs: handleProxyReqWs,
|
||||||
|
proxyRes: handleProxyRes,
|
||||||
|
},
|
||||||
|
pathRewrite: (_path, req) => sanitizeGatewayProxyPath(req.params[0] || ''),
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
selfHandleResponse: false,
|
selfHandleResponse: false,
|
||||||
@ -398,6 +433,13 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
|
|||||||
|
|
||||||
代理前必须解析 session,拒绝非 active/created session,换取 Credential,注入 `Authorization: Bearer <credential>`,删除浏览器传入的 API/Admin cookies,不允许浏览器改变 target。
|
代理前必须解析 session,拒绝非 active/created session,换取 Credential,注入 `Authorization: Bearer <credential>`,删除浏览器传入的 API/Admin cookies,不允许浏览器改变 target。
|
||||||
|
|
||||||
|
WebSocket upgrade 必须仍走 `http-proxy-middleware`,不能通过 MQTT 搬运 WebUI 数据帧,也不要手写 WebSocket tunnel。`NapcatWebuiProxyService` 暴露 `bindWebSocketUpgrade(server)`,内部用 HPM 的 `proxy.upgrade(req, socket, head)`;`main.ts` 在 `app.listen()` 后调用:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(server);
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] **Step 6:实现公开 controller**
|
- [ ] **Step 6:实现公开 controller**
|
||||||
|
|
||||||
公开路径:
|
公开路径:
|
||||||
@ -407,7 +449,7 @@ GET /napcat-webui/session/:sessionId/bootstrap
|
|||||||
ALL /napcat-webui/session/:sessionId/webui/*
|
ALL /napcat-webui/session/:sessionId/webui/*
|
||||||
```
|
```
|
||||||
|
|
||||||
bootstrap 兑换一次性 ticket,设置 HttpOnly session cookie,跳转到 `/napcat-webui/session/:sessionId/webui/webui`。
|
bootstrap 兑换一次性 ticket,设置 HttpOnly session cookie,跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。
|
||||||
|
|
||||||
- [ ] **Step 7:运行测试和类型检查**
|
- [ ] **Step 7:运行测试和类型检查**
|
||||||
|
|
||||||
@ -611,7 +653,7 @@ pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vit
|
|||||||
|
|
||||||
- [ ] **Step 3:实现生命周期 composable**
|
- [ ] **Step 3:实现生命周期 composable**
|
||||||
|
|
||||||
创建 `useNapcatWebuiGatewaySession.ts`,状态为 `idle/loading/ready/error/revoked`。`open()` 创建 session,ready 后每 20 秒 heartbeat,heartbeat 失败进入 error 并停止心跳,`revoke()` 停心跳并调用 revoke endpoint,`onBeforeUnmount` 自动 revoke。每个函数补 JSDoc。
|
创建 `useNapcatWebuiGatewaySession.ts`,状态为 `idle/loading/ready/error/revoked`。使用 Admin 已有的 `@vueuse/core` `useIntervalFn(callback, 20_000, { immediate: false })` 管理 heartbeat,不手写原生定时器。`open()` 创建 session,ready 后 `resumeHeartbeat()`;heartbeat 失败进入 error 并 `pauseHeartbeat()`;`revoke()` 先 `pauseHeartbeat()` 再调 revoke endpoint;`onBeforeUnmount` 自动 revoke。每个函数补 JSDoc。
|
||||||
|
|
||||||
- [ ] **Step 4:实现 route 页面**
|
- [ ] **Step 4:实现 route 页面**
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user