小程序优化,商铺定位等
This commit is contained in:
@@ -10,6 +10,12 @@ export type ReverseGeocodeResult = {
|
||||
logId: bigint;
|
||||
};
|
||||
|
||||
export type GeocodeAddressResult = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
logId: bigint;
|
||||
};
|
||||
|
||||
function normalizeCityName(name: string) {
|
||||
return name.replace(/市$/, '').trim();
|
||||
}
|
||||
@@ -25,6 +31,83 @@ export class TencentLbsProvider {
|
||||
return !!this.config.tencentLbsKey;
|
||||
}
|
||||
|
||||
/** 地址 → 坐标(正向地理编码) */
|
||||
async geocodeAddress(
|
||||
address: string,
|
||||
actorRef?: WechatActorRef,
|
||||
): Promise<GeocodeAddressResult | null> {
|
||||
const trimmed = address.replace(/\s+/g, '').trim();
|
||||
const baseLog = {
|
||||
provider: 'WECHAT_MAP' as const,
|
||||
scene: 'GEOCODE',
|
||||
refType: actorRef?.refType,
|
||||
refId: actorRef?.refId,
|
||||
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
|
||||
requestBody: { address: trimmed.slice(0, 200) },
|
||||
};
|
||||
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'TENCENT_LBS_KEY 未配置',
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
|
||||
url.searchParams.set('address', trimmed);
|
||||
url.searchParams.set('key', this.config.tencentLbsKey);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
const data = (await res.json()) as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
result?: { location?: { lat?: number; lng?: number } };
|
||||
};
|
||||
const loc = data.result?.location;
|
||||
const ok =
|
||||
data.status === 0 &&
|
||||
typeof loc?.lat === 'number' &&
|
||||
typeof loc?.lng === 'number' &&
|
||||
Number.isFinite(loc.lat) &&
|
||||
Number.isFinite(loc.lng);
|
||||
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
responseBody: {
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
lat: loc?.lat,
|
||||
lng: loc?.lng,
|
||||
},
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.message ?? '地理编码失败',
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok || !loc) return null;
|
||||
return { latitude: loc.lat!, longitude: loc.lng!, logId: log.id };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Tencent LBS geocode failed: ${message}`);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
|
||||
@@ -14,8 +14,14 @@ export class PublicStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('cityCode') cityCode?: string) {
|
||||
return this.storeService.listOpenStores(cityCode);
|
||||
list(
|
||||
@Query('cityCode') cityCode?: string,
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
) {
|
||||
const userLat = lat != null && lat !== '' ? Number(lat) : undefined;
|
||||
const userLng = lng != null && lng !== '' ? Number(lng) : undefined;
|
||||
return this.storeService.listOpenStores(cityCode, userLat, userLng);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IamModule } from '../iam/iam.module';
|
||||
import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { StoreService } from './store.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import {
|
||||
@@ -17,7 +18,13 @@ import {
|
||||
} from './store.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
||||
imports: [
|
||||
IamModule,
|
||||
AnalyticsModule,
|
||||
CityScopeModule,
|
||||
IntegrationsModule,
|
||||
forwardRef(() => RedeemModule),
|
||||
],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PublicStoreCategoriesController,
|
||||
|
||||
@@ -15,6 +15,18 @@ import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||
|
||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const R = 6371000;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
@@ -26,9 +38,48 @@ export class StoreService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly tencentLbs: TencentLbsProvider,
|
||||
) {}
|
||||
|
||||
async listOpenStores(cityCode?: string) {
|
||||
private storeAddressText(store: {
|
||||
province?: string | null;
|
||||
cityName?: string | null;
|
||||
district?: string | null;
|
||||
address?: string | null;
|
||||
}) {
|
||||
return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
|
||||
}
|
||||
|
||||
/** 缺坐标时用地址正向地理编码并回写 */
|
||||
private async ensureStoreCoordinates(store: {
|
||||
id: bigint;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
province?: string | null;
|
||||
cityName?: string | null;
|
||||
district?: string | null;
|
||||
address?: string | null;
|
||||
}): Promise<{ latitude: number; longitude: number } | null> {
|
||||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||||
const lng = store.longitude != null ? Number(store.longitude) : NaN;
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||
return { latitude: lat, longitude: lng };
|
||||
}
|
||||
const address = this.storeAddressText(store);
|
||||
if (!address) return null;
|
||||
const geo = await this.tencentLbs.geocodeAddress(address, {
|
||||
refType: 'STORE',
|
||||
refId: store.id,
|
||||
});
|
||||
if (!geo) return null;
|
||||
await this.prisma.store.update({
|
||||
where: { id: store.id },
|
||||
data: { latitude: geo.latitude, longitude: geo.longitude },
|
||||
});
|
||||
return { latitude: geo.latitude, longitude: geo.longitude };
|
||||
}
|
||||
|
||||
async listOpenStores(cityCode?: string, userLat?: number, userLng?: number) {
|
||||
const where: Record<string, unknown> = { status: 'OPEN' };
|
||||
if (cityCode) {
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
|
||||
@@ -39,7 +90,43 @@ export class StoreService {
|
||||
include: { category: true, coverResource: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(stores.map(mapStoreCompat));
|
||||
|
||||
const hasUser =
|
||||
userLat != null &&
|
||||
userLng != null &&
|
||||
Number.isFinite(userLat) &&
|
||||
Number.isFinite(userLng);
|
||||
|
||||
type StoreListItem = ReturnType<typeof mapStoreCompat> & {
|
||||
distanceMeters: number | null;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
};
|
||||
|
||||
const items: StoreListItem[] = [];
|
||||
for (const store of stores) {
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const mapped = mapStoreCompat({
|
||||
...store,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
});
|
||||
const distanceMeters =
|
||||
hasUser && coords
|
||||
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
|
||||
: null;
|
||||
items.push({ ...mapped, distanceMeters });
|
||||
}
|
||||
|
||||
if (hasUser) {
|
||||
items.sort((a, b) => {
|
||||
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
return da - db;
|
||||
});
|
||||
}
|
||||
|
||||
return serializeBigInt(items);
|
||||
}
|
||||
|
||||
async getStore(id: bigint) {
|
||||
@@ -48,11 +135,19 @@ export class StoreService {
|
||||
include: { category: true, coverResource: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const media = await this.prisma.commonResource.findMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||
return serializeBigInt(
|
||||
mapStoreCompat({
|
||||
...store,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
media,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolvePartnerScope(actorAccountId: bigint) {
|
||||
@@ -196,6 +291,11 @@ export class StoreService {
|
||||
throw new BadRequestException('人均费用须为非负数字');
|
||||
}
|
||||
|
||||
const introRaw = body.intro != null ? String(body.intro).trim() : '';
|
||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
@@ -207,7 +307,7 @@ export class StoreService {
|
||||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: body.intro ? String(body.intro) : null,
|
||||
intro: introRaw || null,
|
||||
avgPrice: avgPriceRaw,
|
||||
openTime,
|
||||
closeTime,
|
||||
@@ -220,6 +320,8 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.ensureStoreCoordinates(store);
|
||||
|
||||
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||||
|
||||
if (coverUrl) {
|
||||
@@ -404,17 +506,19 @@ export class StoreService {
|
||||
throw new BadRequestException('联系电话须为11位手机号');
|
||||
}
|
||||
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
|
||||
if (introRaw && (introRaw.length < 10 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 10~500 字');
|
||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
|
||||
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||||
await this.prisma.store.update({
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(address !== undefined
|
||||
? { address, latitude: null, longitude: null }
|
||||
: {}),
|
||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||
...(resubmitAudit
|
||||
? {
|
||||
@@ -427,6 +531,10 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
if (address !== undefined) {
|
||||
await this.ensureStoreCoordinates(updated);
|
||||
}
|
||||
|
||||
if (resubmitAudit) {
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user