小程序优化,商铺定位等

This commit is contained in:
2026-07-27 22:33:00 +08:00
parent 1725b9e4b4
commit 23c0cc7a5f
15 changed files with 441 additions and 41 deletions
@@ -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: {