Compare commits
2 Commits
9257c78e72
...
810a420a46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810a420a46 | ||
|
|
300e930c64 |
5
.env
5
.env
@@ -42,4 +42,7 @@ LOG_LEVEL=debug
|
|||||||
# OpenAI API Key (用于 AI 推荐)
|
# OpenAI API Key (用于 AI 推荐)
|
||||||
ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a
|
ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a
|
||||||
|
|
||||||
SSH_PASSPHRASE=x
|
SSH_PASSPHRASE=x
|
||||||
|
|
||||||
|
API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,6 @@ PROXY_PORT=6000
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# OpenAI API Key (用于 AI 推荐)
|
# OpenAI API Key (用于 AI 推荐)
|
||||||
ARK_API_KEY=your_openai_api_key_here
|
ARK_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
|
API_KEY=your_secure_api_key_here
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ widget/looker/frontend/src/assets/fonts/OFL.txt
|
|||||||
dist-electron
|
dist-electron
|
||||||
unpackage
|
unpackage
|
||||||
.cursor
|
.cursor
|
||||||
|
qingyun
|
||||||
@@ -29,7 +29,7 @@ export default tseslint.config(
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
"prettier/prettier": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,52 @@
|
|||||||
import axios from 'axios'
|
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API配置
|
* API配置
|
||||||
* 配置axios实例,设置baseURL和请求拦截器
|
* 配置axios实例,设置baseURL和请求拦截器
|
||||||
*/
|
*/
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: 'http://localhost:3000', // 设置后端服务地址
|
baseURL:
|
||||||
|
(import.meta.env.VITE_API_BASE_URL as string) || 'http://localhost:3000', // 设置后端服务地址
|
||||||
timeout: 120000, // 请求超时时间(120秒)
|
timeout: 120000, // 请求超时时间(120秒)
|
||||||
})
|
});
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(config) => {
|
(config: AxiosRequestConfig) => {
|
||||||
// 可以在这里添加认证信息等
|
// 如果 baseURL 不是 localhost,自动添加 API Key
|
||||||
return config
|
const baseURL =
|
||||||
|
(config.baseURL as string) ||
|
||||||
|
(api.defaults.baseURL as string) ||
|
||||||
|
'';
|
||||||
|
const isLocalhost =
|
||||||
|
baseURL.includes('localhost') || baseURL.includes('127.0.0.1');
|
||||||
|
|
||||||
|
if (!isLocalhost) {
|
||||||
|
// 从环境变量或 localStorage 获取 API Key
|
||||||
|
const apiKey =
|
||||||
|
(import.meta.env.VITE_API_KEY as string) ||
|
||||||
|
localStorage.getItem('apiKey');
|
||||||
|
if (apiKey && config.headers) {
|
||||||
|
config.headers['X-API-Key'] = apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error: AxiosError) => {
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
return response
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error: AxiosError) => {
|
||||||
console.error('API请求错误:', error)
|
console.error('API请求错误:', error);
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export default api
|
export default api;
|
||||||
@@ -28,6 +28,17 @@ export function formatDate(dateStr: string | null | undefined): string {
|
|||||||
*/
|
*/
|
||||||
export function formatDateTime(dateStr: string | null | undefined): string {
|
export function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
|
|
||||||
|
// 如果时间字符串已经包含时区信息(如 +08:00),说明已经是正确的北京时间
|
||||||
|
// 直接从字符串中提取日期和时间部分,避免时区转换问题
|
||||||
|
const timezoneMatch = dateStr.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}):\d{2}(?:\.\d{3})?[+-]\d{2}:\d{2}$/)
|
||||||
|
|
||||||
|
if (timezoneMatch) {
|
||||||
|
// 时间字符串已包含时区,直接提取日期和时间
|
||||||
|
return `${timezoneMatch[1]} ${timezoneMatch[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有时区信息或格式不匹配,使用 Date 对象解析并转换
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
if (isNaN(date.getTime())) return '-'
|
if (isNaN(date.getTime())) return '-'
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { TasksModule } from './schedule/schedule.module';
|
|||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { LoggingMiddleware } from './common/logger/logging.middleware';
|
import { LoggingMiddleware } from './common/logger/logging.middleware';
|
||||||
import { AiModule } from './ai/ai.module';
|
import { AiModule } from './ai/ai.module';
|
||||||
|
import { AuthModule } from './common/auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +22,7 @@ import { AiModule } from './ai/ai.module';
|
|||||||
exclude: ['/api'],
|
exclude: ['/api'],
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
|
AuthModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
BidsModule,
|
BidsModule,
|
||||||
KeywordsModule,
|
KeywordsModule,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
setStartOfDay,
|
setStartOfDay,
|
||||||
setEndOfDay,
|
setEndOfDay,
|
||||||
utcToBeijing,
|
utcToBeijing,
|
||||||
|
utcToBeijingISOString,
|
||||||
} from '../../common/utils/timezone.util';
|
} from '../../common/utils/timezone.util';
|
||||||
|
|
||||||
interface FindAllQuery {
|
interface FindAllQuery {
|
||||||
@@ -179,12 +180,13 @@ export class BidsService {
|
|||||||
await this.crawlInfoRepository.query<CrawlInfoAddRawResult[]>(query);
|
await this.crawlInfoRepository.query<CrawlInfoAddRawResult[]>(query);
|
||||||
|
|
||||||
return results.map((item) => {
|
return results.map((item) => {
|
||||||
// 将UTC时间转换为北京时间显示
|
// 将UTC时间转换为北京时间的ISO字符串格式
|
||||||
|
// 这样前端接收到的时间字符串已经是正确的北京时间,不需要再次转换
|
||||||
const latestUpdateBeijing = item.latestUpdate
|
const latestUpdateBeijing = item.latestUpdate
|
||||||
? utcToBeijing(new Date(item.latestUpdate))
|
? utcToBeijingISOString(new Date(item.latestUpdate))
|
||||||
: null;
|
: null;
|
||||||
const latestPublishDateBeijing = item.latestPublishDate
|
const latestPublishDateBeijing = item.latestPublishDate
|
||||||
? utcToBeijing(new Date(item.latestPublishDate))
|
? utcToBeijingISOString(new Date(item.latestPublishDate))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
329
src/common/auth/auth.guard.spec.ts
Normal file
329
src/common/auth/auth.guard.spec.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { AuthGuard } from './auth.guard';
|
||||||
|
|
||||||
|
describe('AuthGuard', () => {
|
||||||
|
let guard: AuthGuard;
|
||||||
|
let configService: ConfigService;
|
||||||
|
let mockExecutionContext: ExecutionContext;
|
||||||
|
let mockRequest: Partial<Request>;
|
||||||
|
|
||||||
|
const createMockExecutionContext = (request: Partial<Request>): ExecutionContext => {
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request as Request,
|
||||||
|
getResponse: () => ({}),
|
||||||
|
getNext: () => ({}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthGuard,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = module.get<AuthGuard>(AuthGuard);
|
||||||
|
configService = module.get<ConfigService>(ConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('本地IP访问', () => {
|
||||||
|
it('应该允许 127.0.0.1 访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
socket: { remoteAddress: '127.0.0.1' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许 ::1 (IPv6本地地址) 访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '::1',
|
||||||
|
socket: { remoteAddress: '::1' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许 localhost 访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: 'localhost',
|
||||||
|
socket: { remoteAddress: 'localhost' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许通过 X-Forwarded-For 传递的本地IP访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '192.168.1.1',
|
||||||
|
socket: { remoteAddress: '192.168.1.1' },
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '127.0.0.1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('公网IP访问 - 已配置API_KEY', () => {
|
||||||
|
const validApiKey = 'test-api-key-12345';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue(validApiKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许提供正确API Key的公网访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
socket: { remoteAddress: '8.8.8.8' },
|
||||||
|
headers: {
|
||||||
|
'x-api-key': validApiKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝未提供API Key的公网访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
socket: { remoteAddress: '8.8.8.8' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
'Invalid or missing API Key',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝提供错误API Key的公网访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
socket: { remoteAddress: '8.8.8.8' },
|
||||||
|
headers: {
|
||||||
|
'x-api-key': 'wrong-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
'Invalid or missing API Key',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝提供空字符串API Key的公网访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
socket: { remoteAddress: '8.8.8.8' },
|
||||||
|
headers: {
|
||||||
|
'x-api-key': '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理 X-Forwarded-For 中的公网IP', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
socket: { remoteAddress: '127.0.0.1' },
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '8.8.8.8',
|
||||||
|
'x-api-key': validApiKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理多个IP的 X-Forwarded-For 头(取第一个)', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
socket: { remoteAddress: '127.0.0.1' },
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '8.8.8.8, 192.168.1.1',
|
||||||
|
'x-api-key': validApiKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('公网IP访问 - 未配置API_KEY', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许所有公网访问(开发环境)', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
socket: { remoteAddress: '8.8.8.8' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许未提供API Key的公网访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
socket: { remoteAddress: '8.8.8.8' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('内网IP访问', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue('test-api-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该要求内网IP提供API Key', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
socket: { remoteAddress: '192.168.1.100' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许提供正确API Key的内网访问', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
socket: { remoteAddress: '192.168.1.100' },
|
||||||
|
headers: {
|
||||||
|
'x-api-key': 'test-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
const result = guard.canActivate(mockExecutionContext);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该要求 10.x.x.x 网段提供API Key', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
socket: { remoteAddress: '10.0.0.1' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该要求 172.16-31.x.x 网段提供API Key', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: '172.16.0.1',
|
||||||
|
socket: { remoteAddress: '172.16.0.1' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界情况', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue('test-api-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 unknown IP 地址', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: 'unknown',
|
||||||
|
socket: { remoteAddress: 'unknown' },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理缺少 IP 信息的请求', () => {
|
||||||
|
mockRequest = {
|
||||||
|
ip: undefined,
|
||||||
|
socket: { remoteAddress: undefined },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 API Key 大小写敏感', () => {
|
||||||
|
jest.spyOn(configService, 'get').mockReturnValue('Test-API-Key');
|
||||||
|
mockRequest = {
|
||||||
|
ip: '8.8.8.8',
|
||||||
|
socket: { remoteAddress: '8.8.8.8' },
|
||||||
|
headers: {
|
||||||
|
'x-api-key': 'test-api-key', // 小写,应该被拒绝
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockExecutionContext = createMockExecutionContext(mockRequest);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(mockExecutionContext)).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/common/auth/auth.guard.ts
Normal file
83
src/common/auth/auth.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const clientIp = this.getClientIp(request);
|
||||||
|
|
||||||
|
// 检查是否为本地 IP
|
||||||
|
if (this.isLocalIp(clientIp)) {
|
||||||
|
return true; // 本地访问直接放行
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公网访问需要验证 API Key
|
||||||
|
const apiKey = request.headers['x-api-key'] as string;
|
||||||
|
const expectedApiKey = this.configService.get<string>('API_KEY');
|
||||||
|
|
||||||
|
if (!expectedApiKey) {
|
||||||
|
// 如果未配置 API_KEY,允许所有访问(开发环境)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey || apiKey !== expectedApiKey) {
|
||||||
|
throw new UnauthorizedException('Invalid or missing API Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户端真实 IP
|
||||||
|
* 支持反向代理场景(X-Forwarded-For)
|
||||||
|
*/
|
||||||
|
private getClientIp(request: Request): string {
|
||||||
|
const forwardedFor = request.headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
// X-Forwarded-For 可能包含多个 IP,取第一个
|
||||||
|
const ips = (forwardedFor as string).split(',');
|
||||||
|
return ips[0].trim();
|
||||||
|
}
|
||||||
|
return request.ip || request.socket.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为本地 IP
|
||||||
|
*/
|
||||||
|
private isLocalIp(ip: string): boolean {
|
||||||
|
if (!ip || ip === 'unknown') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4 本地地址
|
||||||
|
if (ip === '127.0.0.1' || ip === 'localhost' || ip.startsWith('127.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 本地地址
|
||||||
|
if (ip === '::1' || ip === '::ffff:127.0.0.1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内网地址(可选,根据需求决定是否允许)
|
||||||
|
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||||
|
const privateIpRegex =
|
||||||
|
/^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
|
||||||
|
if (privateIpRegex.test(ip)) {
|
||||||
|
// 如果需要内网也免鉴权,可以返回 true
|
||||||
|
// 这里默认返回 false,内网也需要鉴权
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/common/auth/auth.module.ts
Normal file
13
src/common/auth/auth.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from './auth.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: AuthGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -116,3 +116,28 @@ export function parseDateString(dateStr: string): Date {
|
|||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return convertToTimezone(date);
|
return convertToTimezone(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将UTC时间转换为北京时间的ISO字符串格式
|
||||||
|
* 用于API返回,确保前端接收到的时间字符串已经是北京时间
|
||||||
|
* @param date UTC时间的Date对象
|
||||||
|
* @returns 北京时间的ISO字符串 (格式: YYYY-MM-DDTHH:mm:ss+08:00)
|
||||||
|
*/
|
||||||
|
export function utcToBeijingISOString(date: Date): string {
|
||||||
|
// 获取UTC时间戳(毫秒)
|
||||||
|
const utcTimestamp = date.getTime();
|
||||||
|
// 计算北京时间戳(UTC + 8小时)
|
||||||
|
const beijingTimestamp = utcTimestamp + TIMEZONE_OFFSET;
|
||||||
|
// 创建UTC Date对象来格式化(避免本地时区影响)
|
||||||
|
const beijingDate = new Date(beijingTimestamp);
|
||||||
|
|
||||||
|
// 使用UTC方法获取时间组件,确保不受本地时区影响
|
||||||
|
const year = beijingDate.getUTCFullYear();
|
||||||
|
const month = String(beijingDate.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(beijingDate.getUTCDate()).padStart(2, '0');
|
||||||
|
const hours = String(beijingDate.getUTCHours()).padStart(2, '0');
|
||||||
|
const minutes = String(beijingDate.getUTCMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(beijingDate.getUTCSeconds()).padStart(2, '0');
|
||||||
|
const milliseconds = String(beijingDate.getUTCMilliseconds()).padStart(3, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}+08:00`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ async function bootstrap() {
|
|||||||
// 启用 CORS
|
// 启用 CORS
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
|
||||||
|
// 信任代理(用于获取真实客户端 IP)
|
||||||
|
const httpAdapter = app.getHttpAdapter();
|
||||||
|
httpAdapter.getInstance().set('trust proxy', true);
|
||||||
|
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
142
test/auth.e2e-spec.ts
Normal file
142
test/auth.e2e-spec.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('AuthGuard (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
const validApiKey = 'test-e2e-api-key-12345';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 设置测试环境变量
|
||||||
|
process.env.API_KEY = validApiKey;
|
||||||
|
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
// 清理环境变量
|
||||||
|
delete process.env.API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('本地IP访问', () => {
|
||||||
|
it('应该允许本地IP访问(无需API Key)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '127.0.0.1')
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许 IPv6 本地地址访问', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '::1')
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('公网IP访问 - 已配置API_KEY', () => {
|
||||||
|
it('应该允许提供正确API Key的公网访问', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '8.8.8.8')
|
||||||
|
.set('X-API-Key', validApiKey)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝未提供API Key的公网访问', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '8.8.8.8')
|
||||||
|
.expect(401)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.message).toBe('Invalid or missing API Key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝提供错误API Key的公网访问', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '8.8.8.8')
|
||||||
|
.set('X-API-Key', 'wrong-api-key')
|
||||||
|
.expect(401)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.message).toBe('Invalid or missing API Key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝提供空字符串API Key的公网访问', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '8.8.8.8')
|
||||||
|
.set('X-API-Key', '')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('公网IP访问 - 未配置API_KEY', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
// 清除 API_KEY
|
||||||
|
delete process.env.API_KEY;
|
||||||
|
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许所有公网访问(开发环境)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '8.8.8.8')
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('内网IP访问', () => {
|
||||||
|
it('应该要求内网IP提供API Key', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '192.168.1.100')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许提供正确API Key的内网访问', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/keywords')
|
||||||
|
.set('X-Forwarded-For', '192.168.1.100')
|
||||||
|
.set('X-API-Key', validApiKey)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('多个API端点', () => {
|
||||||
|
it('应该对所有API端点应用鉴权', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/bids')
|
||||||
|
.set('X-Forwarded-For', '8.8.8.8')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该允许带正确API Key访问所有端点', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/bids')
|
||||||
|
.set('X-Forwarded-For', '8.8.8.8')
|
||||||
|
.set('X-API-Key', validApiKey)
|
||||||
|
.expect((res) => {
|
||||||
|
// 可能返回 200 或 404,但不应该是 401
|
||||||
|
expect(res.status).not.toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
10
uni-app-version/.env.production
Normal file
10
uni-app-version/.env.production
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# API 配置
|
||||||
|
VITE_API_BASE_URL=http://47.236.17.71:13002
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
VITE_APP_TITLE=投标项目查看器`nVITE_APP_VERSION=1.0.0
|
||||||
|
|
||||||
|
# 刷新间隔(毫秒)
|
||||||
|
VITE_AUTO_REFRESH_INTERVAL=300000
|
||||||
|
|
||||||
|
VITE_API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e
|
||||||
4
uni-app-version/src/styles/tailwind.css
Normal file
4
uni-app-version/src/styles/tailwind.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/* Tailwind CSS - 使用 PostCSS 处理,避免 Sass 警告 */
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
Reference in New Issue
Block a user