Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Install dependencies
run: yarn install --immutable

- name: Type check
run: yarn type-check

- name: Lint server
run: yarn workspace @my-first-nest/server eslint "{src,database,test}/**/*.ts"

Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production node dist/src/main",
"type-check": "tsc -b",
"migration:generate": "ts-node database/migration.ts generate",
"migration:revert": "ts-node database/migration.ts revert",
"db:init": "ts-node -r tsconfig-paths/register database/manage.ts init",
Expand Down
29 changes: 28 additions & 1 deletion apps/server/src/config/configuration.interface.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,81 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';

export interface ServerConfig {
/** HTTP 监听端口 */
port?: number;
/** 全局 API 前缀,例如 `api` */
apiPrefix?: string;
/** 请求超时时间,单位秒 */
timeout?: number;
}

export interface SwaggerConfig {
/** 是否启用 Swagger */
enabled?: boolean;
/** Swagger UI 路径,不含前导斜杠 */
path?: string;
/** 文档标题 */
title?: string;
/** 文档描述 */
description?: string;
/** 文档版本号 */
version?: string;
}

export interface JwtConfig {
/** 签名和校验 JWT 的密钥 */
secret?: string;
/** access token 过期时间,单位秒 */
accessExpiresIn?: number;
/** refresh token 过期时间,单位秒 */
refreshExpiresIn?: number;
}

export interface SnowflakeConfig {
/** 工作节点 ID,范围 0-31 */
workerId: number;
/** 数据中心 ID,范围 0-31 */
datacenterId: number;
}

export interface RedisConfig {
/** Redis 连接 URL,与 host 二选一 */
url?: string;
/** Redis 主机,与 url 二选一 */
host?: string;
/** Redis 端口 */
port?: number;
/** Redis 密码 */
password?: string;
/** Redis DB 索引,范围 0-15 */
db?: number;
/** 缓存默认 TTL,单位秒;0 表示不过期 */
defaultTtl?: number;
/** 缓存和队列的 Key 前缀 */
keyPrefix?: string;
}

export interface ThrottlerConfig {
/** 默认时间窗口(毫秒) */
/** 默认限流窗口,单位毫秒 */
ttl: number;
/** 窗口内最大请求数 */
limit: number;
}

export interface AppConfig {
/** HTTP 服务配置 */
server?: ServerConfig;
/** Swagger 文档配置 */
swagger?: SwaggerConfig;
/** TypeORM 数据库配置 */
database?: TypeOrmModuleOptions;
/** JWT 鉴权配置 */
jwt?: JwtConfig;
/** 雪花 ID 配置 */
snowflake?: SnowflakeConfig;
/** Redis 连接和缓存配置 */
redis?: RedisConfig;
/** 全局限流配置 */
throttler?: ThrottlerConfig;
}

Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/shared/caching/cache.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,14 @@ const buildRedisUrl = (redis: {
});

