feat(M03-C): 完成地图选店与距离排序
This commit is contained in:
@@ -21,6 +21,10 @@ import {
|
||||
registerContentRoutes,
|
||||
type ContentRouteOptions
|
||||
} from './routes/content-management.js';
|
||||
import {
|
||||
registerStoreDiscoveryRoutes,
|
||||
type StoreDiscoveryRouteOptions
|
||||
} from './routes/store-discovery.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -29,6 +33,7 @@ export interface BuildAppOptions {
|
||||
userManagement?: UserManagementRouteOptions;
|
||||
storeRoom?: StoreRoomRouteOptions;
|
||||
content?: ContentRouteOptions;
|
||||
storeDiscovery?: StoreDiscoveryRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -86,6 +91,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.content) {
|
||||
await registerContentRoutes(app, options.content);
|
||||
}
|
||||
if (options.storeDiscovery) {
|
||||
await registerStoreDiscoveryRoutes(app, options.storeDiscovery);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026061805_m02c_rbac.up.sql',
|
||||
'database/migrations/2026061806_m02d_user_management.up.sql',
|
||||
'database/migrations/2026061807_m03a_store_room_domain.up.sql',
|
||||
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql'
|
||||
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql',
|
||||
'database/migrations/2026061809_m03c_store_discovery.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -38,9 +39,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026061805_m02c_rbac.verify.sql',
|
||||
'database/migrations/2026061806_m02d_user_management.verify.sql',
|
||||
'database/migrations/2026061807_m03a_store_room_domain.verify.sql',
|
||||
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql'
|
||||
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql',
|
||||
'database/migrations/2026061809_m03c_store_discovery.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026061809_m03c_store_discovery.down.sql',
|
||||
'database/migrations/2026061808_m03b_decoration_ads_media.down.sql',
|
||||
'database/migrations/2026061807_m03a_store_room_domain.down.sql',
|
||||
'database/migrations/2026061806_m02d_user_management.down.sql',
|
||||
@@ -172,7 +175,8 @@ export async function executeMigrationPlan(
|
||||
5, 3, 7, 1,
|
||||
1, 3, 1,
|
||||
3, 6, 13, 1,
|
||||
3, 3, 1
|
||||
3, 3, 1,
|
||||
2, 2, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import type { StoreDiscoveryRepository } from '../stores/store-discovery-repository.js';
|
||||
|
||||
const headersSchema = z.object({
|
||||
'x-wechat-appid': z.string().trim().min(6).max(64),
|
||||
'tenant-id': z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
});
|
||||
const querySchema = z.object({
|
||||
city: z.string().trim().min(1).max(64).optional(),
|
||||
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).optional(),
|
||||
openNow: z.enum(['true', 'false']).transform((value) => value === 'true').optional(),
|
||||
latitude: z.coerce.number().min(-90).max(90).optional(),
|
||||
longitude: z.coerce.number().min(-180).max(180).optional(),
|
||||
maxDistanceMeters: z.coerce.number().int().min(1).max(500000).optional()
|
||||
}).refine((value) => (value.latitude === undefined) === (value.longitude === undefined), {
|
||||
message: 'latitude and longitude must be supplied together'
|
||||
}).refine((value) => value.maxDistanceMeters === undefined || value.latitude !== undefined, {
|
||||
message: 'distance filter requires coordinates'
|
||||
});
|
||||
|
||||
export interface StoreDiscoveryRouteOptions {
|
||||
repository: Pick<StoreDiscoveryRepository, 'findStores'>;
|
||||
tenancy: Pick<AuthRepository, 'resolveLoginContext'>;
|
||||
}
|
||||
|
||||
export async function registerStoreDiscoveryRoutes(
|
||||
app: FastifyInstance, options: StoreDiscoveryRouteOptions
|
||||
) {
|
||||
app.get('/app-api/stores', async (request, reply) => {
|
||||
const headers = headersSchema.safeParse(request.headers);
|
||||
const query = querySchema.safeParse(request.query);
|
||||
if (!headers.success || !query.success) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_STORE_DISCOVERY_REQUEST',
|
||||
message: 'AppID and valid city or coordinate filters are required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
try {
|
||||
const context = await options.tenancy.resolveLoginContext(
|
||||
headers.data['x-wechat-appid'], headers.data['tenant-id']
|
||||
);
|
||||
if (!context) {
|
||||
return reply.status(404).send({
|
||||
code: 'APP_TENANT_NOT_FOUND', message: 'Application tenant binding was not found.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return {
|
||||
code: 0,
|
||||
data: await options.repository.findStores({ tenantId: context.tenantId, ...query.data }),
|
||||
traceId: request.traceId
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') {
|
||||
return reply.status(409).send({
|
||||
code: 'TENANT_SELECTION_REQUIRED',
|
||||
message: 'tenant-id is required for an application bound to multiple tenants.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const hoursSchema = z.array(z.object({
|
||||
const storeSchema = z.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
address: z.string().trim().max(255).default(''),
|
||||
city: z.string().trim().max(64).default(''),
|
||||
district: z.string().trim().max(64).default(''),
|
||||
longitude: coordinate.min(-180).max(180).nullable().optional(),
|
||||
latitude: coordinate.min(-90).max(90).nullable().optional(),
|
||||
contactPhone: z.string().trim().max(32).default(''),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { StoreRoomRepository } from './stores/store-room-repository.js';
|
||||
import { ContentRepository } from './content/content-repository.js';
|
||||
import { MediaStorage } from './content/media-storage.js';
|
||||
import { resolve } from 'node:path';
|
||||
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -44,6 +45,10 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
storeDiscovery: {
|
||||
repository: new StoreDiscoveryRepository(pool),
|
||||
tenancy: authRepository
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export interface StoreDiscoveryQuery {
|
||||
tenantId: string;
|
||||
city?: string;
|
||||
businessStatus?: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
||||
openNow?: boolean;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
maxDistanceMeters?: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
interface DiscoveryRow extends RowDataPacket {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
district: string;
|
||||
longitude: string | null;
|
||||
latitude: string | null;
|
||||
contactPhone: string;
|
||||
timezone: string;
|
||||
businessStatus: string;
|
||||
weekday: number | null;
|
||||
openMinute: number | null;
|
||||
closeMinute: number | null;
|
||||
isClosed: number | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface DiscoveredStore {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
district: string;
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
contactPhone: string;
|
||||
timezone: string;
|
||||
businessStatus: string;
|
||||
openNow: boolean;
|
||||
distanceMeters: number | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export class StoreDiscoveryRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async findStores(query: StoreDiscoveryQuery): Promise<DiscoveredStore[]> {
|
||||
const filters = ['s.tenant_id = ?', 's.deleted_at IS NULL'];
|
||||
const params: Array<string> = [query.tenantId];
|
||||
if (query.city) {
|
||||
filters.push('s.city = ?');
|
||||
params.push(query.city);
|
||||
}
|
||||
if (query.businessStatus) {
|
||||
filters.push('s.business_status = ?');
|
||||
params.push(query.businessStatus);
|
||||
}
|
||||
const [rows] = await this.pool.execute<DiscoveryRow[]>(
|
||||
`SELECT s.id, s.name, s.address, s.city, s.district, s.longitude, s.latitude,
|
||||
s.contact_phone AS contactPhone, s.timezone,
|
||||
s.business_status AS businessStatus, s.sort_order AS sortOrder,
|
||||
h.weekday, h.open_minute AS openMinute, h.close_minute AS closeMinute,
|
||||
h.is_closed AS isClosed
|
||||
FROM qipai_stores s
|
||||
LEFT JOIN qipai_store_business_hours h
|
||||
ON h.tenant_id = s.tenant_id AND h.store_id = s.id
|
||||
WHERE ${filters.join(' AND ')}
|
||||
ORDER BY s.sort_order, s.id`,
|
||||
params
|
||||
);
|
||||
const grouped = new Map<string, { store: Omit<DiscoveredStore, 'openNow' | 'distanceMeters'>;
|
||||
hours: DiscoveryRow[] }>();
|
||||
for (const row of rows) {
|
||||
const id = String(row.id);
|
||||
const existing = grouped.get(id);
|
||||
if (existing) {
|
||||
existing.hours.push(row);
|
||||
continue;
|
||||
}
|
||||
grouped.set(id, {
|
||||
store: {
|
||||
id,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
city: row.city,
|
||||
district: row.district,
|
||||
longitude: row.longitude === null ? null : Number(row.longitude),
|
||||
latitude: row.latitude === null ? null : Number(row.latitude),
|
||||
contactPhone: row.contactPhone,
|
||||
timezone: row.timezone,
|
||||
businessStatus: row.businessStatus,
|
||||
sortOrder: row.sortOrder
|
||||
},
|
||||
hours: [row]
|
||||
});
|
||||
}
|
||||
const now = query.now ?? new Date();
|
||||
const stores = [...grouped.values()].map(({ store, hours }) => {
|
||||
const openNow = store.businessStatus === 'OPEN' && isOpenAt(hours, store.timezone, now);
|
||||
const distanceMeters = query.latitude !== undefined && query.longitude !== undefined
|
||||
&& store.latitude !== null && store.longitude !== null
|
||||
? Math.round(haversineMeters(
|
||||
query.latitude, query.longitude, store.latitude, store.longitude
|
||||
))
|
||||
: null;
|
||||
return { ...store, openNow, distanceMeters };
|
||||
}).filter((store) => {
|
||||
if (query.openNow === true && !store.openNow) return false;
|
||||
if (query.openNow === false && store.openNow) return false;
|
||||
return query.maxDistanceMeters === undefined || store.distanceMeters === null
|
||||
|| store.distanceMeters <= query.maxDistanceMeters;
|
||||
});
|
||||
return stores.sort((left, right) => {
|
||||
if (left.distanceMeters !== null && right.distanceMeters !== null
|
||||
&& left.distanceMeters !== right.distanceMeters) {
|
||||
return left.distanceMeters - right.distanceMeters;
|
||||
}
|
||||
if (left.distanceMeters !== null) return -1;
|
||||
if (right.distanceMeters !== null) return 1;
|
||||
return left.sortOrder - right.sortOrder || Number(left.id) - Number(right.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function haversineMeters(
|
||||
latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number
|
||||
): number {
|
||||
const radians = (degrees: number) => degrees * Math.PI / 180;
|
||||
const latitudeDelta = radians(latitudeB - latitudeA);
|
||||
const longitudeDelta = radians(longitudeB - longitudeA);
|
||||
const a = Math.sin(latitudeDelta / 2) ** 2
|
||||
+ Math.cos(radians(latitudeA)) * Math.cos(radians(latitudeB))
|
||||
* Math.sin(longitudeDelta / 2) ** 2;
|
||||
return 6371008.8 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function isOpenAt(hours: DiscoveryRow[], timezone: string, now: Date): boolean {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).formatToParts(now);
|
||||
const weekdayName = parts.find((part) => part.type === 'weekday')?.value ?? '';
|
||||
const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(weekdayName) + 1;
|
||||
const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? 0);
|
||||
const currentMinute = hour * 60 + minute;
|
||||
const today = hours.find((item) => item.weekday === weekday);
|
||||
if (!today || today.isClosed === 1 || today.openMinute === null || today.closeMinute === null) {
|
||||
return false;
|
||||
}
|
||||
if (today.openMinute === today.closeMinute) return true;
|
||||
if (today.closeMinute > today.openMinute) {
|
||||
return currentMinute >= today.openMinute && currentMinute < today.closeMinute;
|
||||
}
|
||||
return currentMinute >= today.openMinute || currentMinute < today.closeMinute;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
export interface StoreInput {
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
district: string;
|
||||
longitude?: number | null;
|
||||
latitude?: number | null;
|
||||
contactPhone: string;
|
||||
@@ -46,7 +48,8 @@ export interface RoomInput {
|
||||
interface IdRow extends RowDataPacket { id: string }
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface StoreRow extends RowDataPacket {
|
||||
id: string; name: string; address: string; longitude: string | null; latitude: string | null;
|
||||
id: string; name: string; address: string; city: string; district: string;
|
||||
longitude: string | null; latitude: string | null;
|
||||
contactPhone: string; timezone: string; businessStatus: string; wifiSsid: string;
|
||||
notificationUrl: string; sortOrder: number;
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export class StoreRoomRepository {
|
||||
async listStores(actor: ManagementActor) {
|
||||
const scope = this.scope(actor, 's.id');
|
||||
const [rows] = await this.pool.execute<StoreRow[]>(
|
||||
`SELECT s.id, s.name, s.address, s.longitude, s.latitude,
|
||||
`SELECT s.id, s.name, s.address, s.city, s.district, s.longitude, s.latitude,
|
||||
s.contact_phone AS contactPhone, s.timezone,
|
||||
s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid,
|
||||
s.notification_url AS notificationUrl, s.sort_order AS sortOrder
|
||||
@@ -91,10 +94,10 @@ export class StoreRoomRepository {
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_stores
|
||||
(tenant_id, name, address, longitude, latitude, contact_phone, timezone,
|
||||
(tenant_id, name, address, city, district, longitude, latitude, contact_phone, timezone,
|
||||
business_status, wifi_ssid, wifi_password, notification_url, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.name, input.address, input.longitude ?? null,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.name, input.address, input.city, input.district, input.longitude ?? null,
|
||||
input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus,
|
||||
input.wifiSsid, input.wifiPassword, input.notificationUrl, input.sortOrder]
|
||||
);
|
||||
@@ -109,11 +112,13 @@ export class StoreRoomRepository {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockStore(connection, actor, storeId);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_stores SET name = ?, address = ?, longitude = ?, latitude = ?,
|
||||
`UPDATE qipai_stores SET name = ?, address = ?, city = ?, district = ?,
|
||||
longitude = ?, latitude = ?,
|
||||
contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
|
||||
wifi_password = ?, notification_url = ?, sort_order = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.name, input.address, input.longitude ?? null, input.latitude ?? null,
|
||||
[input.name, input.address, input.city, input.district,
|
||||
input.longitude ?? null, input.latitude ?? null,
|
||||
input.contactPhone, input.timezone, input.businessStatus, input.wifiSsid,
|
||||
input.wifiPassword, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user