feat(M08-A): 接入顾客端选店下单入口
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user