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 = [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( `SELECT COUNT(DISTINCT u.id) AS total FROM qipai_users u WHERE ${where}`, params ); const [rows] = await this.pool.execute( `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( `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( `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( `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( `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( `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( `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( `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( `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( `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; }