feat(M03-C): 完成地图选店与距离排序

This commit is contained in:
Codex
2026-06-18 15:15:39 +08:00
parent 9417064e61
commit df373faa82
24 changed files with 614 additions and 132 deletions
+67
View File
@@ -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(''),