feat(M03-C): 完成地图选店与距离排序
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(''),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
- 小程序新增固定域名门店查询封装。
|
||||||
|
- 无新增环境变量和秘密。
|
||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
onCityInput(event) {
|
||||||
canIUseNicknameComp: wx.canIUse('input.type.nickname'),
|
this.setData({ city: event.detail.value })
|
||||||
},
|
},
|
||||||
bindViewTap() {
|
|
||||||
wx.navigateTo({
|
async searchByCity() {
|
||||||
url: '../logs/logs'
|
const city = this.data.city.trim()
|
||||||
})
|
if (!city) {
|
||||||
},
|
this.setData({ errorMessage: '请输入城市名称' })
|
||||||
onChooseAvatar(e) {
|
return
|
||||||
const { avatarUrl } = e.detail
|
}
|
||||||
const { nickName } = this.data.userInfo
|
await this.loadStores({ city })
|
||||||
this.setData({
|
},
|
||||||
'userInfo.avatarUrl': avatarUrl,
|
|
||||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
locateNearby() {
|
||||||
})
|
this.setData({ locating: true, errorMessage: '' })
|
||||||
},
|
wx.getLocation({
|
||||||
onInputChange(e) {
|
type: 'gcj02',
|
||||||
const nickName = e.detail.value
|
success: async ({ latitude, longitude }) => {
|
||||||
const { avatarUrl } = this.data.userInfo
|
await this.loadStores({ latitude, longitude })
|
||||||
this.setData({
|
},
|
||||||
'userInfo.nickName': nickName,
|
fail: () => {
|
||||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
this.setData({ errorMessage: '定位未授权,请输入城市手工选店' })
|
||||||
})
|
},
|
||||||
},
|
complete: () => {
|
||||||
getUserProfile() {
|
this.setData({ locating: false })
|
||||||
wx.getUserProfile({
|
},
|
||||||
desc: '用于展示用户头像和昵称',
|
})
|
||||||
success: (res) => {
|
},
|
||||||
this.setData({
|
|
||||||
userInfo: res.userInfo,
|
async loadStores(filters) {
|
||||||
hasUserInfo: true
|
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 })
|
||||||
}
|
}
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
</view>
|
||||||
</block>
|
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||||
<block wx:elif="{{!hasUserInfo}}">
|
<view wx:if="{{!loading && stores.length === 0}}" class="empty">暂无门店</view>
|
||||||
<button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile">获取头像昵称</button>
|
<view wx:for="{{stores}}" wx:key="id" class="store-card">
|
||||||
<view wx:else>请升级微信后重试</view>
|
<view class="store-name">{{item.name}}</view>
|
||||||
</block>
|
<view>{{item.city}}{{item.district}} {{item.address}}</view>
|
||||||
<block wx:else>
|
<view wx:if="{{item.distanceMeters !== null}}">距离 {{item.distanceMeters}} 米</view>
|
||||||
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
|
<view class="{{item.openNow ? 'open' : 'closed'}}">
|
||||||
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
|
{{item.openNow ? '营业中' : '休息中'}}
|
||||||
</block>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="usermotto">
|
|
||||||
<text class="user-motto">{{motto}}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user