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

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
+61
View File
@@ -0,0 +1,61 @@
export type ClientGpsLocation = {
province?: string;
city?: string;
district?: string;
latitude: number;
longitude: number;
address?: string;
};
type WxLocationResult = {
latitude: number;
longitude: number;
};
declare global {
interface Window {
wx?: {
getLocation?: (options: {
type?: string;
success?: (res: WxLocationResult) => void;
fail?: () => void;
}) => void;
};
}
}
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
if (typeof window !== 'undefined' && window.wx?.getLocation) {
const wxResult = await new Promise<WxLocationResult | null>((resolve) => {
window.wx!.getLocation!({
type: 'gcj02',
success: (res) => resolve(res),
fail: () => resolve(null),
});
});
if (wxResult) {
return {
latitude: wxResult.latitude,
longitude: wxResult.longitude,
};
}
}
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return null;
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
},
() => resolve(null),
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60_000 },
);
});
}
+8 -2
View File
@@ -5,7 +5,7 @@ import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { buildProductDetailUrl } from '../lib/navigation'; import { buildProductDetailUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images'; import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { getProductMainImage } from '../lib/product-images'; import { tryGetClientGpsLocation } from '../lib/client-location';
type Address = { type Address = {
id: string; id: string;
@@ -119,9 +119,15 @@ export default function OrderConfirmPage() {
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
const clientLocation = await tryGetClientGpsLocation();
const order = await request<{ id: string }>('USER_H5', '/trade/orders', { const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
method: 'POST', method: 'POST',
body: JSON.stringify({ productId, quantity, addressId }), body: JSON.stringify({
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
}),
}); });
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set('orderId', order.id); qs.set('orderId', order.id);
+12
View File
@@ -181,6 +181,9 @@ importers:
ioredis: ioredis:
specifier: ^5.4.1 specifier: ^5.4.1
version: 5.11.1 version: 5.11.1
ip2region:
specifier: ^2.3.0
version: 2.3.0(@types/node@20.19.43)
reflect-metadata: reflect-metadata:
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2 version: 0.2.2
@@ -1620,6 +1623,11 @@ packages:
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
ip2region@2.3.0:
resolution: {integrity: sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==}
peerDependencies:
'@types/node': '*'
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -4137,6 +4145,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
ip2region@2.3.0(@types/node@20.19.43):
dependencies:
'@types/node': 20.19.43
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-arrayish@0.2.1: {} is-arrayish@0.2.1: {}
+3
View File
@@ -8,3 +8,6 @@ MOCK_SMS_CODE=123456
MOCK_PAY=true MOCK_PAY=true
MOCK_DELIVERY_AUTO=true MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true AUTO_APPROVE_STORE=true
# 反向代理后提取真实客户端 IP(下单 IP 定位)
# TRUST_PROXY=true
+1
View File
@@ -27,6 +27,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"ip2region": "^2.3.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
+12
View File
@@ -574,6 +574,16 @@ model Order {
receiverProvince String @map("receiver_province") @db.VarChar(32) receiverProvince String @map("receiver_province") @db.VarChar(32)
receiverCity String @map("receiver_city") @db.VarChar(32) receiverCity String @map("receiver_city") @db.VarChar(32)
receiverDistrict String @map("receiver_district") @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) productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
freightAmount Decimal @default(0) @map("freight_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) freightPayType String? @map("freight_pay_type") @db.VarChar(8)
@@ -607,6 +617,8 @@ model Order {
@@index([cityId, createdAt]) @@index([cityId, createdAt])
@@index([receiverPhone]) @@index([receiverPhone])
@@index([originOrderId]) @@index([originOrderId])
@@index([ipCity])
@@index([gpsCity])
@@map("orders") @@map("orders")
} }
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { PrismaModule } from './common/prisma/prisma.module'; import { PrismaModule } from './common/prisma/prisma.module';
import { GeoModule } from './common/geo/geo.module';
import { RedisModule } from './common/redis/redis.module'; import { RedisModule } from './common/redis/redis.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { IamModule } from './modules/iam/iam.module'; import { IamModule } from './modules/iam/iam.module';
@@ -23,6 +24,7 @@ import { JobsModule } from './jobs/jobs.module';
}, },
}), }),
PrismaModule, PrismaModule,
GeoModule,
RedisModule, RedisModule,
HealthModule, HealthModule,
IamModule, 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 { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { ResponseInterceptor } from './common/interceptors/response.interceptor';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
app.set('trust proxy', true);
app.enableCors({ origin: true, credentials: true }); app.enableCors({ origin: true, credentials: true });
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter()); 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 { TradeService } from './trade.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -14,8 +15,8 @@ export class TradeController {
} }
@Post() @Post()
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) { create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) {
return this.tradeService.createOrder(user.actorId, body as never); return this.tradeService.createOrder(user.actorId, body as never, req);
} }
@Get() @Get()
@@ -16,12 +16,17 @@ import { BenefitService } from '../benefit/benefit.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants'; import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface'; import { IPayProvider } from '../../integrations/pay/pay.interface';
import { IDeliveryProvider } from '../../integrations/delivery/delivery.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() @Injectable()
export class TradeService { export class TradeService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly benefitService: BenefitService, private readonly benefitService: BenefitService,
private readonly ipGeoService: IpGeoService,
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider, @Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider, @Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
) {} ) {}
@@ -80,7 +85,9 @@ export class TradeService {
productId: string; productId: string;
quantity: number; quantity: number;
addressId: string; addressId: string;
clientLocation?: unknown;
}, },
req: Request,
) { ) {
const preview = await this.preview(userId, body); const preview = await this.preview(userId, body);
const address = await this.prisma.userAddress.findFirst({ const address = await this.prisma.userAddress.findFirst({
@@ -95,6 +102,12 @@ export class TradeService {
const orderNo = generateOrderNo(); const orderNo = generateOrderNo();
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); 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({ const order = await this.prisma.order.create({
data: { data: {
orderNo, orderNo,
@@ -108,6 +121,16 @@ export class TradeService {
receiverProvince: address.province, receiverProvince: address.province,
receiverCity: address.city, receiverCity: address.city,
receiverDistrict: address.district, 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, productAmount: preview.productAmount,
freightAmount: preview.freightAmount, freightAmount: preview.freightAmount,
freightPayType: preview.freightPayType, freightPayType: preview.freightPayType,