diff --git a/apps/h5-user/src/lib/client-location.ts b/apps/h5-user/src/lib/client-location.ts new file mode 100644 index 0000000..df62dc9 --- /dev/null +++ b/apps/h5-user/src/lib/client-location.ts @@ -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 { + if (typeof window !== 'undefined' && window.wx?.getLocation) { + const wxResult = await new Promise((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 }, + ); + }); +} diff --git a/apps/h5-user/src/pages/OrderConfirmPage.tsx b/apps/h5-user/src/pages/OrderConfirmPage.tsx index 68b64cf..b4a9908 100644 --- a/apps/h5-user/src/pages/OrderConfirmPage.tsx +++ b/apps/h5-user/src/pages/OrderConfirmPage.tsx @@ -5,7 +5,7 @@ import AppImage from '@dukang/shared-ui/AppImage'; import { request } from '../lib/api'; import { buildProductDetailUrl } from '../lib/navigation'; import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images'; -import { getProductMainImage } from '../lib/product-images'; +import { tryGetClientGpsLocation } from '../lib/client-location'; type Address = { id: string; @@ -119,9 +119,15 @@ export default function OrderConfirmPage() { setLoading(true); setMsg(''); try { + const clientLocation = await tryGetClientGpsLocation(); const order = await request<{ id: string }>('USER_H5', '/trade/orders', { method: 'POST', - body: JSON.stringify({ productId, quantity, addressId }), + body: JSON.stringify({ + productId, + quantity, + addressId, + ...(clientLocation ? { clientLocation } : {}), + }), }); const qs = new URLSearchParams(); qs.set('orderId', order.id); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97684c2..d592d19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: ioredis: specifier: ^5.4.1 version: 5.11.1 + ip2region: + specifier: ^2.3.0 + version: 2.3.0(@types/node@20.19.43) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -1620,6 +1623,11 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} + ip2region@2.3.0: + resolution: {integrity: sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==} + peerDependencies: + '@types/node': '*' + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4137,6 +4145,10 @@ snapshots: transitivePeerDependencies: - supports-color + ip2region@2.3.0(@types/node@20.19.43): + dependencies: + '@types/node': 20.19.43 + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index abeb435..405da35 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -8,3 +8,6 @@ MOCK_SMS_CODE=123456 MOCK_PAY=true MOCK_DELIVERY_AUTO=true AUTO_APPROVE_STORE=true + +# 反向代理后提取真实客户端 IP(下单 IP 定位) +# TRUST_PROXY=true diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index fcf682a..98f7f25 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -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" }, diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index ea4c43a..f5d0b40 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -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") } diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts index 016b4f9..983236d 100644 --- a/server/dukang-api/src/app.module.ts +++ b/server/dukang-api/src/app.module.ts @@ -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, diff --git a/server/dukang-api/src/common/geo/client-ip.util.ts b/server/dukang-api/src/common/geo/client-ip.util.ts new file mode 100644 index 0000000..6a3ce7e --- /dev/null +++ b/server/dukang-api/src/common/geo/client-ip.util.ts @@ -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); +} diff --git a/server/dukang-api/src/common/geo/client-location.util.ts b/server/dukang-api/src/common/geo/client-location.util.ts new file mode 100644 index 0000000..0696913 --- /dev/null +++ b/server/dukang-api/src/common/geo/client-location.util.ts @@ -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; + 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, + }; +} diff --git a/server/dukang-api/src/common/geo/geo.module.ts b/server/dukang-api/src/common/geo/geo.module.ts new file mode 100644 index 0000000..82b13be --- /dev/null +++ b/server/dukang-api/src/common/geo/geo.module.ts @@ -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 {} diff --git a/server/dukang-api/src/common/geo/geo.types.ts b/server/dukang-api/src/common/geo/geo.types.ts new file mode 100644 index 0000000..7dc38e1 --- /dev/null +++ b/server/dukang-api/src/common/geo/geo.types.ts @@ -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; +}; diff --git a/server/dukang-api/src/common/geo/ip-geo.service.ts b/server/dukang-api/src/common/geo/ip-geo.service.ts new file mode 100644 index 0000000..24b91c6 --- /dev/null +++ b/server/dukang-api/src/common/geo/ip-geo.service.ts @@ -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 }; + } + } +} diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index e95aaed..2b6bcfb 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -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(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()); diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index 80c5533..6787147 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -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) { - return this.tradeService.createOrder(user.actorId, body as never); + create(@CurrentUser() user: AuthUser, @Body() body: Record, @Req() req: Request) { + return this.tradeService.createOrder(user.actorId, body as never, req); } @Get() diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 15f5b3c..cd178e3 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -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,