feat(M07-D): 完成会员管理聚合查询
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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 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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user