feat(M07-D): 完成会员管理聚合查询

This commit is contained in:
Codex
2026-06-24 15:58:41 +08:00
parent 239ef4dcf1
commit ed0d455083
6 changed files with 772 additions and 5 deletions
@@ -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;
}