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