478 lines
17 KiB
TypeScript
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;
|
|
}
|