feat(M08-C): 补管理员验券与经营统计

This commit is contained in:
Codex
2026-08-10 11:08:55 +08:00
parent 3bfccb8513
commit ce52f07588
18 changed files with 1141 additions and 22 deletions
+8
View File
@@ -51,6 +51,10 @@ import {
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js';
import {
registerBusinessStatisticsRoutes,
type BusinessStatisticsRouteOptions
} from './routes/business-statistics.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -74,6 +78,7 @@ export interface BuildAppOptions {
members?: MemberRouteOptions;
recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
businessStatistics?: BusinessStatisticsRouteOptions;
}
declare module 'fastify' {
@@ -175,6 +180,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.cleaning) {
await registerCleaningRoutes(app, options.cleaning);
}
if (options.businessStatistics) {
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
}
return app;
}
@@ -0,0 +1,180 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
interface OrderSummaryRow extends RowDataPacket {
orderTotal: number;
activeOrderTotal: number;
finishedOrderTotal: number;
bookedAmountCents: number;
payingMemberTotal: number;
}
interface OrderStatusRow extends RowDataPacket {
status: string;
total: number;
amountCents: number;
}
interface PaymentChannelRow extends RowDataPacket {
channel: string;
total: number;
amountCents: number;
}
interface DailyRevenueRow extends RowDataPacket {
date: string | Date;
total: number;
amountCents: number;
}
interface RoomSummaryRow extends RowDataPacket {
roomTotal: number;
availableRoomTotal: number;
attentionRoomTotal: number;
}
interface VoucherSummaryRow extends RowDataPacket {
voucherTotal: number;
voucherSucceeded: number;
voucherFailed: number;
}
export class BusinessStatisticsError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class BusinessStatisticsRepository {
constructor(private readonly pool: MySqlPool) {}
async overview(
actor: ManagementActor,
input: { storeId: string; from: Date; to: Date }
) {
this.assertStoreScope(actor, input.storeId);
const rangeParams = [actor.tenantId, input.storeId, input.from, input.to];
const [
orderSummaryResult,
orderStatusResult,
paymentChannelResult,
dailyRevenueResult,
roomSummaryResult,
voucherSummaryResult
] = await Promise.all([
this.pool.execute<OrderSummaryRow[]>(
`SELECT COUNT(*) AS orderTotal,
COALESCE(SUM(o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS')), 0) AS activeOrderTotal,
COALESCE(SUM(o.status = 'FINISHED'), 0) AS finishedOrderTotal,
COALESCE(SUM(o.total_amount_cents), 0) AS bookedAmountCents,
COUNT(DISTINCT owner.user_id) AS payingMemberTotal
FROM qipai_orders o
LEFT JOIN (
SELECT tenant_id, order_id, MIN(user_id) AS user_id
FROM qipai_order_user_access
WHERE access_type = 'OWNER'
GROUP BY tenant_id, order_id
) owner ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
AND o.created_at >= ? AND o.created_at < ?`,
rangeParams
),
this.pool.execute<OrderStatusRow[]>(
`SELECT o.status, COUNT(*) AS total,
COALESCE(SUM(o.total_amount_cents), 0) AS amountCents
FROM qipai_orders o
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
AND o.created_at >= ? AND o.created_at < ?
GROUP BY o.status ORDER BY total DESC, o.status`,
rangeParams
),
this.pool.execute<PaymentChannelRow[]>(
`SELECT p.channel, COUNT(*) AS total,
COALESCE(SUM(p.amount_cents), 0) AS amountCents
FROM qipai_payments p
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
AND p.paid_at >= ? AND p.paid_at < ?
GROUP BY p.channel ORDER BY amountCents DESC, p.channel`,
rangeParams
),
this.pool.execute<DailyRevenueRow[]>(
`SELECT DATE_FORMAT(p.paid_at, '%Y-%m-%d') AS date, COUNT(*) AS total,
COALESCE(SUM(p.amount_cents), 0) AS amountCents
FROM qipai_payments p
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
AND p.paid_at >= ? AND p.paid_at < ?
GROUP BY DATE_FORMAT(p.paid_at, '%Y-%m-%d') ORDER BY date`,
rangeParams
),
this.pool.execute<RoomSummaryRow[]>(
`SELECT COUNT(*) AS roomTotal,
COALESCE(SUM(configuration_status = 'ENABLED'
AND operational_status = 'AVAILABLE'), 0) AS availableRoomTotal,
COALESCE(SUM(configuration_status = 'DISABLED'
OR operational_status IN ('MAINTENANCE', 'CLEANING_REQUIRED')), 0) AS attentionRoomTotal
FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`,
[actor.tenantId, input.storeId]
),
this.pool.execute<VoucherSummaryRow[]>(
`SELECT COUNT(*) AS voucherTotal,
COALESCE(SUM(status = 'SUCCEEDED'), 0) AS voucherSucceeded,
COALESCE(SUM(status = 'FAILED'), 0) AS voucherFailed
FROM qipai_group_redemptions
WHERE tenant_id = ? AND store_id = ? AND created_at >= ? AND created_at < ?`,
rangeParams
)
]);
const order = orderSummaryResult[0][0];
const room = roomSummaryResult[0][0];
const voucher = voucherSummaryResult[0][0];
const paymentChannels = paymentChannelResult[0].map((row) => ({
channel: row.channel,
total: Number(row.total),
amountCents: Number(row.amountCents)
}));
return {
storeId: input.storeId,
from: input.from.toISOString(),
to: input.to.toISOString(),
summary: {
orderTotal: Number(order?.orderTotal ?? 0),
activeOrderTotal: Number(order?.activeOrderTotal ?? 0),
finishedOrderTotal: Number(order?.finishedOrderTotal ?? 0),
bookedAmountCents: Number(order?.bookedAmountCents ?? 0),
collectedAmountCents: paymentChannels.reduce((sum, row) => sum + row.amountCents, 0),
payingMemberTotal: Number(order?.payingMemberTotal ?? 0),
voucherTotal: Number(voucher?.voucherTotal ?? 0),
voucherSucceeded: Number(voucher?.voucherSucceeded ?? 0),
voucherFailed: Number(voucher?.voucherFailed ?? 0),
roomTotal: Number(room?.roomTotal ?? 0),
availableRoomTotal: Number(room?.availableRoomTotal ?? 0),
attentionRoomTotal: Number(room?.attentionRoomTotal ?? 0)
},
orderStatuses: orderStatusResult[0].map((row) => ({
status: row.status,
total: Number(row.total),
amountCents: Number(row.amountCents)
})),
paymentChannels,
dailyRevenue: dailyRevenueResult[0].map((row) => ({
date: formatDate(row.date),
total: Number(row.total),
amountCents: Number(row.amountCents)
}))
};
}
private assertStoreScope(actor: ManagementActor, storeId: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (!actor.access.capabilities.includes('store.operation.read')
|| !actor.access.storeIds.includes(storeId)) {
throw new BusinessStatisticsError('BUSINESS_STATISTICS_FORBIDDEN');
}
}
}
function formatDate(value: string | Date) {
if (typeof value === 'string') return value.slice(0, 10);
return value.toISOString().slice(0, 10);
}
+94
View File
@@ -0,0 +1,94 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import {
BusinessStatisticsError,
type BusinessStatisticsRepository
} from '../operations/business-statistics-repository.js';
const querySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
from: z.coerce.date().optional(),
to: z.coerce.date().optional()
}).strict();
export interface BusinessStatisticsRouteOptions {
repository: Pick<BusinessStatisticsRepository, 'overview'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerBusinessStatisticsRoutes(
app: FastifyInstance,
options: BusinessStatisticsRouteOptions
) {
app.get('/app-api/management/statistics', async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
const query = querySchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
const to = query.data.to ?? new Date();
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
const duration = to.getTime() - from.getTime();
if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId);
try {
return {
code: 0,
data: await options.repository.overview(actor, {
storeId: query.data.storeId,
from,
to
}),
traceId: request.traceId
};
} catch (error) {
if (!(error instanceof BusinessStatisticsError)) throw error;
return reply.status(403).send({
code: error.code,
message: 'Business statistics permission is required.',
traceId: request.traceId
});
}
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: BusinessStatisticsRouteOptions
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_BUSINESS_STATISTICS_REQUEST',
message: 'The statistics request is invalid.',
traceId
});
}
+56 -2
View File
@@ -36,7 +36,8 @@ const bookingSchema = z.object({
const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) });
const recordsQuery = z.object({
provider: providerSchema.optional(),
status: z.string().min(1).max(32).optional()
status: z.string().min(1).max(32).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const configSchema = z.object({
provider: providerSchema,
@@ -99,6 +100,40 @@ export async function registerThirdPartyRoutes(
}));
});
app.post('/app-api/management/group-vouchers/redeem', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = redeemSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucherAsManager({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/app-api/management/group-vouchers/redeem-manual', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = manualSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucherManually({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
app.post(
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
async (request, reply) => {
@@ -150,7 +185,26 @@ export async function registerThirdPartyRoutes(
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined,
status: query.data.status
status: query.data.status,
storeId: query.data.storeId
}),
traceId: request.traceId
}));
});
app.get('/app-api/management/third-party/records', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = recordsQuery.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listRecords({
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined,
status: query.data.status,
storeId: query.data.storeId
}),
traceId: request.traceId
}));
+7
View File
@@ -39,6 +39,7 @@ import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -202,6 +203,12 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
businessStatistics: {
repository: new BusinessStatisticsRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+75 -17
View File
@@ -119,6 +119,45 @@ export class ThirdPartyService {
});
}
async redeemVoucherAsManager(input: {
tenantId: string;
actorId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
voucherCode: string;
orderId: string;
clientRequestId: string;
}) {
const existing = await this.findRedemptionByRequest(
input.tenantId, input.clientRequestId, input.orderId
);
if (existing) return { ...existing, idempotent: true };
const order = await this.loadOrder(input.tenantId, input.orderId);
assertStoreAccess(input.access, order.storeId);
const config = await this.resolveConfig(input.tenantId, order.storeId, input.provider);
const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
if (expectedAmountCents <= 0) throw new ThirdPartyError('PAYMENT_NOT_REQUIRED');
const result = await this.client.redeemVoucher({
mode: config.mode,
provider: input.provider,
voucherCode: input.voucherCode,
orderNo: order.orderNo,
expectedAmountCents,
settings: config.settings,
credential: this.resolveCredential(config.credentialRef)
});
return this.recordRedemption({
...input,
mode: config.mode,
amountCents: result.amountCents,
expectedAmountCents,
externalProductId: result.externalProductId ?? '',
status: result.status,
failureCode: result.failureCode ?? '',
response: result.response ?? {}
});
}
async receiveDirectBooking(input: {
tenantId: string;
provider: ThirdPartyProvider;
@@ -242,26 +281,31 @@ export class ThirdPartyService {
access: AccessProfile;
provider?: ThirdPartyProvider;
status?: string;
storeId?: string;
}) {
const storeFilter = input.access.capabilities.includes('tenant.manage')
|| input.access.roles.includes('PLATFORM_ADMIN')
? null : input.access.storeIds;
const unrestricted = input.access.capabilities.includes('tenant.manage')
|| input.access.roles.includes('PLATFORM_ADMIN');
if (input.storeId && !unrestricted && !input.access.storeIds.includes(input.storeId)) {
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
const storeFilter = input.storeId ? [input.storeId]
: unrestricted ? null : input.access.storeIds;
if (storeFilter && storeFilter.length === 0) {
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
const params: string[] = [input.tenantId];
const filters = ['tenant_id = ?'];
const bookingParams: string[] = [input.tenantId];
const bookingFilters = ['tenant_id = ?'];
if (input.provider) {
filters.push('provider = ?');
params.push(input.provider);
bookingFilters.push('provider = ?');
bookingParams.push(input.provider);
}
if (input.status) {
filters.push('status = ?');
params.push(input.status);
bookingFilters.push('status = ?');
bookingParams.push(input.status);
}
if (storeFilter) {
filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
params.push(...storeFilter);
bookingFilters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
bookingParams.push(...storeFilter);
}
const [bookings] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, external_booking_no AS externalBookingNo,
@@ -269,20 +313,34 @@ export class ThirdPartyService {
ends_at AS endsAt, amount_cents AS amountCents, status,
order_id AS orderId, failure_code AS failureCode, created_at AS createdAt
FROM qipai_direct_bookings
WHERE ${filters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
params
WHERE ${bookingFilters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
bookingParams
);
const redemptionParams: string[] = [input.tenantId];
const redemptionFilters = ['r.tenant_id = ?'];
if (input.provider) {
redemptionFilters.push('v.provider = ?');
redemptionParams.push(input.provider);
}
if (input.status) {
redemptionFilters.push('r.status = ?');
redemptionParams.push(input.status);
}
if (storeFilter) {
redemptionFilters.push(`r.store_id IN (${storeFilter.map(() => '?').join(',')})`);
redemptionParams.push(...storeFilter);
}
const [redemptions] = await this.pool.execute<RowDataPacket[]>(
`SELECT r.id, v.provider, v.voucher_masked AS voucherMasked,
r.order_id AS orderId, r.store_id AS storeId, r.redemption_mode AS mode,
r.status, r.failure_code AS failureCode, r.created_at AS createdAt
r.actor_id AS actorId, r.status, r.failure_code AS failureCode,
r.created_at AS createdAt, r.completed_at AS completedAt
FROM qipai_group_redemptions r
INNER JOIN qipai_group_vouchers v
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
WHERE r.tenant_id = ?
${storeFilter ? `AND r.store_id IN (${storeFilter.map(() => '?').join(',')})` : ''}
WHERE ${redemptionFilters.join(' AND ')}
ORDER BY r.id DESC LIMIT 100`,
storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId]
redemptionParams
);
return { bookings, redemptions };
}