return {
stores: [new Keyv({ store: keyvRedis, namespace: redis.keyPrefix })],
// KeyvRedis 自己会加 namespace::,这里再开 useKeyPrefix 会变成 prefix:prefix::key
stores: [
new Keyv({
store: keyvRedis,
namespace: redis.keyPrefix,
useKeyPrefix: false,
}),
],
ttl,
};
},
Expand Down
24 changes: 19 additions & 5 deletions apps/server/src/shared/caching/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,38 @@ export class CacheService {
const separator = redisStore.keyPrefixSeparator;
const formatKey = (key: string) =>
namespace ? `${namespace}${separator}${key}` : key;
const ttlMs = ttlSeconds * 1000;
const serializedValue = JSON.stringify({
value: expectedValue,
...(ttlMs > 0 ? { expires: Date.now() + ttlMs } : {}),
});

const result = await client.eval(
`
if redis.call("GET", KEYS[1]) ~= ARGV[1] then
local raw = redis.call("GET", KEYS[1])
if not raw then
return 0
end

-- Keyv 存的是 {"value":"...","expires":...},也兼容历史裸字符串
if raw ~= ARGV[1] then
local ok, data = pcall(cjson.decode, raw)
if (not ok) or data["value"] ~= ARGV[1] then
return 0
end
end

redis.call("DEL", KEYS[1])
if tonumber(ARGV[2]) > 0 then
redis.call("SET", KEYS[2], ARGV[1], "PX", ARGV[2])
if tonumber(ARGV[3]) > 0 then
redis.call("SET", KEYS[2], ARGV[2], "PX", ARGV[3])
else
redis.call("SET", KEYS[2], ARGV[1])
redis.call("SET", KEYS[2], ARGV[2])
end
return 1
`,
{
keys: [formatKey(oldKey), formatKey(newKey)],
arguments: [expectedValue, String(ttlSeconds * 1000)],
arguments: [expectedValue, serializedValue, String(ttlMs)],
},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import { Injectable, NestMiddleware } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { NextFunction, Request, Response } from 'express';

type JobBoardRequest = Pick<Request, 'headers' | 'cookies'>;
type JobBoardResponse = Pick<Response, 'status' | 'json'>;

@Injectable()
export class JobBoardAuthMiddleware implements NestMiddleware {
constructor(
private readonly jwtService: JwtService,
private readonly usersService: UsersService,
) {}

async use(req: Request, res: Response, next: NextFunction) {
async use(req: JobBoardRequest, res: JobBoardResponse, next: NextFunction) {
const tokenResult = this.extractToken(req);
if (!tokenResult) {
this.reject(res);
Expand All @@ -33,7 +36,7 @@ export class JobBoardAuthMiddleware implements NestMiddleware {
}
}

private extractToken(req: Request) {
private extractToken(req: JobBoardRequest) {
const bearerToken = this.extractBearerToken(req.headers.authorization);
if (bearerToken) {
return { token: bearerToken, tokenType: TokenType.ACCESS };
Expand All @@ -53,7 +56,7 @@ export class JobBoardAuthMiddleware implements NestMiddleware {
return token || null;
}

private reject(res: Response) {
private reject(res: JobBoardResponse) {
res.status(401).json({
statusCode: 401,
message: 'Unauthorized',
Expand Down
10 changes: 7 additions & 3 deletions apps/server/src/shared/jobs/board/job-board.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { Queue } from 'bullmq';
import { Express } from 'express';
import type { Express, RequestHandler } from 'express';
import { DEFAULT_JOB_QUEUE } from '../constants/job.constants';
import { JobQueueModule } from '../queue/job-queue.module';
import { IBullJobData } from '../types/job.types';
Expand Down Expand Up @@ -55,7 +55,11 @@ export class JobBoardModule implements OnModuleInit {

const app: Express =
this.httpAdapterHost.httpAdapter.getInstance<Express>();
app.use(JOB_BOARD_PATH, this.authMiddleware.use.bind(this.authMiddleware));
app.use(JOB_BOARD_PATH, serverAdapter.getRouter());
const boardAuthMiddleware: RequestHandler = (req, res, next) =>
this.authMiddleware.use(req, res, next);
const boardRouter = serverAdapter.getRouter() as RequestHandler;

app.use(JOB_BOARD_PATH, boardAuthMiddleware);
app.use(JOB_BOARD_PATH, boardRouter);
}
}
2 changes: 1 addition & 1 deletion apps/server/src/shared/jobs/services/job.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class JobService {
payload: input.payload,
},
{
jobId: run.id,
jobId: `job-${run.id}`,
delayMs,
attempts: maxAttempts,
backoffMs: input.backoffMs,
Expand Down
14 changes: 8 additions & 6 deletions apps/server/tests/e2e/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { TestHelper } from './helpers/test-helper';
import request from 'supertest';
import { describe, expect, it, beforeAll, afterAll, beforeEach } from 'vitest';

const getSetCookieHeaders = (headers: Record<string, string | string[]>) => {
const setCookie = headers['set-cookie'];
return Array.isArray(setCookie) ? setCookie : [];
};

describe('Auth (e2e)', () => {
let helper: TestHelper;

Expand Down Expand Up @@ -83,8 +88,7 @@ describe('Auth (e2e)', () => {
expect(res.body).toHaveProperty('expiresAt');

// should set refreshToken cookie
const cookies = res.headers['set-cookie'] as string[];
expect(cookies).toBeDefined();
const cookies = getSetCookieHeaders(res.headers);
expect(cookies.some((c) => c.startsWith('refreshToken='))).toBe(true);
});

Expand Down Expand Up @@ -121,8 +125,7 @@ describe('Auth (e2e)', () => {
expect(res.body).toHaveProperty('expiresAt');

// should set new refreshToken cookie
const cookies = res.headers['set-cookie'] as string[];
expect(cookies).toBeDefined();
const cookies = getSetCookieHeaders(res.headers);
expect(cookies.some((c) => c.startsWith('refreshToken='))).toBe(true);
});

Expand Down Expand Up @@ -153,8 +156,7 @@ describe('Auth (e2e)', () => {
expect(res.body.message).toBe('Logout successfully');

// should clear refreshToken cookie
const cookies = res.headers['set-cookie'] as string[];
expect(cookies).toBeDefined();
const cookies = getSetCookieHeaders(res.headers);
expect(cookies.some((c) => c.includes('refreshToken=;'))).toBe(true);
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ExportReportHandler } from '@/modules/background-tasks/handlers/export-report.handler';
import {
ExportReportHandler,
IExportReportPayload,
} from '@/modules/background-tasks/handlers/export-report.handler';
import { JOB_NAMES } from '@/shared/jobs/constants/job.constants';
import { JobRegistryService } from '@/shared/jobs/registry/job-registry.service';
import { IJobContext } from '@/shared/jobs/types/job.types';
Expand All @@ -23,7 +26,7 @@ describe('ExportReportHandler', () => {
expect(handler.name).toBe(JOB_NAMES.EXPORT_REPORT);

const updateProgress = vi.fn(() => Promise.resolve());
const ctx: IJobContext = {
const ctx: IJobContext<IExportReportPayload> = {
jobId: 'job-1',
name: JOB_NAMES.EXPORT_REPORT,
payload: { title: 'report', steps: 2, stepDelayMs: 100 },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { FlakyRetryHandler } from '@/modules/background-tasks/handlers/flaky-retry.handler';
import {
FlakyRetryHandler,
IFlakyRetryPayload,
} from '@/modules/background-tasks/handlers/flaky-retry.handler';
import { JOB_NAMES } from '@/shared/jobs/constants/job.constants';
import { JobRegistryService } from '@/shared/jobs/registry/job-registry.service';
import { IJobContext } from '@/shared/jobs/types/job.types';
import { describe, expect, it, vi } from 'vitest';

const createCtx = (attemptsMade: number, failTimes = 2): IJobContext => ({
const createCtx = (
attemptsMade: number,
failTimes = 2,
): IJobContext<IFlakyRetryPayload> => ({
jobId: 'job-1',
name: JOB_NAMES.FLAKY_RETRY,
payload: { failTimes },
Expand Down
20 changes: 17 additions & 3 deletions apps/server/tests/unit/shared/caching/cache.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CacheService } from '@/shared/caching/cache.service';
import { describe, expect, it, MockInstance, vi } from 'vitest';
import { afterEach, describe, expect, it, MockInstance, vi } from 'vitest';

type MockCache = {
del: MockInstance<(key: string) => Promise<boolean>>;
Expand Down Expand Up @@ -70,6 +70,10 @@ const createServiceWithRedis = (redisStore: Record<string, unknown> | null) => {
};

describe('CacheService', () => {
afterEach(() => {
vi.useRealTimers();
});

it('should delegate get set del and wrap to cache manager', async () => {
const cache = createCache();
const service = new CacheService(cache as never);
Expand Down Expand Up @@ -133,6 +137,9 @@ describe('CacheService', () => {
});

it('should rotate refresh token via lua script', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));

const redisClient = createMockRedisClient();
const { service } = createServiceWithRedis(redisClient);

Expand All @@ -146,11 +153,18 @@ describe('CacheService', () => {
expect(result).toBe(true);
expect(redisClient.evalFn).toHaveBeenCalledWith(expect.any(String), {
keys: ['old-token', 'new-token'],
arguments: ['expected-value', '60000'],
arguments: [
'expected-value',
'{"value":"expected-value","expires":1767225660000}',
'60000',
],
});
});

it('should rotate refresh token with namespace', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));

const redisClient = createMockRedisClient({
keyPrefixSeparator: ':',
namespace: 'myapp',
Expand All @@ -161,7 +175,7 @@ describe('CacheService', () => {

expect(redisClient.evalFn).toHaveBeenCalledWith(expect.any(String), {
keys: ['myapp:old', 'myapp:new'],
arguments: ['val', '60000'],
arguments: ['val', '{"value":"val","expires":1767225660000}', '60000'],
});
});

Expand Down
Loading