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:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "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: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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+8
View File
@@ -21,6 +21,10 @@ import {
registerContentRoutes, registerContentRoutes,
type ContentRouteOptions type ContentRouteOptions
} from './routes/content-management.js'; } from './routes/content-management.js';
import {
registerStoreDiscoveryRoutes,
type StoreDiscoveryRouteOptions
} from './routes/store-discovery.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -29,6 +33,7 @@ export interface BuildAppOptions {
userManagement?: UserManagementRouteOptions; userManagement?: UserManagementRouteOptions;
storeRoom?: StoreRoomRouteOptions; storeRoom?: StoreRoomRouteOptions;
content?: ContentRouteOptions; content?: ContentRouteOptions;
storeDiscovery?: StoreDiscoveryRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -86,6 +91,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.content) { if (options.content) {
await registerContentRoutes(app, options.content); await registerContentRoutes(app, options.content);
} }
if (options.storeDiscovery) {
await registerStoreDiscoveryRoutes(app, options.storeDiscovery);
}
return app; 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/2026061805_m02c_rbac.up.sql',
'database/migrations/2026061806_m02d_user_management.up.sql', 'database/migrations/2026061806_m02d_user_management.up.sql',
'database/migrations/2026061807_m03a_store_room_domain.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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/2026061805_m02c_rbac.verify.sql',
'database/migrations/2026061806_m02d_user_management.verify.sql', 'database/migrations/2026061806_m02d_user_management.verify.sql',
'database/migrations/2026061807_m03a_store_room_domain.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: [ down: [
'database/migrations/2026061809_m03c_store_discovery.down.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.down.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.down.sql',
'database/migrations/2026061807_m03a_store_room_domain.down.sql', 'database/migrations/2026061807_m03a_store_room_domain.down.sql',
'database/migrations/2026061806_m02d_user_management.down.sql', 'database/migrations/2026061806_m02d_user_management.down.sql',
@@ -172,7 +175,8 @@ export async function executeMigrationPlan(
5, 3, 7, 1, 5, 3, 7, 1,
1, 3, 1, 1, 3, 1,
3, 6, 13, 1, 3, 6, 13, 1,
3, 3, 1 3, 3, 1,
2, 2, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( 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({ const storeSchema = z.object({
name: z.string().trim().min(1).max(128), name: z.string().trim().min(1).max(128),
address: z.string().trim().max(255).default(''), 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(), longitude: coordinate.min(-180).max(180).nullable().optional(),
latitude: coordinate.min(-90).max(90).nullable().optional(), latitude: coordinate.min(-90).max(90).nullable().optional(),
contactPhone: z.string().trim().max(32).default(''), 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 { ContentRepository } from './content/content-repository.js';
import { MediaStorage } from './content/media-storage.js'; import { MediaStorage } from './content/media-storage.js';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
const config = loadConfig(); const config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
@@ -44,6 +45,10 @@ const app = await buildApp({
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
},
storeDiscovery: {
repository: new StoreDiscoveryRepository(pool),
tenancy: authRepository
} }
}); });
app.addHook('onClose', async () => { 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 { export interface StoreInput {
name: string; name: string;
address: string; address: string;
city: string;
district: string;
longitude?: number | null; longitude?: number | null;
latitude?: number | null; latitude?: number | null;
contactPhone: string; contactPhone: string;
@@ -46,7 +48,8 @@ export interface RoomInput {
interface IdRow extends RowDataPacket { id: string } interface IdRow extends RowDataPacket { id: string }
interface CountRow extends RowDataPacket { total: number } interface CountRow extends RowDataPacket { total: number }
interface StoreRow extends RowDataPacket { 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; contactPhone: string; timezone: string; businessStatus: string; wifiSsid: string;
notificationUrl: string; sortOrder: number; notificationUrl: string; sortOrder: number;
} }
@@ -69,7 +72,7 @@ export class StoreRoomRepository {
async listStores(actor: ManagementActor) { async listStores(actor: ManagementActor) {
const scope = this.scope(actor, 's.id'); const scope = this.scope(actor, 's.id');
const [rows] = await this.pool.execute<StoreRow[]>( 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.contact_phone AS contactPhone, s.timezone,
s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid, s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid,
s.notification_url AS notificationUrl, s.sort_order AS sortOrder s.notification_url AS notificationUrl, s.sort_order AS sortOrder
@@ -91,10 +94,10 @@ export class StoreRoomRepository {
return this.transaction(async (connection) => { return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>( const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_stores `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) business_status, wifi_ssid, wifi_password, notification_url, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.name, input.address, input.longitude ?? null, [actor.tenantId, input.name, input.address, input.city, input.district, input.longitude ?? null,
input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus, input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus,
input.wifiSsid, input.wifiPassword, input.notificationUrl, input.sortOrder] input.wifiSsid, input.wifiPassword, input.notificationUrl, input.sortOrder]
); );
@@ -109,11 +112,13 @@ export class StoreRoomRepository {
return this.transaction(async (connection) => { return this.transaction(async (connection) => {
await this.lockStore(connection, actor, storeId); await this.lockStore(connection, actor, storeId);
await connection.execute( 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 = ?, contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
wifi_password = ?, notification_url = ?, sort_order = ? wifi_password = ?, notification_url = ?, sort_order = ?
WHERE tenant_id = ? AND id = ?`, 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.contactPhone, input.timezone, input.businessStatus, input.wifiSsid,
input.wifiPassword, input.notificationUrl, input.sortOrder, actor.tenantId, storeId] 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 contentUpSql = read('database/migrations/2026061808_m03b_decoration_ads_media.up.sql');
const contentDownSql = read('database/migrations/2026061808_m03b_decoration_ads_media.down.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 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 = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -160,5 +163,12 @@ for (const table of [
assert.match(contentUpSql, /schema_version INT UNSIGNED/); assert.match(contentUpSql, /schema_version INT UNSIGNED/);
assert.match(contentUpSql, /scope_type VARCHAR/); assert.match(contentUpSql, /scope_type VARCHAR/);
assert.match(contentUpSql, /checksum_sha256 CHAR\(64\)/); 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, /2026061805_m02c_rbac\.up\.sql/);
assert.match(plan.file, /2026061806_m02d_user_management\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); 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 { UserManagementRepository } from '../dist/auth/user-management-repository.js';
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js'; import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
import { ContentRepository, ContentError } from '../dist/content/content-repository.js'; import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -72,10 +73,10 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', ['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808'] '2026061805', '2026061806', '2026061807', '2026061808', '2026061809']
); );
return rows; 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' traceId: 'm03a-live-test', ip: '127.0.0.1', userAgent: 'M03-A live test'
}; };
const store = await repository.createStore(actor, { 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', longitude: 121.4737, latitude: 31.2304, contactPhone: '13800000003',
timezone: 'Asia/Shanghai', businessStatus: 'OPEN', timezone: 'Asia/Shanghai', businessStatus: 'OPEN',
wifiSsid: 'M03A-WIFI', wifiPassword: 'sanitized-password', 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) { async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query( const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u `SELECT u.id FROM qipai_users u
@@ -488,7 +520,8 @@ try {
{ version: '2026061805', name: 'm02c_rbac' }, { version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' }, { version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' }, { 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); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -496,6 +529,7 @@ try {
await assertUserManagement(pool, loginContext); await assertUserManagement(pool, loginContext);
await assertStoreRoomDomain(pool, loginContext); await assertStoreRoomDomain(pool, loginContext);
await assertContentManagement(pool, loginContext); await assertContentManagement(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
@@ -515,7 +549,8 @@ try {
{ version: '2026061805', name: 'm02c_rbac' }, { version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' }, { version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' }, { 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); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -560,7 +595,10 @@ try {
'tenant-isolated media asset', 'tenant-isolated media asset',
'versioned decoration publish and archive', 'versioned decoration publish and archive',
'store advertisement delivery scope', 'store advertisement delivery scope',
'platform advertisement rejection' 'platform advertisement rejection',
'city fallback store filtering',
'server-side distance sorting',
'empty manual city result'
] ]
}, null, 2)); }, null, 2));
} finally { } 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.');
@@ -0,0 +1,6 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026061809';
ALTER TABLE qipai_stores
DROP INDEX idx_qipai_stores_tenant_coordinates,
DROP INDEX idx_qipai_stores_tenant_city_status,
DROP COLUMN district,
DROP COLUMN city;
@@ -0,0 +1,8 @@
ALTER TABLE qipai_stores
ADD COLUMN city VARCHAR(64) NOT NULL DEFAULT '' AFTER address,
ADD COLUMN district VARCHAR(64) NOT NULL DEFAULT '' AFTER city,
ADD KEY idx_qipai_stores_tenant_city_status (tenant_id, city, business_status, deleted_at),
ADD KEY idx_qipai_stores_tenant_coordinates (tenant_id, latitude, longitude);
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026061809', 'm03c_store_discovery');
@@ -0,0 +1,11 @@
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'qipai_stores'
AND column_name IN ('city', 'district')
ORDER BY column_name;
SELECT index_name FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'qipai_stores'
AND index_name IN ('idx_qipai_stores_tenant_city_status', 'idx_qipai_stores_tenant_coordinates')
GROUP BY index_name ORDER BY index_name;
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061809';
@@ -0,0 +1,20 @@
# M03-C 地图选店与距离 API
## `GET /app-api/stores`
请求头:
- `x-wechat-appid`
- 多租户 AppID 时提供 `tenant-id`
筛选参数:
- `city`
- `businessStatus`
- `openNow=true|false`
- `latitude` + `longitude`
- `maxDistanceMeters`
经纬度必须成对提供。距离由后端使用 Haversine 公式计算并排序,客户端不能提交或覆盖距离值。未授权定位时可仅传城市进行手工选店。
营业中状态按门店时区、业务状态和每周营业时间计算,支持跨午夜营业时间。
@@ -0,0 +1,8 @@
# M03-C 门店发现数据库变更
- 迁移版本:`2026061809`
- `qipai_stores` 新增 `city``district`
- 新增租户/城市/营业状态索引
- 新增租户/经纬度索引
城市字段用于拒绝定位后的手工选店;经纬度仅作为后端距离计算输入,不接受客户端提供的伪造距离。
@@ -0,0 +1,29 @@
# M03-C 地图选店与距离
- 日期:2026-06-18
- 起始 commit`9417064`
- 工程 commit:本阶段工程提交
- ENGINEERING_DELTA=YES
- 子阶段状态:待 push 与远端校验
## 工程增量
- 公开门店发现 API,按 AppID/tenant 绑定解析租户。
- 后端 Haversine 距离计算、最近门店排序和最大距离筛选。
- 城市、营业状态和实时营业中筛选。
- 按门店时区和每周营业时间计算营业状态,支持跨午夜。
- 小程序首页接入定位附近门店和手工城市查询;拒绝定位不会阻断选店。
## 验证
- Windows 全量 `npm test` 通过。
- 小程序 JSON 解析和 JavaScript 静态语法检查通过。
- WSL MySQL 8.4.9 `up → verify → down → up → verify` 通过。
- 迁移语句:up 46、verify 29、down 43。
- 实测同城筛选、距离排序、无匹配城市空结果。
## 影响
- 数据库迁移:`2026061809_m03c_store_discovery`
- 小程序新增固定域名门店查询封装。
- 无新增环境变量和秘密。
+1
View File
@@ -4,4 +4,5 @@ module.exports = {
API_ORIGIN, API_ORIGIN,
APP_API_BASE_URL: `${API_ORIGIN}/app-api`, APP_API_BASE_URL: `${API_ORIGIN}/app-api`,
UPLOAD_BASE_URL: `${API_ORIGIN}/uploads/`, UPLOAD_BASE_URL: `${API_ORIGIN}/uploads/`,
WECHAT_APP_ID: 'wx2a5721d5d0fb81a6',
} }
+47 -38
View File
@@ -1,46 +1,55 @@
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0' const { request } = require('../../utils/api.js')
Page({ Page({
data: { data: {
motto: '自助棋牌室', city: '',
userInfo: { locating: false,
avatarUrl: defaultAvatarUrl, loading: false,
nickName: '', errorMessage: '',
}, stores: [],
hasUserInfo: false,
canIUseGetUserProfile: wx.canIUse('getUserProfile'),
canIUseNicknameComp: wx.canIUse('input.type.nickname'),
}, },
bindViewTap() {
wx.navigateTo({ onCityInput(event) {
url: '../logs/logs' this.setData({ city: event.detail.value })
},
async searchByCity() {
const city = this.data.city.trim()
if (!city) {
this.setData({ errorMessage: '请输入城市名称' })
return
}
await this.loadStores({ city })
},
locateNearby() {
this.setData({ locating: true, errorMessage: '' })
wx.getLocation({
type: 'gcj02',
success: async ({ latitude, longitude }) => {
await this.loadStores({ latitude, longitude })
},
fail: () => {
this.setData({ errorMessage: '定位未授权,请输入城市手工选店' })
},
complete: () => {
this.setData({ locating: false })
},
}) })
}, },
onChooseAvatar(e) {
const { avatarUrl } = e.detail async loadStores(filters) {
const { nickName } = this.data.userInfo this.setData({ loading: true, errorMessage: '' })
this.setData({ try {
'userInfo.avatarUrl': avatarUrl, const query = Object.entries(filters)
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
}) .join('&')
}, const response = await request(`/stores?${query}`)
onInputChange(e) { this.setData({ stores: response.data || [] })
const nickName = e.detail.value } catch (error) {
const { avatarUrl } = this.data.userInfo this.setData({ errorMessage: error.message || '门店加载失败' })
this.setData({ } finally {
'userInfo.nickName': nickName, this.setData({ loading: false })
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, }
})
},
getUserProfile() {
wx.getUserProfile({
desc: '用于展示用户头像和昵称',
success: (res) => {
this.setData({
userInfo: res.userInfo,
hasUserInfo: true
})
}
})
}, },
}) })
+14 -20
View File
@@ -1,26 +1,20 @@
<scroll-view class="scrollarea" scroll-y type="list"> <scroll-view class="scrollarea" scroll-y type="list">
<view class="container"> <view class="container">
<view class="userinfo"> <view class="title">选择门店</view>
<block wx:if="{{canIUseNicknameComp && !hasUserInfo}}"> <button loading="{{locating}}" bindtap="locateNearby">定位附近门店</button>
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar"> <view class="city-search">
<image class="avatar" src="{{userInfo.avatarUrl}}"></image> <input value="{{city}}" placeholder="拒绝定位时输入城市" bindinput="onCityInput" />
</button> <button size="mini" loading="{{loading}}" bindtap="searchByCity">查询</button>
<view class="nickname-wrapper">
<text class="nickname-label">昵称</text>
<input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
</view>
</block>
<block wx:elif="{{!hasUserInfo}}">
<button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile">获取头像昵称</button>
<view wx:else>请升级微信后重试</view>
</block>
<block wx:else>
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
</block>
</view> </view>
<view class="usermotto"> <view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<text class="user-motto">{{motto}}</text> <view wx:if="{{!loading && stores.length === 0}}" class="empty">暂无门店</view>
<view wx:for="{{stores}}" wx:key="id" class="store-card">
<view class="store-name">{{item.name}}</view>
<view>{{item.city}}{{item.district}} {{item.address}}</view>
<view wx:if="{{item.distanceMeters !== null}}">距离 {{item.distanceMeters}} 米</view>
<view class="{{item.openNow ? 'open' : 'closed'}}">
{{item.openNow ? '营业中' : '休息中'}}
</view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
+48 -53
View File
@@ -1,62 +1,57 @@
/**index.wxss**/
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea { .scrollarea {
flex: 1; height: 100vh;
overflow-y: hidden; background: #f5f6f8;
} }
.userinfo { .container {
padding: 32rpx;
}
.title {
margin-bottom: 24rpx;
font-size: 40rpx;
font-weight: 600;
}
.city-search {
display: flex; display: flex;
flex-direction: column; gap: 16rpx;
align-items: center; align-items: center;
color: #aaa; margin: 24rpx 0;
width: 80%;
} }
.userinfo-avatar { .city-search input {
overflow: hidden;
width: 128rpx;
height: 128rpx;
margin: 20rpx;
border-radius: 50%;
}
.usermotto {
margin-top: 200px;
}
.avatar-wrapper {
padding: 0;
width: 56px !important;
border-radius: 8px;
margin-top: 40px;
margin-bottom: 40px;
}
.avatar {
display: block;
width: 56px;
height: 56px;
}
.nickname-wrapper {
display: flex;
width: 100%;
padding: 16px;
box-sizing: border-box;
border-top: .5px solid rgba(0, 0, 0, 0.1);
border-bottom: .5px solid rgba(0, 0, 0, 0.1);
color: black;
}
.nickname-label {
width: 105px;
}
.nickname-input {
flex: 1; flex: 1;
padding: 18rpx 24rpx;
border: 1rpx solid #d7dbe0;
border-radius: 12rpx;
background: #ffffff;
}
.store-card {
margin-top: 20rpx;
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
line-height: 1.7;
}
.store-name {
font-size: 34rpx;
font-weight: 600;
}
.open {
color: #17823b;
}
.closed,
.error {
color: #c73535;
}
.empty {
padding: 80rpx 0;
color: #888888;
text-align: center;
} }
+25
View File
@@ -0,0 +1,25 @@
const { APP_API_BASE_URL, WECHAT_APP_ID } = require('../config/env.js')
function request(path, options = {}) {
return new Promise((resolve, reject) => {
wx.request({
url: `${APP_API_BASE_URL}${path}`,
method: options.method || 'GET',
data: options.data,
header: {
'x-wechat-appid': WECHAT_APP_ID,
...(options.headers || {}),
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data)
return
}
reject(new Error(response.data?.message || `请求失败:${response.statusCode}`))
},
fail: reject,
})
})
}
module.exports = { request }
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M03-B migration roundtrip in a temporary database." echo "INFO: MySQL ${mysql_version}; running M01-B through M03-C migration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M03-B live MySQL migration roundtrip completed." echo "PASS: M01-B through M03-C live MySQL migration roundtrip completed."