create-pardx-scaffold 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/apps/api/libs/infra/clients/internal/ip-info/dto/ip-info.dto.ts +15 -0
- package/template/apps/api/libs/infra/clients/internal/ip-info/index.ts +3 -0
- package/template/apps/api/libs/infra/clients/internal/ip-info/ip-info.client.ts +57 -0
- package/template/apps/api/libs/infra/clients/internal/ip-info/ip-info.module.ts +17 -0
- package/template/apps/api/libs/infra/shared-services/file-storage/README.md +4 -4
- package/template/apps/api/libs/infra/shared-services/file-storage/bucket-resolver.ts +4 -4
- package/template/apps/api/libs/infra/shared-services/file-storage/file-storage.module.ts +3 -3
- package/template/apps/api/libs/infra/shared-services/ip-geo/continent-mapping.ts +86 -0
- package/template/apps/api/libs/infra/shared-services/ip-geo/index.ts +37 -0
- package/template/apps/api/libs/infra/shared-services/ip-geo/ip-geo.module.ts +21 -0
- package/template/apps/api/libs/infra/shared-services/ip-geo/ip-geo.service.ts +135 -0
- package/template/apps/api/libs/infra/shared-services/uploader/index.ts +1 -1
- package/template/apps/api/libs/infra/shared-services/uploader/uploader.service.ts +10 -2
- package/template/apps/api/src/app.module.ts +0 -13
- package/template/apps/api/src/modules/uploader/uploader.controller.ts +10 -25
- package/template/apps/web/components/index.ts +21 -0
- package/template/apps/web/components/skeletons.tsx +188 -0
- package/template/apps/web/components/suspense-utils.tsx +123 -0
- package/template/apps/web/lib/api/contracts/client.ts +4 -0
- package/template/apps/web/lib/deprecation-warning.ts +150 -0
- package/template/apps/web/lib/queries/optimistic-update.ts +204 -0
- package/template/packages/constants/src/index.ts +22 -0
- package/template/apps/api/src/modules/health/health.controller.ts +0 -13
- package/template/apps/api/src/modules/health/health.module.ts +0 -7
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP Info API Response DTO
|
|
3
|
+
*
|
|
4
|
+
* @description ipinfo.io API 返回的数据结构
|
|
5
|
+
*/
|
|
6
|
+
export interface IpInfoResponse {
|
|
7
|
+
ip: string;
|
|
8
|
+
country: string;
|
|
9
|
+
region?: string;
|
|
10
|
+
city?: string;
|
|
11
|
+
loc?: string;
|
|
12
|
+
org?: string;
|
|
13
|
+
postal?: string;
|
|
14
|
+
timezone?: string;
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP Info Client
|
|
3
|
+
*
|
|
4
|
+
* 职责:仅负责与 ipinfo.io API 通信
|
|
5
|
+
* - 调用 ipinfo.io API 获取 IP 地理位置信息
|
|
6
|
+
* - 不访问数据库
|
|
7
|
+
* - 不包含业务逻辑
|
|
8
|
+
*/
|
|
9
|
+
import { Injectable, Inject } from '@nestjs/common';
|
|
10
|
+
import { HttpService } from '@nestjs/axios';
|
|
11
|
+
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
|
12
|
+
import { Logger } from 'winston';
|
|
13
|
+
import { ConfigService } from '@nestjs/config';
|
|
14
|
+
import { firstValueFrom } from 'rxjs';
|
|
15
|
+
import { IpInfoResponse } from './dto/ip-info.dto';
|
|
16
|
+
import { IpInfoConfig } from '@/config/validation';
|
|
17
|
+
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class IpInfoClient {
|
|
20
|
+
private readonly config: IpInfoConfig;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly httpService: HttpService,
|
|
24
|
+
private readonly configService: ConfigService,
|
|
25
|
+
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
|
26
|
+
) {
|
|
27
|
+
this.config = configService.getOrThrow<IpInfoConfig>('ipinfo');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 获取 IP 地理位置信息
|
|
32
|
+
* @param ip IP 地址
|
|
33
|
+
* @returns IP 信息
|
|
34
|
+
*/
|
|
35
|
+
async getIpInfo(ip: string): Promise<IpInfoResponse> {
|
|
36
|
+
const url = `${this.config.url}/${ip}?token=${this.config.token}`;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const response = await firstValueFrom(
|
|
40
|
+
this.httpService.get<IpInfoResponse>(url, {
|
|
41
|
+
timeout: 10000,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
this.logger.debug(`IP info fetched for ${ip}`, {
|
|
46
|
+
country: response.data.country,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return response.data;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
this.logger.error(`Failed to fetch IP info for ${ip}`, {
|
|
52
|
+
error: (error as Error).message,
|
|
53
|
+
});
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { HttpModule } from '@nestjs/axios';
|
|
3
|
+
import { ConfigModule } from '@nestjs/config';
|
|
4
|
+
import { IpInfoClient } from './ip-info.client';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [
|
|
8
|
+
ConfigModule,
|
|
9
|
+
HttpModule.register({
|
|
10
|
+
timeout: 10000,
|
|
11
|
+
maxRedirects: 3,
|
|
12
|
+
}),
|
|
13
|
+
],
|
|
14
|
+
providers: [IpInfoClient],
|
|
15
|
+
exports: [IpInfoClient],
|
|
16
|
+
})
|
|
17
|
+
export class IpInfoClientModule {}
|
|
@@ -59,7 +59,7 @@ file-storage/
|
|
|
59
59
|
|
|
60
60
|
```typescript
|
|
61
61
|
import { Module } from '@nestjs/common';
|
|
62
|
-
import { FileStorageServiceModule } from '@app/services/file-storage';
|
|
62
|
+
import { FileStorageServiceModule } from '@app/shared-services/file-storage';
|
|
63
63
|
|
|
64
64
|
@Module({
|
|
65
65
|
imports: [FileStorageServiceModule],
|
|
@@ -71,7 +71,7 @@ export class VideoModule {}
|
|
|
71
71
|
|
|
72
72
|
```typescript
|
|
73
73
|
import { Injectable } from '@nestjs/common';
|
|
74
|
-
import { FileStorageService } from '@app/services/file-storage';
|
|
74
|
+
import { FileStorageService } from '@app/shared-services/file-storage';
|
|
75
75
|
import { FileBucketVendor } from '@prisma/client';
|
|
76
76
|
|
|
77
77
|
@Injectable()
|
|
@@ -244,7 +244,7 @@ const audioInfo = await fileStorage.getAudioInfo('oss', 'bucket', 'audio.mp3');
|
|
|
244
244
|
用于高级场景,直接操作存储客户端。
|
|
245
245
|
|
|
246
246
|
```typescript
|
|
247
|
-
import { FileStorageClientFactory } from '@app/services/file-storage';
|
|
247
|
+
import { FileStorageClientFactory } from '@app/shared-services/file-storage';
|
|
248
248
|
|
|
249
249
|
// 获取客户端
|
|
250
250
|
const client = factory.getClient('oss', 'my-bucket');
|
|
@@ -267,7 +267,7 @@ const configs = factory.getAllBucketConfigs();
|
|
|
267
267
|
用于存储桶解析的高级场景。
|
|
268
268
|
|
|
269
269
|
```typescript
|
|
270
|
-
import { BucketResolver } from '@app/services/file-storage';
|
|
270
|
+
import { BucketResolver } from '@app/shared-services/file-storage';
|
|
271
271
|
|
|
272
272
|
// 解析存储桶
|
|
273
273
|
const result = await resolver.resolve({
|
|
@@ -16,7 +16,7 @@ import { Logger } from 'winston';
|
|
|
16
16
|
import { FileBucketVendor } from '@prisma/client';
|
|
17
17
|
|
|
18
18
|
import { PardxUploader } from '@app/clients/internal/file-storage';
|
|
19
|
-
import {
|
|
19
|
+
import { IpGeoService } from '@app/shared-services/ip-geo';
|
|
20
20
|
import { AppConfig } from '@/config/validation';
|
|
21
21
|
import arrayUtil from '@/utils/array.util';
|
|
22
22
|
import enviromentUtil from '@/utils/enviroment.util';
|
|
@@ -89,12 +89,12 @@ export class BucketResolver {
|
|
|
89
89
|
* 构造函数
|
|
90
90
|
*
|
|
91
91
|
* @param {ConfigService} configService - NestJS 配置服务
|
|
92
|
-
* @param {
|
|
92
|
+
* @param {IpGeoService} ipGeoService - IP 地理位置服务(infra 层)
|
|
93
93
|
* @param {Logger} logger - Winston 日志记录器
|
|
94
94
|
*/
|
|
95
95
|
constructor(
|
|
96
96
|
private readonly configService: ConfigService,
|
|
97
|
-
private readonly
|
|
97
|
+
private readonly ipGeoService: IpGeoService,
|
|
98
98
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
|
99
99
|
) {
|
|
100
100
|
this.bucketConfigs =
|
|
@@ -178,7 +178,7 @@ export class BucketResolver {
|
|
|
178
178
|
// 确定区域
|
|
179
179
|
let zone = enviromentUtil.getBaseZone();
|
|
180
180
|
if (ip) {
|
|
181
|
-
const continent = await this.
|
|
181
|
+
const continent = await this.ipGeoService.getContinent(ip);
|
|
182
182
|
zone = continent ?? zone;
|
|
183
183
|
}
|
|
184
184
|
locale = locale ?? zone;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { Module } from '@nestjs/common';
|
|
13
13
|
import { ConfigModule } from '@nestjs/config';
|
|
14
14
|
import { HttpModule } from '@nestjs/axios';
|
|
15
|
-
import {
|
|
15
|
+
import { IpGeoModule } from '@app/shared-services/ip-geo';
|
|
16
16
|
import { RedisModule } from '@app/redis';
|
|
17
17
|
import { FileStorageService } from './file-storage.service';
|
|
18
18
|
import { FileStorageClientFactory } from './file-storage.factory';
|
|
@@ -32,7 +32,7 @@ import { BucketResolver } from './bucket-resolver';
|
|
|
32
32
|
* - `ConfigModule`: 配置服务
|
|
33
33
|
* - `HttpModule`: HTTP 客户端
|
|
34
34
|
* - `RedisModule`: Redis 缓存
|
|
35
|
-
* - `
|
|
35
|
+
* - `IpGeoModule`: IP 地理位置服务(用于区域感知,纯 infra 层)
|
|
36
36
|
*
|
|
37
37
|
* @example
|
|
38
38
|
* ```typescript
|
|
@@ -55,7 +55,7 @@ import { BucketResolver } from './bucket-resolver';
|
|
|
55
55
|
* ```
|
|
56
56
|
*/
|
|
57
57
|
@Module({
|
|
58
|
-
imports: [ConfigModule, RedisModule,
|
|
58
|
+
imports: [ConfigModule, RedisModule, IpGeoModule, HttpModule],
|
|
59
59
|
providers: [BucketResolver, FileStorageClientFactory, FileStorageService],
|
|
60
60
|
exports: [FileStorageService, FileStorageClientFactory, BucketResolver],
|
|
61
61
|
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview 国家代码到大洲的静态映射
|
|
3
|
+
*
|
|
4
|
+
* 本文件提供纯静态的国家代码到大洲映射,不依赖数据库。
|
|
5
|
+
* 用于 infra 层的 IP 地理位置服务,避免 infra 依赖 domain。
|
|
6
|
+
*
|
|
7
|
+
* @module ip-geo/continent-mapping
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 大洲类型
|
|
12
|
+
*/
|
|
13
|
+
export type Continent = 'as' | 'eu' | 'na' | 'sa' | 'af' | 'oc' | 'an';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 大洲到国家代码的映射
|
|
17
|
+
*
|
|
18
|
+
* 数据来源:ISO 3166-1 alpha-2 国家代码
|
|
19
|
+
*/
|
|
20
|
+
export const CONTINENT_COUNTRIES: Record<Continent, string[]> = {
|
|
21
|
+
// 亚洲 (Asia)
|
|
22
|
+
as: [
|
|
23
|
+
'AF', 'AM', 'AZ', 'BH', 'BD', 'BT', 'BN', 'KH', 'CN', 'CY', 'GE', 'HK',
|
|
24
|
+
'IN', 'ID', 'IR', 'IQ', 'IL', 'JP', 'JO', 'KZ', 'KW', 'KG', 'LA', 'LB',
|
|
25
|
+
'MO', 'MY', 'MV', 'MN', 'MM', 'NP', 'KP', 'OM', 'PK', 'PS', 'PH', 'QA',
|
|
26
|
+
'SA', 'SG', 'KR', 'LK', 'SY', 'TW', 'TJ', 'TH', 'TL', 'TR', 'TM', 'AE',
|
|
27
|
+
'UZ', 'VN', 'YE',
|
|
28
|
+
],
|
|
29
|
+
// 欧洲 (Europe)
|
|
30
|
+
eu: [
|
|
31
|
+
'AL', 'AD', 'AT', 'BY', 'BE', 'BA', 'BG', 'HR', 'CZ', 'DK', 'EE', 'FI',
|
|
32
|
+
'FR', 'DE', 'GR', 'HU', 'IS', 'IE', 'IT', 'XK', 'LV', 'LI', 'LT', 'LU',
|
|
33
|
+
'MT', 'MD', 'MC', 'ME', 'NL', 'MK', 'NO', 'PL', 'PT', 'RO', 'RU', 'SM',
|
|
34
|
+
'RS', 'SK', 'SI', 'ES', 'SE', 'CH', 'UA', 'GB', 'VA',
|
|
35
|
+
],
|
|
36
|
+
// 北美洲 (North America)
|
|
37
|
+
na: [
|
|
38
|
+
'AG', 'BS', 'BB', 'BZ', 'CA', 'CR', 'CU', 'DM', 'DO', 'SV', 'GD', 'GT',
|
|
39
|
+
'HT', 'HN', 'JM', 'MX', 'NI', 'PA', 'KN', 'LC', 'VC', 'TT', 'US',
|
|
40
|
+
],
|
|
41
|
+
// 南美洲 (South America)
|
|
42
|
+
sa: [
|
|
43
|
+
'AR', 'BO', 'BR', 'CL', 'CO', 'EC', 'GY', 'PY', 'PE', 'SR', 'UY', 'VE',
|
|
44
|
+
],
|
|
45
|
+
// 非洲 (Africa)
|
|
46
|
+
af: [
|
|
47
|
+
'DZ', 'AO', 'BJ', 'BW', 'BF', 'BI', 'CV', 'CM', 'CF', 'TD', 'KM', 'CG',
|
|
48
|
+
'CD', 'DJ', 'EG', 'GQ', 'ER', 'SZ', 'ET', 'GA', 'GM', 'GH', 'GN', 'GW',
|
|
49
|
+
'CI', 'KE', 'LS', 'LR', 'LY', 'MG', 'MW', 'ML', 'MR', 'MU', 'MA', 'MZ',
|
|
50
|
+
'NA', 'NE', 'NG', 'RW', 'ST', 'SN', 'SC', 'SL', 'SO', 'ZA', 'SS', 'SD',
|
|
51
|
+
'TZ', 'TG', 'TN', 'UG', 'ZM', 'ZW',
|
|
52
|
+
],
|
|
53
|
+
// 大洋洲 (Oceania)
|
|
54
|
+
oc: [
|
|
55
|
+
'AU', 'FJ', 'KI', 'MH', 'FM', 'NR', 'NZ', 'PW', 'PG', 'WS', 'SB', 'TO',
|
|
56
|
+
'TV', 'VU',
|
|
57
|
+
],
|
|
58
|
+
// 南极洲 (Antarctica) - 通常无常住人口
|
|
59
|
+
an: ['AQ'],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 国家代码到大洲的反向映射(运行时生成)
|
|
64
|
+
*/
|
|
65
|
+
export const COUNTRY_TO_CONTINENT: Record<string, Continent> = {};
|
|
66
|
+
|
|
67
|
+
// 构建反向映射
|
|
68
|
+
for (const [continent, countries] of Object.entries(CONTINENT_COUNTRIES)) {
|
|
69
|
+
for (const country of countries) {
|
|
70
|
+
COUNTRY_TO_CONTINENT[country] = continent as Continent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 根据国家代码获取大洲
|
|
76
|
+
*
|
|
77
|
+
* @param countryCode - ISO 3166-1 alpha-2 国家代码
|
|
78
|
+
* @param defaultContinent - 默认大洲(找不到时返回)
|
|
79
|
+
* @returns 大洲代码
|
|
80
|
+
*/
|
|
81
|
+
export function getContinentByCountry(
|
|
82
|
+
countryCode: string,
|
|
83
|
+
defaultContinent: Continent = 'as',
|
|
84
|
+
): Continent {
|
|
85
|
+
return COUNTRY_TO_CONTINENT[countryCode?.toUpperCase()] ?? defaultContinent;
|
|
86
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview IP 地理位置服务导出入口(Infra 层)
|
|
3
|
+
*
|
|
4
|
+
* 本模块提供纯 infra 层的 IP 地理位置服务:
|
|
5
|
+
* - IpGeoService: IP 地理位置查询服务
|
|
6
|
+
* - IpGeoModule: NestJS 模块
|
|
7
|
+
* - getContinentByCountry: 静态大洲映射函数
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { IpGeoModule, IpGeoService } from '@app/shared-services/ip-geo';
|
|
12
|
+
*
|
|
13
|
+
* @Module({
|
|
14
|
+
* imports: [IpGeoModule],
|
|
15
|
+
* })
|
|
16
|
+
* export class MyModule {}
|
|
17
|
+
*
|
|
18
|
+
* @Injectable()
|
|
19
|
+
* class MyService {
|
|
20
|
+
* constructor(private readonly ipGeo: IpGeoService) {}
|
|
21
|
+
*
|
|
22
|
+
* async getRegion(ip: string) {
|
|
23
|
+
* return await this.ipGeo.getContinent(ip);
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @module ip-geo
|
|
29
|
+
*/
|
|
30
|
+
export { IpGeoModule } from './ip-geo.module';
|
|
31
|
+
export { IpGeoService } from './ip-geo.service';
|
|
32
|
+
export {
|
|
33
|
+
getContinentByCountry,
|
|
34
|
+
Continent,
|
|
35
|
+
CONTINENT_COUNTRIES,
|
|
36
|
+
COUNTRY_TO_CONTINENT,
|
|
37
|
+
} from './continent-mapping';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview IP 地理位置服务模块(Infra 层)
|
|
3
|
+
*
|
|
4
|
+
* @module ip-geo/module
|
|
5
|
+
*/
|
|
6
|
+
import { Module } from '@nestjs/common';
|
|
7
|
+
import { RedisModule } from '@app/redis';
|
|
8
|
+
import { IpInfoClientModule } from '@app/clients/internal/ip-info';
|
|
9
|
+
import { IpGeoService } from './ip-geo.service';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* IP 地理位置服务模块
|
|
13
|
+
*
|
|
14
|
+
* @description 提供纯 infra 层的 IP 地理位置服务,不依赖 domain 层。
|
|
15
|
+
*/
|
|
16
|
+
@Module({
|
|
17
|
+
imports: [RedisModule, IpInfoClientModule],
|
|
18
|
+
providers: [IpGeoService],
|
|
19
|
+
exports: [IpGeoService],
|
|
20
|
+
})
|
|
21
|
+
export class IpGeoModule {}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview IP 地理位置服务(Infra 层)
|
|
3
|
+
*
|
|
4
|
+
* 本服务提供纯 infra 层的 IP 地理位置查询功能:
|
|
5
|
+
* - IP 信息查询(via IpInfoClient)
|
|
6
|
+
* - 国家代码查询
|
|
7
|
+
* - 大洲查询(使用静态映射,不依赖数据库)
|
|
8
|
+
*
|
|
9
|
+
* 注意:此服务不依赖 domain 层,可在 infra 层安全使用。
|
|
10
|
+
* 如需完整的 IP 信息服务(含数据库查询),请使用 domain/services/ip-info。
|
|
11
|
+
*
|
|
12
|
+
* @module ip-geo/service
|
|
13
|
+
*/
|
|
14
|
+
import { Injectable, Inject } from '@nestjs/common';
|
|
15
|
+
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
|
16
|
+
import { Logger } from 'winston';
|
|
17
|
+
import { RedisService } from '@app/redis';
|
|
18
|
+
import { FastifyRequest } from 'fastify';
|
|
19
|
+
import ipUtil from '@/utils/ip.util';
|
|
20
|
+
import validateUtil from '@/utils/validate.util';
|
|
21
|
+
import { PardxApp } from '@/config/dto/config.dto';
|
|
22
|
+
import enviromentUtil from '@/utils/enviroment.util';
|
|
23
|
+
import { getContinentByCountry, Continent } from './continent-mapping';
|
|
24
|
+
import { IpInfoClient, IpInfoResponse } from '@app/clients/internal/ip-info';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* IP 地理位置服务(Infra 层)
|
|
28
|
+
*
|
|
29
|
+
* @description 提供 IP 地理位置查询功能,使用静态大洲映射,不依赖数据库。
|
|
30
|
+
*
|
|
31
|
+
* @class IpGeoService
|
|
32
|
+
*/
|
|
33
|
+
@Injectable()
|
|
34
|
+
export class IpGeoService {
|
|
35
|
+
protected ipinfoRedisKey = 'ipinfo';
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly redis: RedisService,
|
|
39
|
+
private readonly ipInfoClient: IpInfoClient,
|
|
40
|
+
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 从请求中提取 IP 地址
|
|
45
|
+
*/
|
|
46
|
+
extractIp(req: FastifyRequest): string {
|
|
47
|
+
return ipUtil.extractIp(req);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 获取 IP 信息
|
|
52
|
+
*/
|
|
53
|
+
async getIpInfo(ip: string): Promise<Partial<PardxApp.IPInfo>> {
|
|
54
|
+
if (enviromentUtil.getBaseZone() === 'cn') {
|
|
55
|
+
return {
|
|
56
|
+
ip,
|
|
57
|
+
country: 'CN',
|
|
58
|
+
region: 'Beijing',
|
|
59
|
+
city: 'Beijing',
|
|
60
|
+
loc: '1.2897,103.8501',
|
|
61
|
+
timezone: 'Asia/Shanghai',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (validateUtil.isBlank(ip) || ip === '127.0.0.1') {
|
|
66
|
+
return {
|
|
67
|
+
ip,
|
|
68
|
+
country: 'SG',
|
|
69
|
+
region: 'Singapore',
|
|
70
|
+
city: 'Singapore',
|
|
71
|
+
loc: '1.2897,103.8501',
|
|
72
|
+
timezone: 'Asia/Singapore',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ipinfo = await this.redis.getData(this.ipinfoRedisKey, ip);
|
|
77
|
+
if (ipinfo) return ipinfo;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const response: IpInfoResponse = await this.ipInfoClient.getIpInfo(ip);
|
|
81
|
+
let ipInfoData: PardxApp.IPInfo = response;
|
|
82
|
+
this.logger.info('IP info:', { ipInfoData });
|
|
83
|
+
await this.redis.saveData(this.ipinfoRedisKey, ip, ipInfoData);
|
|
84
|
+
|
|
85
|
+
if (ipInfoData.country === 'CN') {
|
|
86
|
+
ipInfoData = {
|
|
87
|
+
ip,
|
|
88
|
+
country: 'SG',
|
|
89
|
+
region: 'Singapore',
|
|
90
|
+
city: 'Singapore',
|
|
91
|
+
loc: '1.2897,103.8501',
|
|
92
|
+
timezone: 'Asia/Singapore',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return ipInfoData;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.logger.error('Failed to fetch IP info:', error);
|
|
98
|
+
return {
|
|
99
|
+
ip,
|
|
100
|
+
country: 'SG',
|
|
101
|
+
region: 'Singapore',
|
|
102
|
+
city: 'Singapore',
|
|
103
|
+
loc: '1.2897,103.8501',
|
|
104
|
+
timezone: 'Asia/Singapore',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 获取 IP 对应的国家代码
|
|
111
|
+
*/
|
|
112
|
+
async getIpCountry(ip: string): Promise<string> {
|
|
113
|
+
const ipInfo = await this.getIpInfo(ip);
|
|
114
|
+
return ipInfo.country;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 获取 IP 对应的大洲
|
|
119
|
+
*
|
|
120
|
+
* @description 使用静态映射,不依赖数据库
|
|
121
|
+
*/
|
|
122
|
+
async getContinent(ip: string): Promise<Continent> {
|
|
123
|
+
const countryCode = await this.getIpCountry(ip);
|
|
124
|
+
const defaultZone = enviromentUtil.getBaseZone() as Continent;
|
|
125
|
+
return getContinentByCountry(countryCode, defaultZone);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 获取 IP 对应的时区
|
|
130
|
+
*/
|
|
131
|
+
async getTimeZone(ip: string): Promise<string> {
|
|
132
|
+
const ipInfo = await this.getIpInfo(ip);
|
|
133
|
+
return ipInfo.timezone;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { UploaderModule } from './uploader.module';
|
|
2
|
-
export { UploaderService } from './uploader.service';
|
|
2
|
+
export { UploaderService, TokenRequest, SignatureData } from './uploader.service';
|
|
@@ -16,7 +16,11 @@ import enviromentUtil from '@/utils/enviroment.util';
|
|
|
16
16
|
import fileUtil from '@/utils/file.util';
|
|
17
17
|
import { FileBucketVendor } from '@prisma/client';
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 签名数据接口
|
|
21
|
+
* 从加密签名中解析出的数据结构
|
|
22
|
+
*/
|
|
23
|
+
export interface SignatureData {
|
|
20
24
|
timestamp?: number;
|
|
21
25
|
filename?: string;
|
|
22
26
|
fileId?: string;
|
|
@@ -25,7 +29,11 @@ interface SignatureData {
|
|
|
25
29
|
sha256?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
/**
|
|
33
|
+
* 上传令牌请求接口
|
|
34
|
+
* 用于获取上传凭证的请求参数
|
|
35
|
+
*/
|
|
36
|
+
export interface TokenRequest {
|
|
29
37
|
signature?: string;
|
|
30
38
|
filename?: string;
|
|
31
39
|
vendor?: FileBucketVendor;
|
|
@@ -17,8 +17,6 @@ import { HttpExceptionFilter } from '@/common/filter/exception/http.exception';
|
|
|
17
17
|
import { WinstonModule } from 'nest-winston';
|
|
18
18
|
import * as loggerUtil from '@/utils/logger.util';
|
|
19
19
|
|
|
20
|
-
/** health module */
|
|
21
|
-
import { HealthModule } from './modules/health/health.module';
|
|
22
20
|
/** uploader module */
|
|
23
21
|
import { UploaderModule } from './modules/uploader/uploader.module';
|
|
24
22
|
|
|
@@ -160,16 +158,6 @@ import { DbMetricsService } from '@app/prisma/db-metrics/src/db-metrics.service'
|
|
|
160
158
|
};
|
|
161
159
|
},
|
|
162
160
|
}),
|
|
163
|
-
WinstonModule.forRootAsync({
|
|
164
|
-
imports: [ConfigModule],
|
|
165
|
-
useFactory: (configService: ConfigService) => {
|
|
166
|
-
const output =
|
|
167
|
-
configService.get<loggerUtil.LogOutputMode>('app.nestLogOutput') ||
|
|
168
|
-
'file';
|
|
169
|
-
return loggerUtil.getWinstonConfig(output);
|
|
170
|
-
},
|
|
171
|
-
inject: [ConfigService],
|
|
172
|
-
}),
|
|
173
161
|
IpInfoServiceModule,
|
|
174
162
|
ScheduleModule.forRoot(),
|
|
175
163
|
RedisModule,
|
|
@@ -181,7 +169,6 @@ import { DbMetricsService } from '@app/prisma/db-metrics/src/db-metrics.service'
|
|
|
181
169
|
VerifyModule,
|
|
182
170
|
SystemHealthModule,
|
|
183
171
|
JwtModule,
|
|
184
|
-
HealthModule,
|
|
185
172
|
UploaderModule,
|
|
186
173
|
],
|
|
187
174
|
providers: [
|
|
@@ -73,16 +73,10 @@ export class UploaderController {
|
|
|
73
73
|
const userId = req.userId;
|
|
74
74
|
const ip = ipUtil.extractIp(req);
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
body as any,
|
|
79
|
-
);
|
|
76
|
+
// 签名验证 - body 类型由 ts-rest contract 的 Zod schema 推断
|
|
77
|
+
this.uploaderService.checkValidateAndReturnSignatureData(userId, body);
|
|
80
78
|
|
|
81
|
-
const result = await this.uploaderService.uploadThumbToken(
|
|
82
|
-
userId,
|
|
83
|
-
body as any,
|
|
84
|
-
ip,
|
|
85
|
-
);
|
|
79
|
+
const result = await this.uploaderService.uploadThumbToken(userId, body, ip);
|
|
86
80
|
|
|
87
81
|
return success({
|
|
88
82
|
token: result.token,
|
|
@@ -100,11 +94,9 @@ export class UploaderController {
|
|
|
100
94
|
const userId = req.userId;
|
|
101
95
|
const ip = ipUtil.extractIp(req);
|
|
102
96
|
|
|
97
|
+
// 签名验证
|
|
103
98
|
const signatureData =
|
|
104
|
-
this.uploaderService.checkValidateAndReturnSignatureData(
|
|
105
|
-
userId,
|
|
106
|
-
body as any,
|
|
107
|
-
);
|
|
99
|
+
this.uploaderService.checkValidateAndReturnSignatureData(userId, body);
|
|
108
100
|
|
|
109
101
|
const vendor =
|
|
110
102
|
body.vendor ?? (this.appConfig.defaultVendor as FileBucketVendor);
|
|
@@ -166,15 +158,10 @@ export class UploaderController {
|
|
|
166
158
|
const userId = req.userId;
|
|
167
159
|
const ip = ipUtil.extractIp(req);
|
|
168
160
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
body as any,
|
|
172
|
-
);
|
|
161
|
+
// 签名验证
|
|
162
|
+
this.uploaderService.checkValidateAndReturnSignatureData(userId, body);
|
|
173
163
|
|
|
174
|
-
const result = await this.uploaderService.getUploaderPresignedUrl(
|
|
175
|
-
body as any,
|
|
176
|
-
ip,
|
|
177
|
-
);
|
|
164
|
+
const result = await this.uploaderService.getUploaderPresignedUrl(body, ip);
|
|
178
165
|
|
|
179
166
|
const defaultBucket = await this.fileStorageService.getDefaultBucket();
|
|
180
167
|
|
|
@@ -193,11 +180,9 @@ export class UploaderController {
|
|
|
193
180
|
const userId = req.userId;
|
|
194
181
|
const ip = ipUtil.extractIp(req);
|
|
195
182
|
|
|
183
|
+
// 签名验证
|
|
196
184
|
const signatureData =
|
|
197
|
-
this.uploaderService.checkValidateAndReturnSignatureData(
|
|
198
|
-
userId,
|
|
199
|
-
body as any,
|
|
200
|
-
);
|
|
185
|
+
this.uploaderService.checkValidateAndReturnSignatureData(userId, body);
|
|
201
186
|
|
|
202
187
|
const vendor =
|
|
203
188
|
body.vendor ?? (this.appConfig.defaultVendor as FileBucketVendor);
|
|
@@ -8,3 +8,24 @@ export {
|
|
|
8
8
|
PageErrorBoundary,
|
|
9
9
|
ComponentErrorBoundary,
|
|
10
10
|
} from './error-boundary';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
Skeleton,
|
|
14
|
+
CardSkeleton,
|
|
15
|
+
ListItemSkeleton,
|
|
16
|
+
ListSkeleton,
|
|
17
|
+
TableSkeleton,
|
|
18
|
+
FormSkeleton,
|
|
19
|
+
DetailSkeleton,
|
|
20
|
+
AvatarSkeleton,
|
|
21
|
+
PageSkeleton,
|
|
22
|
+
} from './skeletons';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
AsyncBoundary,
|
|
26
|
+
CardBoundary,
|
|
27
|
+
ListBoundary,
|
|
28
|
+
PageBoundary,
|
|
29
|
+
withSuspense,
|
|
30
|
+
withAsyncBoundary,
|
|
31
|
+
} from './suspense-utils';
|