feat(M08-A): 接入顾客端权益明细
This commit is contained in:
@@ -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[]>(
|
||||
|
||||
Reference in New Issue
Block a user