用户端和服务端增加地址功能(三个地址共存)

This commit is contained in:
2026-07-01 00:52:44 +08:00
parent eb85e0926f
commit d3dd3a0ad1
15 changed files with 282 additions and 6 deletions
+3
View File
@@ -8,3 +8,6 @@ MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# 反向代理后提取真实客户端 IP(下单 IP 定位)
# TRUST_PROXY=true
+1
View File
@@ -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"
},
+12
View File
@@ -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
View File
@@ -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 };
}
}
}
+3 -1
View File
@@ -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,