用户端和服务端增加地址功能(三个地址共存)
This commit is contained in:
@@ -8,3 +8,6 @@ MOCK_SMS_CODE=123456
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
|
||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||
# TRUST_PROXY=true
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip2region": "^2.3.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
@@ -574,6 +574,16 @@ model Order {
|
||||
receiverProvince String @map("receiver_province") @db.VarChar(32)
|
||||
receiverCity String @map("receiver_city") @db.VarChar(32)
|
||||
receiverDistrict String @map("receiver_district") @db.VarChar(32)
|
||||
clientIp String? @map("client_ip") @db.VarChar(45)
|
||||
ipProvince String? @map("ip_province") @db.VarChar(32)
|
||||
ipCity String? @map("ip_city") @db.VarChar(32)
|
||||
ipDistrict String? @map("ip_district") @db.VarChar(32)
|
||||
gpsProvince String? @map("gps_province") @db.VarChar(32)
|
||||
gpsCity String? @map("gps_city") @db.VarChar(32)
|
||||
gpsDistrict String? @map("gps_district") @db.VarChar(32)
|
||||
gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
|
||||
gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
|
||||
gpsAddress String? @map("gps_address") @db.VarChar(256)
|
||||
productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
|
||||
freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
|
||||
freightPayType String? @map("freight_pay_type") @db.VarChar(8)
|
||||
@@ -607,6 +617,8 @@ model Order {
|
||||
@@index([cityId, createdAt])
|
||||
@@index([receiverPhone])
|
||||
@@index([originOrderId])
|
||||
@@index([ipCity])
|
||||
@@index([gpsCity])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { PrismaModule } from './common/prisma/prisma.module';
|
||||
import { GeoModule } from './common/geo/geo.module';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { IamModule } from './modules/iam/iam.module';
|
||||
@@ -23,6 +24,7 @@ import { JobsModule } from './jobs/jobs.module';
|
||||
},
|
||||
}),
|
||||
PrismaModule,
|
||||
GeoModule,
|
||||
RedisModule,
|
||||
HealthModule,
|
||||
IamModule,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
function normalizeIp(raw?: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
const ip = raw.replace(/^::ffff:/, '').trim();
|
||||
if (!ip || ip === '::1') return null;
|
||||
return ip;
|
||||
}
|
||||
|
||||
/** 从反向代理 / Socket 提取客户端 IP */
|
||||
export function extractClientIp(req: Request): string | null {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
||||
return normalizeIp(forwarded.split(',')[0]);
|
||||
}
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
return normalizeIp(forwarded[0]?.split(',')[0]);
|
||||
}
|
||||
const realIp = req.headers['x-real-ip'];
|
||||
if (typeof realIp === 'string' && realIp.length > 0) {
|
||||
return normalizeIp(realIp);
|
||||
}
|
||||
return normalizeIp(req.ip ?? req.socket?.remoteAddress ?? null);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ClientGpsLocation, OrderClientLocationSnapshot } from './geo.types';
|
||||
import type { IpRegion } from './geo.types';
|
||||
import { extractClientIp } from './client-ip.util';
|
||||
import type { Request } from 'express';
|
||||
|
||||
function parseGpsLocation(raw: unknown): ClientGpsLocation | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const input = raw as Record<string, unknown>;
|
||||
const latitude = Number(input.latitude);
|
||||
const longitude = Number(input.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
|
||||
|
||||
return {
|
||||
province: input.province != null ? String(input.province).trim() || null : null,
|
||||
city: input.city != null ? String(input.city).trim() || null : null,
|
||||
district: input.district != null ? String(input.district).trim() || null : null,
|
||||
latitude,
|
||||
longitude,
|
||||
address: input.address != null ? String(input.address).trim() || null : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrderClientLocationSnapshot(
|
||||
req: Request,
|
||||
ipGeo: IpRegion,
|
||||
clientLocationRaw?: unknown,
|
||||
): OrderClientLocationSnapshot {
|
||||
const clientIp = extractClientIp(req);
|
||||
const gps = parseGpsLocation(clientLocationRaw);
|
||||
|
||||
return {
|
||||
clientIp,
|
||||
ipProvince: ipGeo.province,
|
||||
ipCity: ipGeo.city,
|
||||
ipDistrict: ipGeo.district,
|
||||
gpsProvince: gps?.province ?? null,
|
||||
gpsCity: gps?.city ?? null,
|
||||
gpsDistrict: gps?.district ?? null,
|
||||
gpsLatitude: gps?.latitude ?? null,
|
||||
gpsLongitude: gps?.longitude ?? null,
|
||||
gpsAddress: gps?.address ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { IpGeoService } from './ip-geo.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [IpGeoService],
|
||||
exports: [IpGeoService],
|
||||
})
|
||||
export class GeoModule {}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type IpRegion = {
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
district: string | null;
|
||||
};
|
||||
|
||||
export type ClientGpsLocation = {
|
||||
province?: string | null;
|
||||
city?: string | null;
|
||||
district?: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string | null;
|
||||
};
|
||||
|
||||
export type OrderClientLocationSnapshot = {
|
||||
clientIp: string | null;
|
||||
ipProvince: string | null;
|
||||
ipCity: string | null;
|
||||
ipDistrict: string | null;
|
||||
gpsProvince: string | null;
|
||||
gpsCity: string | null;
|
||||
gpsDistrict: string | null;
|
||||
gpsLatitude: number | null;
|
||||
gpsLongitude: number | null;
|
||||
gpsAddress: string | null;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import IP2Region from 'ip2region';
|
||||
import type { IpRegion } from './geo.types';
|
||||
|
||||
function cleanRegionName(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === '0' || trimmed === '内网IP') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IpGeoService implements OnModuleInit {
|
||||
private readonly logger = new Logger(IpGeoService.name);
|
||||
private query: IP2Region | null = null;
|
||||
|
||||
onModuleInit() {
|
||||
try {
|
||||
this.query = new IP2Region();
|
||||
} catch (error) {
|
||||
this.logger.warn(`ip2region 初始化失败: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(ip: string | null): IpRegion {
|
||||
if (!ip || !this.query) {
|
||||
return { province: null, city: null, district: null };
|
||||
}
|
||||
if (ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||
return { province: null, city: null, district: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = this.query.search(ip) as {
|
||||
province?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
return {
|
||||
province: cleanRegionName(result?.province),
|
||||
city: cleanRegionName(result?.city),
|
||||
district: cleanRegionName(result?.region),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.debug(`IP 解析失败 ${ip}: ${error instanceof Error ? error.message : error}`);
|
||||
return { province: null, city: null, district: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.set('trust proxy', true);
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { TradeService } from './trade.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
@@ -14,8 +15,8 @@ export class TradeController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.tradeService.createOrder(user.actorId, body as never);
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) {
|
||||
return this.tradeService.createOrder(user.actorId, body as never, req);
|
||||
}
|
||||
|
||||
@Get()
|
||||
|
||||
@@ -16,12 +16,17 @@ import { BenefitService } from '../benefit/benefit.service';
|
||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
|
||||
import { IpGeoService } from '../../common/geo/ip-geo.service';
|
||||
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
|
||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||
import type { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class TradeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly benefitService: BenefitService,
|
||||
private readonly ipGeoService: IpGeoService,
|
||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||
) {}
|
||||
@@ -80,7 +85,9 @@ export class TradeService {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
addressId: string;
|
||||
clientLocation?: unknown;
|
||||
},
|
||||
req: Request,
|
||||
) {
|
||||
const preview = await this.preview(userId, body);
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
@@ -95,6 +102,12 @@ export class TradeService {
|
||||
const orderNo = generateOrderNo();
|
||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||
|
||||
const location = buildOrderClientLocationSnapshot(
|
||||
req,
|
||||
this.ipGeoService.resolve(extractClientIp(req)),
|
||||
body.clientLocation,
|
||||
);
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
@@ -108,6 +121,16 @@ export class TradeService {
|
||||
receiverProvince: address.province,
|
||||
receiverCity: address.city,
|
||||
receiverDistrict: address.district,
|
||||
clientIp: location.clientIp,
|
||||
ipProvince: location.ipProvince,
|
||||
ipCity: location.ipCity,
|
||||
ipDistrict: location.ipDistrict,
|
||||
gpsProvince: location.gpsProvince,
|
||||
gpsCity: location.gpsCity,
|
||||
gpsDistrict: location.gpsDistrict,
|
||||
gpsLatitude: location.gpsLatitude,
|
||||
gpsLongitude: location.gpsLongitude,
|
||||
gpsAddress: location.gpsAddress,
|
||||
productAmount: preview.productAmount,
|
||||
freightAmount: preview.freightAmount,
|
||||
freightPayType: preview.freightPayType,
|
||||
|
||||
Reference in New Issue
Block a user