feat(M08-A): 接入顾客端选店下单入口

This commit is contained in:
Codex
2026-06-24 21:26:32 +08:00
parent c90f6e34a1
commit 4ac2c960e4
20 changed files with 734 additions and 32 deletions
+87 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import type { AuthRepository, LoginContext } from '../auth/auth-repository.js';
import type { StoreDiscoveryRepository } from '../stores/store-discovery-repository.js';
const headersSchema = z.object({
@@ -21,7 +21,7 @@ const querySchema = z.object({
});
export interface StoreDiscoveryRouteOptions {
repository: Pick<StoreDiscoveryRepository, 'findStores'>;
repository: Pick<StoreDiscoveryRepository, 'findStores' | 'getStore' | 'listRooms'>;
tenancy: Pick<AuthRepository, 'resolveLoginContext'>;
}
@@ -64,4 +64,89 @@ export async function registerStoreDiscoveryRoutes(
throw error;
}
});
app.get('/app-api/stores/:storeId', async (request, reply) => {
const context = await resolveContext(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!context) return;
if (!params.success) return invalid(reply, request.traceId);
const store = await options.repository.getStore({
tenantId: context.tenantId,
storeId: params.data.storeId
});
if (!store) {
return reply.status(404).send({
code: 'STORE_NOT_FOUND',
message: 'Store was not found.',
traceId: request.traceId
});
}
return { code: 0, data: store, traceId: request.traceId };
});
app.get('/app-api/stores/:storeId/rooms', async (request, reply) => {
const context = await resolveContext(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!context) return;
if (!params.success) return invalid(reply, request.traceId);
return {
code: 0,
data: await options.repository.listRooms({
tenantId: context.tenantId,
storeId: params.data.storeId
}),
traceId: request.traceId
};
});
}
const storeIdSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/)
});
async function resolveContext(
request: { headers: unknown; traceId: string },
reply: { status(code: number): { send(payload: unknown): unknown } },
options: StoreDiscoveryRouteOptions
): Promise<LoginContext | null> {
const headers = headersSchema.safeParse(request.headers);
if (!headers.success) {
invalid(reply, request.traceId);
return null;
}
try {
const context = await options.tenancy.resolveLoginContext(
headers.data['x-wechat-appid'], headers.data['tenant-id']
);
if (!context) {
reply.status(404).send({
code: 'APP_TENANT_NOT_FOUND',
message: 'Application tenant binding was not found.',
traceId: request.traceId
});
return null;
}
return context;
} catch (error) {
if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') {
reply.status(409).send({
code: 'TENANT_SELECTION_REQUIRED',
message: 'tenant-id is required for an application bound to multiple tenants.',
traceId: request.traceId
});
return null;
}
throw error;
}
}
function invalid(
reply: { status(code: number): { send(payload: unknown): unknown } },
traceId: string
) {
return reply.status(400).send({
code: 'INVALID_STORE_DISCOVERY_REQUEST',
message: 'AppID and valid store filters are required.',
traceId
});
}
@@ -30,6 +30,30 @@ interface DiscoveryRow extends RowDataPacket {
sortOrder: number;
}
interface RoomRow extends RowDataPacket {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: string;
operationalStatus: string;
tags: string | string[] | null;
images: string | string[] | null;
sortOrder: number;
}
export interface DiscoveredStore {
id: string;
name: string;
@@ -46,6 +70,30 @@ export interface DiscoveredStore {
sortOrder: number;
}
export interface PublicRoom {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: string;
operationalStatus: string;
tags: string[];
images: string[];
sortOrder: number;
}
export class StoreDiscoveryRepository {
constructor(private readonly pool: MySqlPool) {}
@@ -125,6 +173,47 @@ export class StoreDiscoveryRepository {
return left.sortOrder - right.sortOrder || Number(left.id) - Number(right.id);
});
}
async getStore(query: { tenantId: string; storeId: string; now?: Date }):
Promise<DiscoveredStore | null> {
const stores = await this.findStores({ tenantId: query.tenantId, now: query.now });
return stores.find((store) => store.id === query.storeId) ?? null;
}
async listRooms(query: { tenantId: string; storeId: string }): Promise<PublicRoom[]> {
const [rows] = await this.pool.execute<RoomRow[]>(
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
r.name, r.room_no AS roomNo, r.capacity,
r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents,
r.holiday_price_cents AS holidayPriceCents,
r.overnight_price_cents AS overnightPriceCents,
r.full_day_price_cents AS fullDayPriceCents,
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
r.max_advance_days AS maxAdvanceDays,
r.configuration_status AS configurationStatus,
r.operational_status AS operationalStatus, r.tags, r.images,
r.sort_order AS sortOrder
FROM qipai_rooms r
INNER JOIN qipai_stores s
ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL
LEFT JOIN qipai_room_categories c
ON c.id = r.category_id AND c.tenant_id = r.tenant_id AND c.deleted_at IS NULL
WHERE r.tenant_id = ? AND r.store_id = ? AND r.deleted_at IS NULL
AND r.configuration_status = 'ENABLED'
ORDER BY r.sort_order, r.id`,
[query.tenantId, query.storeId]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: String(row.storeId),
tags: parseJsonArray(row.tags),
images: parseJsonArray(row.images)
}));
}
}
export function haversineMeters(
@@ -162,3 +251,14 @@ function isOpenAt(hours: DiscoveryRow[], timezone: string, now: Date): boolean {
}
return currentMinute >= today.openMinute || currentMinute < today.closeMinute;
}
function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value;
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : [];
} catch {
return [];
}
}