Files
qipai/backend/src/wallets/member-profile-service.ts
T
2026-06-25 12:07:09 +08:00

478 lines
17 KiB
TypeScript

import type { RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import { maskPhone } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
interface MemberRow extends RowDataPacket {
id: string;
status: string;
nickname: string;
phone: string;
createdAt: Date;
lastLoginAt: Date | null;
}
interface WalletSummaryRow extends RowDataPacket {
accountCount: number;
cashBalanceCents: number;
giftBalanceCents: number;
}
interface BenefitSummaryRow extends RowDataPacket {
availableCoupons: number;
frozenCoupons: number;
activePackages: number;
frozenPackages: number;
packageMinutes: number;
packageAmountCents: number;
}
interface RechargeSummaryRow extends RowDataPacket {
rechargeOrderCount: number;
creditedRechargeCount: number;
creditedRechargeCents: number;
giftedRechargeCents: number;
lastRechargeAt: Date | null;
}
interface OrderSummaryRow extends RowDataPacket {
orderCount: number;
paidOrderCount: number;
paidAmountCents: number;
lastOrderAt: Date | null;
}
interface LedgerRow extends RowDataPacket {
id: string;
storeId: string | null;
businessType: string;
businessId: string;
entryType: string;
cashDeltaCents: number;
giftDeltaCents: number;
cashBalanceAfterCents: number;
giftBalanceAfterCents: number;
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 {
tenantId: string;
userId: string;
access: AccessProfile;
}
export class MemberProfileError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class MemberProfileService {
constructor(private readonly pool: MySqlPool) {}
async listMembers(input: {
actor: MemberActor;
page: number;
pageSize: number;
status?: 'ACTIVE' | 'DISABLED';
search?: string;
}) {
const filters = ['u.tenant_id = ?', "u.user_type = 'CUSTOMER'", 'u.deleted_at IS NULL'];
const params: Array<string | number> = [input.actor.tenantId];
if (input.status) {
filters.push('u.status = ?');
params.push(input.status);
}
if (input.search) {
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
const like = `%${input.search}%`;
params.push(like, like, input.search);
}
const scope = memberScopeClause(input.actor, 'u.id');
filters.push(scope.sql);
params.push(...scope.params);
const where = filters.join(' AND ');
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT u.id) AS total
FROM qipai_users u
WHERE ${where}`,
params
);
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE ${where}
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
[...params, input.pageSize, (input.page - 1) * input.pageSize]
);
const items = await Promise.all(rows.map((row) => this.memberCard(input.actor, row)));
return { items, total: Number(counts[0]?.total ?? 0) };
}
async getMember(input: {
actor: MemberActor;
memberId: string;
ledgerLimit?: number;
}) {
const scope = memberScopeClause(input.actor, 'u.id');
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.user_type = 'CUSTOMER'
AND u.deleted_at IS NULL AND ${scope.sql}
LIMIT 1`,
[input.actor.tenantId, input.memberId, ...scope.params]
);
if (!rows[0]) throw new MemberProfileError('MEMBER_NOT_FOUND');
return {
...await this.memberCard(input.actor, rows[0]),
recentLedger: await this.recentLedger(
input.actor.tenantId,
String(rows[0].id),
input.ledgerLimit ?? 10
)
};
}
async getMyProfile(input: {
tenantId: string;
userId: string;
ledgerLimit?: number;
}) {
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.user_type = 'CUSTOMER'
AND u.deleted_at IS NULL
LIMIT 1`,
[input.tenantId, input.userId]
);
if (!rows[0]) throw new MemberProfileError('MEMBER_NOT_FOUND');
const actor = {
tenantId: input.tenantId,
userId: input.userId,
access: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] }
};
return {
...await this.memberCard(actor, rows[0]),
recentLedger: await this.recentLedger(
input.tenantId,
String(rows[0].id),
input.ledgerLimit ?? 10
)
};
}
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[]>(
`SELECT COUNT(*) AS accountCount,
COALESCE(SUM(cash_balance_cents), 0) AS cashBalanceCents,
COALESCE(SUM(gift_balance_cents), 0) AS giftBalanceCents
FROM qipai_wallet_accounts
WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, memberId]
);
const [benefitRows] = await this.pool.execute<BenefitSummaryRow[]>(
`SELECT
COALESCE(SUM(CASE WHEN c.status = 'AVAILABLE' THEN 1 ELSE 0 END), 0) AS availableCoupons,
COALESCE(SUM(CASE WHEN c.status = 'FROZEN' THEN 1 ELSE 0 END), 0) AS frozenCoupons,
COALESCE((SELECT SUM(CASE WHEN h.status = 'ACTIVE' THEN 1 ELSE 0 END)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ?), 0) AS activePackages,
COALESCE((SELECT SUM(CASE WHEN h.status = 'FROZEN' THEN 1 ELSE 0 END)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ?), 0) AS frozenPackages,
COALESCE((SELECT SUM(h.remaining_minutes)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ? AND h.status IN ('ACTIVE', 'FROZEN')), 0) AS packageMinutes,
COALESCE((SELECT SUM(h.remaining_amount_cents)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ? AND h.status IN ('ACTIVE', 'FROZEN')), 0) AS packageAmountCents
FROM qipai_coupon_grants c
WHERE c.tenant_id = ? AND c.user_id = ?`,
[
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId
]
);
const [rechargeRows] = await this.pool.execute<RechargeSummaryRow[]>(
`SELECT COUNT(*) AS rechargeOrderCount,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN 1 ELSE 0 END), 0) AS creditedRechargeCount,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN pay_amount_cents ELSE 0 END), 0) AS creditedRechargeCents,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN gift_amount_cents ELSE 0 END), 0) AS giftedRechargeCents,
MAX(credited_at) AS lastRechargeAt
FROM qipai_recharge_orders
WHERE tenant_id = ? AND user_id = ?`,
[actor.tenantId, memberId]
);
const [orderRows] = await this.pool.execute<OrderSummaryRow[]>(
`SELECT COUNT(*) AS orderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
THEN 1 ELSE 0 END), 0) AS paidOrderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
THEN o.paid_amount_cents ELSE 0 END), 0) AS paidAmountCents,
MAX(o.created_at) AS lastOrderAt
FROM qipai_orders o
INNER JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id
AND a.user_id = ? AND a.revoked_at IS NULL
WHERE o.tenant_id = ? AND o.deleted_at IS NULL`,
[memberId, actor.tenantId]
);
const wallet = walletRows[0] ?? emptyWallet();
const benefit = benefitRows[0] ?? emptyBenefit();
const recharge = rechargeRows[0] ?? emptyRecharge();
const order = orderRows[0] ?? emptyOrder();
return {
memberId,
status: row.status,
nickname: row.nickname,
maskedPhone: maskPhone(row.phone),
registeredAt: row.createdAt,
lastLoginAt: row.lastLoginAt,
wallet: {
accountCount: Number(wallet.accountCount),
cashBalanceCents: Number(wallet.cashBalanceCents),
giftBalanceCents: Number(wallet.giftBalanceCents),
totalBalanceCents: Number(wallet.cashBalanceCents) + Number(wallet.giftBalanceCents)
},
benefits: {
availableCoupons: Number(benefit.availableCoupons),
frozenCoupons: Number(benefit.frozenCoupons),
activePackages: Number(benefit.activePackages),
frozenPackages: Number(benefit.frozenPackages),
packageMinutes: Number(benefit.packageMinutes),
packageAmountCents: Number(benefit.packageAmountCents)
},
recharge: {
rechargeOrderCount: Number(recharge.rechargeOrderCount),
creditedRechargeCount: Number(recharge.creditedRechargeCount),
creditedRechargeCents: Number(recharge.creditedRechargeCents),
giftedRechargeCents: Number(recharge.giftedRechargeCents),
lastRechargeAt: recharge.lastRechargeAt
},
orders: {
orderCount: Number(order.orderCount),
paidOrderCount: Number(order.paidOrderCount),
paidAmountCents: Number(order.paidAmountCents),
lastOrderAt: order.lastOrderAt
}
};
}
private async recentLedger(tenantId: string, memberId: string, limit: number) {
const [rows] = await this.pool.execute<LedgerRow[]>(
`SELECT id, store_id AS storeId, business_type AS businessType,
business_id AS businessId, entry_type AS entryType,
cash_delta_cents AS cashDeltaCents,
gift_delta_cents AS giftDeltaCents,
cash_balance_after_cents AS cashBalanceAfterCents,
gift_balance_after_cents AS giftBalanceAfterCents,
created_at AS createdAt
FROM qipai_wallet_ledger_entries
WHERE tenant_id = ? AND user_id = ?
ORDER BY id DESC LIMIT ?`,
[tenantId, memberId, Math.max(1, Math.min(limit, 50))]
);
return rows.map((row) => ({
ledgerId: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
businessType: row.businessType,
businessId: row.businessId,
entryType: row.entryType,
cashDeltaCents: Number(row.cashDeltaCents),
giftDeltaCents: Number(row.giftDeltaCents),
cashBalanceAfterCents: Number(row.cashBalanceAfterCents),
giftBalanceAfterCents: Number(row.giftBalanceAfterCents),
createdAt: row.createdAt
}));
}
}
function memberScopeClause(actor: MemberActor, userExpression: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
return { sql: '1 = 1', params: [] as string[] };
}
if (!actor.access.capabilities.includes('user.read') || actor.access.storeIds.length === 0) {
return { sql: '1 = 0', params: [] as string[] };
}
const placeholders = actor.access.storeIds.map(() => '?').join(',');
return {
sql: `EXISTS (
SELECT 1 FROM qipai_wallet_accounts wa
WHERE wa.tenant_id = u.tenant_id AND wa.user_id = ${userExpression}
AND wa.store_id IN (${placeholders})
)`,
params: actor.access.storeIds
};
}
function emptyWallet(): WalletSummaryRow {
return { accountCount: 0, cashBalanceCents: 0, giftBalanceCents: 0 } as WalletSummaryRow;
}
function emptyBenefit(): BenefitSummaryRow {
return {
availableCoupons: 0,
frozenCoupons: 0,
activePackages: 0,
frozenPackages: 0,
packageMinutes: 0,
packageAmountCents: 0
} as BenefitSummaryRow;
}
function emptyRecharge(): RechargeSummaryRow {
return {
rechargeOrderCount: 0,
creditedRechargeCount: 0,
creditedRechargeCents: 0,
giftedRechargeCents: 0,
lastRechargeAt: null
} as RechargeSummaryRow;
}
function emptyOrder(): OrderSummaryRow {
return {
orderCount: 0,
paidOrderCount: 0,
paidAmountCents: 0,
lastOrderAt: null
} as OrderSummaryRow;
}