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
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+8
View File
@@ -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;
}
+7 -3
View File
@@ -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(
+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(''),
+5
View File
@@ -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;
}
+12 -7
View File
@@ -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]
);
+11 -1
View File
@@ -33,6 +33,9 @@ const storeRoomVerifySql = read('database/migrations/2026061807_m03a_store_room_
const contentUpSql = read('database/migrations/2026061808_m03b_decoration_ads_media.up.sql');
const contentDownSql = read('database/migrations/2026061808_m03b_decoration_ads_media.down.sql');
const contentVerifySql = read('database/migrations/2026061808_m03b_decoration_ads_media.verify.sql');
const discoveryUpSql = read('database/migrations/2026061809_m03c_store_discovery.up.sql');
const discoveryDownSql = read('database/migrations/2026061809_m03c_store_discovery.down.sql');
const discoveryVerifySql = read('database/migrations/2026061809_m03c_store_discovery.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -160,5 +163,12 @@ for (const table of [
assert.match(contentUpSql, /schema_version INT UNSIGNED/);
assert.match(contentUpSql, /scope_type VARCHAR/);
assert.match(contentUpSql, /checksum_sha256 CHAR\(64\)/);
for (const column of ['city', 'district']) {
assert.match(discoveryUpSql, new RegExp(`ADD COLUMN ${column}`));
assert.match(discoveryDownSql, new RegExp(`DROP COLUMN ${column}`));
assert.match(discoveryVerifySql, new RegExp(`'${column}'`));
}
assert.match(discoveryUpSql, /idx_qipai_stores_tenant_city_status/);
assert.match(discoveryUpSql, /idx_qipai_stores_tenant_coordinates/);
console.log('PASS: M01-B through M03-B migration contracts are present.');
console.log('PASS: M01-B through M03-C migration contracts are present.');
+2 -1
View File
@@ -19,7 +19,8 @@ assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql/);
assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql/);
assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql/);
assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/);
assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql$/);
assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/);
assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -15,6 +15,7 @@ import { RbacRepository } from '../dist/auth/rbac-repository.js';
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -72,10 +73,10 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808']
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809']
);
return rows;
}
@@ -341,7 +342,7 @@ async function assertStoreRoomDomain(pool, context) {
traceId: 'm03a-live-test', ip: '127.0.0.1', userAgent: 'M03-A live test'
};
const store = await repository.createStore(actor, {
name: 'M03A Store', address: 'Sanitized address',
name: 'M03A Store', address: 'Sanitized address', city: '上海市', district: '黄浦区',
longitude: 121.4737, latitude: 31.2304, contactPhone: '13800000003',
timezone: 'Asia/Shanghai', businessStatus: 'OPEN',
wifiSsid: 'M03A-WIFI', wifiPassword: 'sanitized-password',
@@ -393,6 +394,37 @@ async function assertStoreRoomDomain(pool, context) {
]);
}
async function assertStoreDiscovery(pool, context) {
const [secondStore] = await pool.query(
`INSERT INTO qipai_stores
(tenant_id, name, address, city, district, longitude, latitude, business_status, sort_order)
VALUES (?, 'M03C Far Store', 'Sanitized address B', '上海市', '浦东新区',
121.6000000, 31.3000000, 'OPEN', 2)`,
[context.tenantId]
);
await pool.query(
`INSERT INTO qipai_store_business_hours
(tenant_id, store_id, weekday, open_minute, close_minute, is_closed)
VALUES (?, ?, 4, 0, 0, 0)`,
[context.tenantId, secondStore.insertId]
);
const repository = new StoreDiscoveryRepository(pool);
const stores = await repository.findStores({
tenantId: context.tenantId,
city: '上海市',
latitude: 31.2304,
longitude: 121.4737,
now: new Date('2026-06-18T04:00:00.000Z')
});
assert.equal(stores[0].name, 'M03A Store');
assert.equal(stores[0].distanceMeters, 0);
assert.ok(stores[1].distanceMeters > stores[0].distanceMeters);
assert.equal(stores.every((store) => store.city === '上海市'), true);
assert.equal((await repository.findStores({
tenantId: context.tenantId, city: '不存在的城市'
})).length, 0);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -488,7 +520,8 @@ try {
{ version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' }
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -496,6 +529,7 @@ try {
await assertUserManagement(pool, loginContext);
await assertStoreRoomDomain(pool, loginContext);
await assertContentManagement(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
@@ -515,7 +549,8 @@ try {
{ version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' }
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -560,7 +595,10 @@ try {
'tenant-isolated media asset',
'versioned decoration publish and archive',
'store advertisement delivery scope',
'platform advertisement rejection'
'platform advertisement rejection',
'city fallback store filtering',
'server-side distance sorting',
'empty manual city result'
]
}, null, 2));
} finally {
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import {
haversineMeters,
StoreDiscoveryRepository
} from '../dist/stores/store-discovery-repository.js';
assert.ok(haversineMeters(31.2304, 121.4737, 31.2304, 121.4737) < 1);
assert.ok(haversineMeters(31.2304, 121.4737, 31.2200, 121.4800) > 1000);
const rows = [
{
id: 11, name: '近店', address: 'A', city: '上海市', district: '黄浦区',
longitude: '121.4737', latitude: '31.2304', contactPhone: '',
timezone: 'Asia/Shanghai', businessStatus: 'OPEN',
weekday: 4, openMinute: 0, closeMinute: 0, isClosed: 0, sortOrder: 1
},
{
id: 12, name: '远店', address: 'B', city: '上海市', district: '浦东新区',
longitude: '121.6000', latitude: '31.3000', contactPhone: '',
timezone: 'Asia/Shanghai', businessStatus: 'OPEN',
weekday: 4, openMinute: 0, closeMinute: 0, isClosed: 0, sortOrder: 2
}
];
const repository = new StoreDiscoveryRepository({
async execute(sql, params) {
assert.match(sql, /s\.tenant_id = \?/);
assert.deepEqual(params, ['7', '上海市']);
return [rows, []];
}
});
const stores = await repository.findStores({
tenantId: '7', city: '上海市', latitude: 31.2304, longitude: 121.4737,
now: new Date('2026-06-18T04:00:00.000Z')
});
assert.deepEqual(stores.map((store) => store.id), ['11', '12']);
assert.equal(stores[0].distanceMeters, 0);
assert.equal(stores[0].openNow, true);
const app = await buildApp({
storeDiscovery: {
tenancy: {
async resolveLoginContext(appId, tenantId) {
assert.equal(appId, 'wx-test-app');
assert.equal(tenantId, '7');
return { tenantId: '7', platformAppId: '9', appId };
}
},
repository: {
async findStores(query) {
assert.equal(query.city, '上海市');
return stores;
}
}
}
});
const response = await app.inject({
method: 'GET',
url: '/app-api/stores?city=%E4%B8%8A%E6%B5%B7%E5%B8%82',
headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' }
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().data[0].id, '11');
const invalid = await app.inject({
method: 'GET',
url: '/app-api/stores?latitude=31.2',
headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' }
});
assert.equal(invalid.statusCode, 400);
await app.close();
console.log('PASS: M03-C trusted distance sorting, city fallback and public store discovery are present.');