diff --git a/backend/package.json b/backend/package.json index c3ccf3f..d2d67ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.ts b/backend/src/app.ts index 6a1e738..d2dc77a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 = { '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 = { '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( diff --git a/backend/src/routes/store-discovery.ts b/backend/src/routes/store-discovery.ts new file mode 100644 index 0000000..e8c5eb4 --- /dev/null +++ b/backend/src/routes/store-discovery.ts @@ -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; + tenancy: Pick; +} + +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; + } + }); +} diff --git a/backend/src/routes/store-room-management.ts b/backend/src/routes/store-room-management.ts index 0395416..7f872d3 100644 --- a/backend/src/routes/store-room-management.ts +++ b/backend/src/routes/store-room-management.ts @@ -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(''), diff --git a/backend/src/server.ts b/backend/src/server.ts index 60546d7..4f639c7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => { diff --git a/backend/src/stores/store-discovery-repository.ts b/backend/src/stores/store-discovery-repository.ts new file mode 100644 index 0000000..7db6f03 --- /dev/null +++ b/backend/src/stores/store-discovery-repository.ts @@ -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 { + const filters = ['s.tenant_id = ?', 's.deleted_at IS NULL']; + const params: Array = [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( + `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; + 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; +} diff --git a/backend/src/stores/store-room-repository.ts b/backend/src/stores/store-room-repository.ts index c693cb6..274ffcf 100644 --- a/backend/src/stores/store-room-repository.ts +++ b/backend/src/stores/store-room-repository.ts @@ -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( - `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( `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] ); diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index c38b984..807acdc 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 65a5f5d..d0f59d6 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 03af9d5..f97ab84 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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 { diff --git a/backend/tests/store-discovery.test.mjs b/backend/tests/store-discovery.test.mjs new file mode 100644 index 0000000..851996a --- /dev/null +++ b/backend/tests/store-discovery.test.mjs @@ -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.'); diff --git a/database/migrations/2026061809_m03c_store_discovery.down.sql b/database/migrations/2026061809_m03c_store_discovery.down.sql new file mode 100644 index 0000000..f7b9420 --- /dev/null +++ b/database/migrations/2026061809_m03c_store_discovery.down.sql @@ -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; diff --git a/database/migrations/2026061809_m03c_store_discovery.up.sql b/database/migrations/2026061809_m03c_store_discovery.up.sql new file mode 100644 index 0000000..5338cd6 --- /dev/null +++ b/database/migrations/2026061809_m03c_store_discovery.up.sql @@ -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'); diff --git a/database/migrations/2026061809_m03c_store_discovery.verify.sql b/database/migrations/2026061809_m03c_store_discovery.verify.sql new file mode 100644 index 0000000..82a7c8e --- /dev/null +++ b/database/migrations/2026061809_m03c_store_discovery.verify.sql @@ -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'; diff --git a/docs/api-changelog/2026-06-18-M03-C-store-discovery.md b/docs/api-changelog/2026-06-18-M03-C-store-discovery.md new file mode 100644 index 0000000..696d61c --- /dev/null +++ b/docs/api-changelog/2026-06-18-M03-C-store-discovery.md @@ -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 公式计算并排序,客户端不能提交或覆盖距离值。未授权定位时可仅传城市进行手工选店。 + +营业中状态按门店时区、业务状态和每周营业时间计算,支持跨午夜营业时间。 diff --git a/docs/db-changelog/2026-06-18-M03-C-store-discovery.md b/docs/db-changelog/2026-06-18-M03-C-store-discovery.md new file mode 100644 index 0000000..b5447ab --- /dev/null +++ b/docs/db-changelog/2026-06-18-M03-C-store-discovery.md @@ -0,0 +1,8 @@ +# M03-C 门店发现数据库变更 + +- 迁移版本:`2026061809` +- `qipai_stores` 新增 `city`、`district` +- 新增租户/城市/营业状态索引 +- 新增租户/经纬度索引 + +城市字段用于拒绝定位后的手工选店;经纬度仅作为后端距离计算输入,不接受客户端提供的伪造距离。 diff --git a/docs/devlogs/2026-06-18-M03-C-地图选店与距离.md b/docs/devlogs/2026-06-18-M03-C-地图选店与距离.md new file mode 100644 index 0000000..6fd3cdb --- /dev/null +++ b/docs/devlogs/2026-06-18-M03-C-地图选店与距离.md @@ -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`。 +- 小程序新增固定域名门店查询封装。 +- 无新增环境变量和秘密。 diff --git a/miniapp/config/env.js b/miniapp/config/env.js index 0e5d972..972d75b 100644 --- a/miniapp/config/env.js +++ b/miniapp/config/env.js @@ -4,4 +4,5 @@ module.exports = { API_ORIGIN, APP_API_BASE_URL: `${API_ORIGIN}/app-api`, UPLOAD_BASE_URL: `${API_ORIGIN}/uploads/`, + WECHAT_APP_ID: 'wx2a5721d5d0fb81a6', } diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index 6865dc7..d2bf1b4 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -1,46 +1,55 @@ -const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0' +const { request } = require('../../utils/api.js') Page({ data: { - motto: '自助棋牌室', - userInfo: { - avatarUrl: defaultAvatarUrl, - nickName: '', - }, - hasUserInfo: false, - canIUseGetUserProfile: wx.canIUse('getUserProfile'), - canIUseNicknameComp: wx.canIUse('input.type.nickname'), + city: '', + locating: false, + loading: false, + errorMessage: '', + stores: [], }, - bindViewTap() { - wx.navigateTo({ - url: '../logs/logs' + + onCityInput(event) { + 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 - const { nickName } = this.data.userInfo - this.setData({ - 'userInfo.avatarUrl': avatarUrl, - hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, - }) - }, - onInputChange(e) { - const nickName = e.detail.value - const { avatarUrl } = this.data.userInfo - this.setData({ - 'userInfo.nickName': nickName, - hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, - }) - }, - getUserProfile() { - wx.getUserProfile({ - desc: '用于展示用户头像和昵称', - success: (res) => { - this.setData({ - userInfo: res.userInfo, - hasUserInfo: true - }) - } - }) + + async loadStores(filters) { + this.setData({ loading: true, errorMessage: '' }) + try { + const query = Object.entries(filters) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&') + const response = await request(`/stores?${query}`) + this.setData({ stores: response.data || [] }) + } catch (error) { + this.setData({ errorMessage: error.message || '门店加载失败' }) + } finally { + this.setData({ loading: false }) + } }, }) diff --git a/miniapp/pages/index/index.wxml b/miniapp/pages/index/index.wxml index 5cff5f5..8e2f7bd 100644 --- a/miniapp/pages/index/index.wxml +++ b/miniapp/pages/index/index.wxml @@ -1,26 +1,20 @@ - - - - - 昵称 - - - - - - 请升级微信后重试 - - - - {{userInfo.nickName}} - + 选择门店 + + + + - - {{motto}} + {{errorMessage}} + 暂无门店 + + {{item.name}} + {{item.city}}{{item.district}} {{item.address}} + 距离 {{item.distanceMeters}} 米 + + {{item.openNow ? '营业中' : '休息中'}} + diff --git a/miniapp/pages/index/index.wxss b/miniapp/pages/index/index.wxss index 1ebed4b..009cb4a 100644 --- a/miniapp/pages/index/index.wxss +++ b/miniapp/pages/index/index.wxss @@ -1,62 +1,57 @@ -/**index.wxss**/ -page { - height: 100vh; - display: flex; - flex-direction: column; -} .scrollarea { - flex: 1; - overflow-y: hidden; + height: 100vh; + background: #f5f6f8; } -.userinfo { +.container { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.city-search { display: flex; - flex-direction: column; + gap: 16rpx; align-items: center; - color: #aaa; - width: 80%; + margin: 24rpx 0; } -.userinfo-avatar { - 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 { +.city-search input { 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; } diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js new file mode 100644 index 0000000..b993897 --- /dev/null +++ b/miniapp/utils/api.js @@ -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 } diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 0f2e11b..f4a7412 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" 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 -echo "PASS: M01-B through M03-B live MySQL migration roundtrip completed." +echo "PASS: M01-B through M03-C live MySQL migration roundtrip completed."