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[]>(
@@ -13,6 +13,7 @@ const forbiddenToken = signAccessToken({
}, secret, 900);
let profileInput;
let benefitsInput;
const app = await buildApp({
members: {
jwtSecret: secret,
@@ -46,6 +47,23 @@ const app = await buildApp({
service: {
async listMembers() { throw new Error('not called'); },
async getMember() { throw new Error('not called'); },
async getMyBenefits(input) {
benefitsInput = input;
return {
coupons: [{
couponGrantId: '401',
name: '30元抵扣券',
status: 'AVAILABLE',
discountAmountCents: 3000
}],
packages: [{
packageHoldingId: '501',
name: '三小时畅玩包',
status: 'ACTIVE',
remainingMinutes: 120
}]
};
},
async getMyProfile(input) {
profileInput = input;
return {
@@ -71,6 +89,17 @@ assert.equal(profileInput.tenantId, '7');
assert.equal(profileInput.userId, '21');
assert.equal(profile.json().data.wallet.totalBalanceCents, 1200);
const benefits = await app.inject({
method: 'GET',
url: '/app-api/profile/benefits',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(benefits.statusCode, 200);
assert.equal(benefitsInput.tenantId, '7');
assert.equal(benefitsInput.userId, '21');
assert.equal(benefits.json().data.coupons[0].couponGrantId, '401');
assert.equal(benefits.json().data.packages[0].packageHoldingId, '501');
const unauthorized = await app.inject({
method: 'GET',
url: '/app-api/profile'
+150 -5
View File
@@ -47,25 +47,111 @@ const wallets = [
];
const coupons = [
{ tenantId: '7', userId: '21', status: 'AVAILABLE' },
{ tenantId: '7', userId: '21', status: 'FROZEN' },
{ tenantId: '7', userId: '22', status: 'USED' }
{
id: '401',
tenantId: '7',
userId: '21',
templateId: '301',
name: '30元抵扣券',
status: 'AVAILABLE',
couponType: 'FULL_REDUCTION',
discountAmountCents: 3000,
timeMinutes: 0,
minOrderAmountCents: 10000,
remainingUses: 1,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-01T00:00:00.000Z'),
storeId: '11',
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null,
usedAt: null
},
{
id: '402',
tenantId: '7',
userId: '21',
templateId: '302',
name: '60分钟券',
status: 'FROZEN',
couponType: 'TIME',
discountAmountCents: 0,
timeMinutes: 60,
minOrderAmountCents: 0,
remainingUses: 1,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-02T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: '301',
usedAt: null
},
{
id: '403',
tenantId: '7',
userId: '22',
templateId: '303',
name: '其他用户券',
status: 'USED',
couponType: 'FULL_REDUCTION',
discountAmountCents: 500,
timeMinutes: 0,
minOrderAmountCents: 0,
remainingUses: 0,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-01T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null,
usedAt: new Date('2026-06-20T00:00:00.000Z')
}
];
const packages = [
{
id: '501',
tenantId: '7',
userId: '21',
planId: '601',
name: '三小时畅玩包',
status: 'ACTIVE',
priceCents: 9000,
minutesTotal: 180,
amountCentsTotal: 3000,
remainingMinutes: 120,
remainingAmountCents: 3_000
remainingAmountCents: 3_000,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-08-01T00:00:00.000Z'),
storeId: '11',
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null
},
{
id: '502',
tenantId: '7',
userId: '21',
planId: '602',
name: '锁定中的套餐',
status: 'FROZEN',
priceCents: 5000,
minutesTotal: 60,
amountCentsTotal: 2000,
remainingMinutes: 60,
remainingAmountCents: 2_000
remainingAmountCents: 2_000,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-08-02T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: '301'
}
];
@@ -185,6 +271,53 @@ function createService() {
.reduce((sum, item) => sum + item.remainingAmountCents, 0)
}], []];
}
if (sql.includes('FROM qipai_coupon_grants g')) {
const rows = coupons
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
templateId: item.templateId,
name: item.name,
status: item.status,
couponType: item.couponType,
discountAmountCents: item.discountAmountCents,
timeMinutes: item.timeMinutes,
minOrderAmountCents: item.minOrderAmountCents,
remainingUses: item.remainingUses,
validFrom: item.validFrom,
validTo: item.validTo,
storeId: item.storeId,
roomCategoryId: item.roomCategoryId,
roomId: item.roomId,
holidayOnly: item.holidayOnly,
frozenOrderId: item.frozenOrderId,
usedAt: item.usedAt
})), []];
}
if (sql.includes('FROM qipai_package_holdings h')) {
const rows = packages
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
planId: item.planId,
name: item.name,
status: item.status,
priceCents: item.priceCents,
minutesTotal: item.minutesTotal,
amountCentsTotal: item.amountCentsTotal,
remainingMinutes: item.remainingMinutes,
remainingAmountCents: item.remainingAmountCents,
validFrom: item.validFrom,
validTo: item.validTo,
storeId: item.storeId,
roomCategoryId: item.roomCategoryId,
roomId: item.roomId,
holidayOnly: item.holidayOnly,
frozenOrderId: item.frozenOrderId
})), []];
}
if (sql.includes('FROM qipai_recharge_orders')) {
const tenantId = String(params[0]);
const userId = String(params[1]);
@@ -306,6 +439,18 @@ const forbiddenReader = {
assert.equal(member.benefits.packageMinutes, 180);
}
{
const service = createService();
const benefits = await service.getMyBenefits({ tenantId: '7', userId: '21' });
assert.equal(benefits.coupons.length, 2);
assert.equal(benefits.coupons[0].couponGrantId, '401');
assert.equal(benefits.coupons[0].discountAmountCents, 3000);
assert.equal(benefits.coupons[1].frozenOrderId, '301');
assert.equal(benefits.packages.length, 2);
assert.equal(benefits.packages[0].packageHoldingId, '501');
assert.equal(benefits.packages[0].remainingAmountCents, 3000);
}
{
const service = createService();
const scoped = await service.listMembers({