feat(M08-A): 接入顾客端权益明细

This commit is contained in:
Codex
2026-06-25 12:04:55 +08:00
parent fb1b593b24
commit e24920c9cc
20 changed files with 661 additions and 38 deletions
+48 -23
View File
@@ -18,7 +18,7 @@ const listSchema = z.object({
});
export interface MemberRouteOptions {
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile'>;
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile' | 'getMyBenefits'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -29,31 +29,26 @@ export async function registerMemberRoutes(
options: MemberRouteOptions
): Promise<void> {
app.get('/app-api/profile', async (request, reply) => {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
if (!access.capabilities.includes('profile.read')) {
return reply.status(403).send({
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
traceId: request.traceId
});
}
const auth = await requireProfileReader(request, reply, options);
if (!auth) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMyProfile({
tenantId: auth.session.tenantId,
userId: auth.session.user.id
tenantId: auth.tenantId,
userId: auth.userId
}),
traceId: request.traceId
}));
});
app.get('/app-api/profile/benefits', async (request, reply) => {
const auth = await requireProfileReader(request, reply, options);
if (!auth) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMyBenefits({
tenantId: auth.tenantId,
userId: auth.userId
}),
traceId: request.traceId
}));
@@ -84,6 +79,36 @@ export async function registerMemberRoutes(
});
}
async function requireProfileReader(
request: FastifyRequest,
reply: FastifyReply,
options: MemberRouteOptions
): Promise<{ tenantId: string; userId: string } | 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
);
if (!access.capabilities.includes('profile.read')) {
reply.status(403).send({
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
traceId: request.traceId
});
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id };
}
async function requireReader(
request: FastifyRequest,
reply: FastifyReply,
@@ -55,6 +55,45 @@ interface LedgerRow extends RowDataPacket {
createdAt: Date;
}
interface CouponDetailRow extends RowDataPacket {
id: string;
templateId: string;
name: string;
status: string;
couponType: string;
discountAmountCents: number;
timeMinutes: number;
minOrderAmountCents: number;
remainingUses: number;
validFrom: Date;
validTo: Date;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
holidayOnly: number;
frozenOrderId: string | null;
usedAt: Date | null;
}
interface PackageDetailRow extends RowDataPacket {
id: string;
planId: string;
name: string;
status: string;
priceCents: number;
minutesTotal: number;
amountCentsTotal: number;
remainingMinutes: number;
remainingAmountCents: number;
validFrom: Date;
validTo: Date;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
holidayOnly: number;
frozenOrderId: string | null;
}
interface CountRow extends RowDataPacket { total: number }
export interface MemberActor {
@@ -167,6 +206,93 @@ export class MemberProfileService {
};
}
async getMyBenefits(input: {
tenantId: string;
userId: string;
limit?: number;
}) {
const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
const [coupons] = await this.pool.execute<CouponDetailRow[]>(
`SELECT g.id, g.template_id AS templateId, t.name, g.status,
t.coupon_type AS couponType,
t.discount_amount_cents AS discountAmountCents,
t.time_minutes AS timeMinutes,
t.min_order_amount_cents AS minOrderAmountCents,
g.remaining_uses AS remainingUses,
g.valid_from AS validFrom, g.valid_to AS validTo,
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
t.room_id AS roomId, t.holiday_only AS holidayOnly,
g.frozen_order_id AS frozenOrderId, g.used_at AS usedAt
FROM qipai_coupon_grants g
INNER JOIN qipai_coupon_templates t
ON t.tenant_id = g.tenant_id AND t.id = g.template_id
WHERE g.tenant_id = ? AND g.user_id = ? AND t.deleted_at IS NULL
ORDER BY FIELD(g.status, 'AVAILABLE', 'FROZEN', 'USED', 'EXPIRED'),
g.valid_to ASC, g.id DESC
LIMIT ?`,
[input.tenantId, input.userId, limit]
);
const [packages] = await this.pool.execute<PackageDetailRow[]>(
`SELECT h.id, h.plan_id AS planId, p.name, h.status,
p.price_cents AS priceCents,
p.minutes_total AS minutesTotal,
p.amount_cents_total AS amountCentsTotal,
h.remaining_minutes AS remainingMinutes,
h.remaining_amount_cents AS remainingAmountCents,
h.valid_from AS validFrom, h.valid_to AS validTo,
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
p.room_id AS roomId, p.holiday_only AS holidayOnly,
h.frozen_order_id AS frozenOrderId
FROM qipai_package_holdings h
INNER JOIN qipai_package_plans p
ON p.tenant_id = h.tenant_id AND p.id = h.plan_id
WHERE h.tenant_id = ? AND h.user_id = ? AND p.deleted_at IS NULL
ORDER BY FIELD(h.status, 'ACTIVE', 'FROZEN', 'EXHAUSTED', 'EXPIRED'),
h.valid_to ASC, h.id DESC
LIMIT ?`,
[input.tenantId, input.userId, limit]
);
return {
coupons: coupons.map((row) => ({
couponGrantId: String(row.id),
templateId: String(row.templateId),
name: row.name,
status: row.status,
couponType: row.couponType,
discountAmountCents: Number(row.discountAmountCents),
timeMinutes: Number(row.timeMinutes),
minOrderAmountCents: Number(row.minOrderAmountCents),
remainingUses: Number(row.remainingUses),
validFrom: row.validFrom,
validTo: row.validTo,
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId),
holidayOnly: Number(row.holidayOnly) === 1,
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId),
usedAt: row.usedAt
})),
packages: packages.map((row) => ({
packageHoldingId: String(row.id),
planId: String(row.planId),
name: row.name,
status: row.status,
priceCents: Number(row.priceCents),
minutesTotal: Number(row.minutesTotal),
amountCentsTotal: Number(row.amountCentsTotal),
remainingMinutes: Number(row.remainingMinutes),
remainingAmountCents: Number(row.remainingAmountCents),
validFrom: row.validFrom,
validTo: row.validTo,
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId),
holidayOnly: Number(row.holidayOnly) === 1,
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId)
}))
};
}
private async memberCard(actor: MemberActor, row: MemberRow) {
const memberId = String(row.id);
const [walletRows] = await this.pool.execute<WalletSummaryRow[]>(