From 810a420a46f527a6ef736458593d12892935db1b Mon Sep 17 00:00:00 2001 From: dmy Date: Fri, 16 Jan 2026 23:31:58 +0800 Subject: [PATCH] feat(date): enhance date formatting and timezone handling - Update formatDateTime function to handle timezone-aware date strings directly. - Introduce utcToBeijingISOString function for consistent Beijing time formatting in ISO string. - Modify BidsService to utilize the new utcToBeijingISOString for date conversions. - Add unit tests for AuthGuard to validate API key authentication and access control. - Create end-to-end tests for API key handling in various scenarios. - Update .gitignore to exclude 'qingyun' directory. - Add environment configuration for production settings in .env.production. - Include Tailwind CSS setup in the uni-app version for styling. --- .gitignore | 1 + eslint.config.mjs | 2 +- frontend/src/utils/date.util.ts | 11 + src/bids/services/bid.service.ts | 8 +- src/common/auth/auth.guard.spec.ts | 329 ++++++++++++++++++++++++ src/common/utils/timezone.util.ts | 25 ++ test/auth.e2e-spec.ts | 142 ++++++++++ uni-app-version/.env.production | 10 + uni-app-version/src/styles/tailwind.css | 4 + 9 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 src/common/auth/auth.guard.spec.ts create mode 100644 test/auth.e2e-spec.ts create mode 100644 uni-app-version/.env.production create mode 100644 uni-app-version/src/styles/tailwind.css diff --git a/.gitignore b/.gitignore index 331e848..a6210de 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ widget/looker/frontend/src/assets/fonts/OFL.txt dist-electron unpackage .cursor +qingyun \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e9f827..3fc5203 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,7 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', - "prettier/prettier": ["error", { endOfLine: "auto" }], + "prettier/prettier": "off", }, }, ); diff --git a/frontend/src/utils/date.util.ts b/frontend/src/utils/date.util.ts index bf509c5..a45cd3a 100644 --- a/frontend/src/utils/date.util.ts +++ b/frontend/src/utils/date.util.ts @@ -28,6 +28,17 @@ export function formatDate(dateStr: string | null | undefined): string { */ export function formatDateTime(dateStr: string | null | undefined): string { 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) if (isNaN(date.getTime())) return '-' diff --git a/src/bids/services/bid.service.ts b/src/bids/services/bid.service.ts index 37fc847..eb45555 100644 --- a/src/bids/services/bid.service.ts +++ b/src/bids/services/bid.service.ts @@ -8,6 +8,7 @@ import { setStartOfDay, setEndOfDay, utcToBeijing, + utcToBeijingISOString, } from '../../common/utils/timezone.util'; interface FindAllQuery { @@ -179,12 +180,13 @@ export class BidsService { await this.crawlInfoRepository.query(query); return results.map((item) => { - // 将UTC时间转换为北京时间显示 + // 将UTC时间转换为北京时间的ISO字符串格式 + // 这样前端接收到的时间字符串已经是正确的北京时间,不需要再次转换 const latestUpdateBeijing = item.latestUpdate - ? utcToBeijing(new Date(item.latestUpdate)) + ? utcToBeijingISOString(new Date(item.latestUpdate)) : null; const latestPublishDateBeijing = item.latestPublishDate - ? utcToBeijing(new Date(item.latestPublishDate)) + ? utcToBeijingISOString(new Date(item.latestPublishDate)) : null; return { diff --git a/src/common/auth/auth.guard.spec.ts b/src/common/auth/auth.guard.spec.ts new file mode 100644 index 0000000..76451e3 --- /dev/null +++ b/src/common/auth/auth.guard.spec.ts @@ -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; + + const createMockExecutionContext = (request: Partial): 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); + configService = module.get(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, + ); + }); + }); +}); diff --git a/src/common/utils/timezone.util.ts b/src/common/utils/timezone.util.ts index 3a7ab47..3b683d3 100644 --- a/src/common/utils/timezone.util.ts +++ b/src/common/utils/timezone.util.ts @@ -116,3 +116,28 @@ export function parseDateString(dateStr: string): Date { const date = new Date(dateStr); 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`; +} diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts new file mode 100644 index 0000000..a5f1577 --- /dev/null +++ b/test/auth.e2e-spec.ts @@ -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; + 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); + }); + }); + }); +}); diff --git a/uni-app-version/.env.production b/uni-app-version/.env.production new file mode 100644 index 0000000..4e500b4 --- /dev/null +++ b/uni-app-version/.env.production @@ -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 \ No newline at end of file diff --git a/uni-app-version/src/styles/tailwind.css b/uni-app-version/src/styles/tailwind.css new file mode 100644 index 0000000..10ab1e6 --- /dev/null +++ b/uni-app-version/src/styles/tailwind.css @@ -0,0 +1,4 @@ +/* Tailwind CSS - 使用 PostCSS 处理,避免 Sass 警告 */ +@tailwind base; +@tailwind components; +@tailwind utilities;