create-pardx-scaffold 0.1.2 → 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/common/ts-rest/response.helper.ts +9 -1
- 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 +14 -6
- package/template/apps/api/src/app.module.ts +3 -12
- package/template/apps/api/src/modules/uploader/uploader.controller.ts +290 -0
- package/template/apps/api/src/modules/uploader/uploader.module.ts +17 -0
- 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 {}
|
|
@@ -42,6 +42,14 @@ export type TsRestResponse<T, S extends number = 200> = {
|
|
|
42
42
|
body: SuccessBody<T> | ErrorBody;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* ts-rest 成功响应类型(仅包含成功体)
|
|
47
|
+
*/
|
|
48
|
+
export type TsRestSuccessResponse<T, S extends number = 200> = {
|
|
49
|
+
status: S;
|
|
50
|
+
body: SuccessBody<T>;
|
|
51
|
+
};
|
|
52
|
+
|
|
45
53
|
/**
|
|
46
54
|
* 创建成功响应
|
|
47
55
|
*
|
|
@@ -56,7 +64,7 @@ export type TsRestResponse<T, S extends number = 200> = {
|
|
|
56
64
|
* }
|
|
57
65
|
* ```
|
|
58
66
|
*/
|
|
59
|
-
export function success<T>(data: T, status: 200 = 200):
|
|
67
|
+
export function success<T>(data: T, status: 200 = 200): TsRestSuccessResponse<T, 200> {
|
|
60
68
|
return {
|
|
61
69
|
status,
|
|
62
70
|
body: {
|
|
@@ -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;
|
|
@@ -64,7 +72,7 @@ export class UploaderService {
|
|
|
64
72
|
try {
|
|
65
73
|
const jsonString = rsaDecrypt(cmd.signature);
|
|
66
74
|
if (!jsonString || jsonString.trim() === '') {
|
|
67
|
-
|
|
75
|
+
this.logger.error(
|
|
68
76
|
'[Signature Validation] Decryption failed: empty result',
|
|
69
77
|
{
|
|
70
78
|
signature: cmd.signature?.substring(0, 50) + '...',
|
|
@@ -75,7 +83,7 @@ export class UploaderService {
|
|
|
75
83
|
}
|
|
76
84
|
signatureData = JSON.parse(jsonString);
|
|
77
85
|
} catch (e) {
|
|
78
|
-
|
|
86
|
+
this.logger.error('[Signature Validation] Decryption or parsing failed:', {
|
|
79
87
|
error: e.message || e,
|
|
80
88
|
signature: cmd.signature?.substring(0, 50) + '...',
|
|
81
89
|
userId,
|
|
@@ -86,7 +94,7 @@ export class UploaderService {
|
|
|
86
94
|
? undefined
|
|
87
95
|
: signatureData.userId;
|
|
88
96
|
if (userId != uploaderUserId) {
|
|
89
|
-
|
|
97
|
+
this.logger.error('[Signature Validation] UserId mismatch:', {
|
|
90
98
|
expectedUserId: userId,
|
|
91
99
|
signatureUserId: uploaderUserId,
|
|
92
100
|
signatureData,
|
|
@@ -99,7 +107,7 @@ export class UploaderService {
|
|
|
99
107
|
enviromentUtil.isProduction() &&
|
|
100
108
|
now - signatureData.timestamp > 15 * 1000
|
|
101
109
|
) {
|
|
102
|
-
|
|
110
|
+
this.logger.error('[Signature Validation] Timestamp expired:', {
|
|
103
111
|
now,
|
|
104
112
|
signatureTimestamp: signatureData.timestamp,
|
|
105
113
|
diff: now - signatureData.timestamp,
|
|
@@ -17,8 +17,8 @@ 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
|
-
/**
|
|
21
|
-
import {
|
|
20
|
+
/** uploader module */
|
|
21
|
+
import { UploaderModule } from './modules/uploader/uploader.module';
|
|
22
22
|
|
|
23
23
|
/** i18n */
|
|
24
24
|
import {
|
|
@@ -158,16 +158,6 @@ import { DbMetricsService } from '@app/prisma/db-metrics/src/db-metrics.service'
|
|
|
158
158
|
};
|
|
159
159
|
},
|
|
160
160
|
}),
|
|
161
|
-
WinstonModule.forRootAsync({
|
|
162
|
-
imports: [ConfigModule],
|
|
163
|
-
useFactory: (configService: ConfigService) => {
|
|
164
|
-
const output =
|
|
165
|
-
configService.get<loggerUtil.LogOutputMode>('app.nestLogOutput') ||
|
|
166
|
-
'file';
|
|
167
|
-
return loggerUtil.getWinstonConfig(output);
|
|
168
|
-
},
|
|
169
|
-
inject: [ConfigService],
|
|
170
|
-
}),
|
|
171
161
|
IpInfoServiceModule,
|
|
172
162
|
ScheduleModule.forRoot(),
|
|
173
163
|
RedisModule,
|
|
@@ -179,6 +169,7 @@ import { DbMetricsService } from '@app/prisma/db-metrics/src/db-metrics.service'
|
|
|
179
169
|
VerifyModule,
|
|
180
170
|
SystemHealthModule,
|
|
181
171
|
JwtModule,
|
|
172
|
+
UploaderModule,
|
|
182
173
|
],
|
|
183
174
|
providers: [
|
|
184
175
|
{
|