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.
This commit is contained in:
dmy
2026-01-16 23:31:58 +08:00
parent 300e930c64
commit 810a420a46
9 changed files with 528 additions and 4 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ widget/looker/frontend/src/assets/fonts/OFL.txt
dist-electron dist-electron
unpackage unpackage
.cursor .cursor
qingyun

View File

@@ -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",
}, },
}, },
); );

View File

@@ -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 '-'

View File

@@ -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 {

View 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,
);
});
});
});

View File

@@ -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`;
}

142
test/auth.e2e-spec.ts Normal file
View 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);
});
});
});
});

View 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

View File

@@ -0,0 +1,4 @@
/* Tailwind CSS - 使用 PostCSS 处理,避免 Sass 警告 */
@tailwind base;
@tailwind components;
@tailwind utilities;