feat(M07-D): 完成会员管理聚合查询
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MemberProfileError,
|
||||
MemberProfileService
|
||||
} from '../dist/wallets/member-profile-service.js';
|
||||
|
||||
const customers = [
|
||||
{
|
||||
id: '21',
|
||||
tenantId: '7',
|
||||
userType: 'CUSTOMER',
|
||||
status: 'ACTIVE',
|
||||
nickname: 'Alice',
|
||||
phone: '13800138000',
|
||||
createdAt: new Date('2026-06-01T00:00:00.000Z'),
|
||||
lastLoginAt: new Date('2026-06-20T00:00:00.000Z'),
|
||||
deleted: false
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
tenantId: '7',
|
||||
userType: 'CUSTOMER',
|
||||
status: 'ACTIVE',
|
||||
nickname: 'Bob',
|
||||
phone: '13900139000',
|
||||
createdAt: new Date('2026-06-02T00:00:00.000Z'),
|
||||
lastLoginAt: null,
|
||||
deleted: false
|
||||
},
|
||||
{
|
||||
id: '23',
|
||||
tenantId: '8',
|
||||
userType: 'CUSTOMER',
|
||||
status: 'ACTIVE',
|
||||
nickname: 'OtherTenant',
|
||||
phone: '13700137000',
|
||||
createdAt: new Date('2026-06-03T00:00:00.000Z'),
|
||||
lastLoginAt: null,
|
||||
deleted: false
|
||||
}
|
||||
];
|
||||
|
||||
const wallets = [
|
||||
{ tenantId: '7', userId: '21', storeId: '11', status: 'ACTIVE', cash: 10_000, gift: 2_000 },
|
||||
{ tenantId: '7', userId: '21', storeId: null, status: 'ACTIVE', cash: 5_000, gift: 500 },
|
||||
{ tenantId: '7', userId: '22', storeId: '12', status: 'ACTIVE', cash: 1_000, gift: 0 }
|
||||
];
|
||||
|
||||
const coupons = [
|
||||
{ tenantId: '7', userId: '21', status: 'AVAILABLE' },
|
||||
{ tenantId: '7', userId: '21', status: 'FROZEN' },
|
||||
{ tenantId: '7', userId: '22', status: 'USED' }
|
||||
];
|
||||
|
||||
const packages = [
|
||||
{
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
status: 'ACTIVE',
|
||||
remainingMinutes: 120,
|
||||
remainingAmountCents: 3_000
|
||||
},
|
||||
{
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
status: 'FROZEN',
|
||||
remainingMinutes: 60,
|
||||
remainingAmountCents: 2_000
|
||||
}
|
||||
];
|
||||
|
||||
const recharges = [
|
||||
{
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
status: 'CREDITED',
|
||||
payAmountCents: 10_000,
|
||||
giftAmountCents: 2_000,
|
||||
creditedAt: new Date('2026-06-21T00:00:00.000Z')
|
||||
},
|
||||
{
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
status: 'PENDING_PAYMENT',
|
||||
payAmountCents: 5_000,
|
||||
giftAmountCents: 500,
|
||||
creditedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
const ledger = [
|
||||
{
|
||||
id: '1002',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
storeId: '11',
|
||||
businessType: 'ORDER',
|
||||
businessId: 'order-1',
|
||||
entryType: 'CONSUME',
|
||||
cashDeltaCents: -200,
|
||||
giftDeltaCents: -300,
|
||||
cashBalanceAfterCents: 14_800,
|
||||
giftBalanceAfterCents: 2_200,
|
||||
createdAt: new Date('2026-06-22T00:00:00.000Z')
|
||||
},
|
||||
{
|
||||
id: '1001',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
storeId: '11',
|
||||
businessType: 'RECHARGE',
|
||||
businessId: 'recharge-1',
|
||||
entryType: 'RECHARGE',
|
||||
cashDeltaCents: 10_000,
|
||||
giftDeltaCents: 2_000,
|
||||
cashBalanceAfterCents: 15_000,
|
||||
giftBalanceAfterCents: 2_500,
|
||||
createdAt: new Date('2026-06-21T00:00:00.000Z')
|
||||
}
|
||||
];
|
||||
|
||||
const orders = [
|
||||
{
|
||||
id: '301',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
status: 'COMPLETED',
|
||||
paidAmountCents: 12_000,
|
||||
createdAt: new Date('2026-06-23T00:00:00.000Z')
|
||||
},
|
||||
{
|
||||
id: '302',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
status: 'CANCELLED',
|
||||
paidAmountCents: 0,
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z')
|
||||
}
|
||||
];
|
||||
|
||||
function createService() {
|
||||
const pool = {
|
||||
async execute(sql, params) {
|
||||
if (sql.includes('COUNT(DISTINCT u.id)')) {
|
||||
return [[{ total: queryMembers(sql, params).length }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_users u') && sql.includes('ORDER BY u.id DESC')) {
|
||||
return [queryMembers(sql, params).sort((a, b) => Number(b.id) - Number(a.id)), []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_users u') && sql.includes('LIMIT 1')) {
|
||||
const found = queryMembers(sql, params).find((user) => user.id === String(params[1]));
|
||||
return [found ? [found] : [], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_wallet_accounts')) {
|
||||
const tenantId = String(params[0]);
|
||||
const userId = String(params[1]);
|
||||
const rows = wallets.filter((item) =>
|
||||
item.tenantId === tenantId && item.userId === userId && item.status === 'ACTIVE'
|
||||
);
|
||||
return [[{
|
||||
accountCount: rows.length,
|
||||
cashBalanceCents: rows.reduce((sum, item) => sum + item.cash, 0),
|
||||
giftBalanceCents: rows.reduce((sum, item) => sum + item.gift, 0)
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_coupon_grants c')) {
|
||||
const tenantId = String(params[8]);
|
||||
const userId = String(params[9]);
|
||||
const memberCoupons = coupons.filter((item) =>
|
||||
item.tenantId === tenantId && item.userId === userId
|
||||
);
|
||||
const memberPackages = packages.filter((item) =>
|
||||
item.tenantId === tenantId && item.userId === userId
|
||||
);
|
||||
return [[{
|
||||
availableCoupons: memberCoupons.filter((item) => item.status === 'AVAILABLE').length,
|
||||
frozenCoupons: memberCoupons.filter((item) => item.status === 'FROZEN').length,
|
||||
activePackages: memberPackages.filter((item) => item.status === 'ACTIVE').length,
|
||||
frozenPackages: memberPackages.filter((item) => item.status === 'FROZEN').length,
|
||||
packageMinutes: memberPackages
|
||||
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
|
||||
.reduce((sum, item) => sum + item.remainingMinutes, 0),
|
||||
packageAmountCents: memberPackages
|
||||
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
|
||||
.reduce((sum, item) => sum + item.remainingAmountCents, 0)
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_recharge_orders')) {
|
||||
const tenantId = String(params[0]);
|
||||
const userId = String(params[1]);
|
||||
const rows = recharges.filter((item) => item.tenantId === tenantId && item.userId === userId);
|
||||
const credited = rows.filter((item) => item.status === 'CREDITED');
|
||||
return [[{
|
||||
rechargeOrderCount: rows.length,
|
||||
creditedRechargeCount: credited.length,
|
||||
creditedRechargeCents: credited.reduce((sum, item) => sum + item.payAmountCents, 0),
|
||||
giftedRechargeCents: credited.reduce((sum, item) => sum + item.giftAmountCents, 0),
|
||||
lastRechargeAt: credited[0]?.creditedAt ?? null
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_orders o')) {
|
||||
const userId = String(params[0]);
|
||||
const tenantId = String(params[1]);
|
||||
const rows = orders.filter((item) => item.tenantId === tenantId && item.userId === userId);
|
||||
const paid = rows.filter((item) => ['PAID', 'IN_USE', 'COMPLETED'].includes(item.status));
|
||||
return [[{
|
||||
orderCount: rows.length,
|
||||
paidOrderCount: paid.length,
|
||||
paidAmountCents: paid.reduce((sum, item) => sum + item.paidAmountCents, 0),
|
||||
lastOrderAt: rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]?.createdAt ?? null
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_wallet_ledger_entries')) {
|
||||
const rows = ledger
|
||||
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
|
||||
.sort((a, b) => Number(b.id) - Number(a.id))
|
||||
.slice(0, Number(params[2]));
|
||||
return [rows.map((item) => ({
|
||||
id: item.id,
|
||||
storeId: item.storeId,
|
||||
businessType: item.businessType,
|
||||
businessId: item.businessId,
|
||||
entryType: item.entryType,
|
||||
cashDeltaCents: item.cashDeltaCents,
|
||||
giftDeltaCents: item.giftDeltaCents,
|
||||
cashBalanceAfterCents: item.cashBalanceAfterCents,
|
||||
giftBalanceAfterCents: item.giftBalanceAfterCents,
|
||||
createdAt: item.createdAt
|
||||
})), []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
return new MemberProfileService(pool);
|
||||
}
|
||||
|
||||
function queryMembers(sql, params) {
|
||||
if (sql.includes('1 = 0')) return [];
|
||||
const tenantId = String(params[0]);
|
||||
const statusFilter = sql.includes('u.status = ?') ? params[1] : null;
|
||||
const hasStoreScope = sql.includes('qipai_wallet_accounts wa');
|
||||
const storeParams = hasStoreScope
|
||||
? params.slice(statusFilter ? 2 : 1).filter((value) => /^\d+$/.test(String(value)))
|
||||
: [];
|
||||
return customers.filter((user) => {
|
||||
if (user.tenantId !== tenantId || user.userType !== 'CUSTOMER' || user.deleted) return false;
|
||||
if (statusFilter && user.status !== statusFilter) return false;
|
||||
if (hasStoreScope) {
|
||||
return wallets.some((wallet) =>
|
||||
wallet.tenantId === tenantId
|
||||
&& wallet.userId === user.id
|
||||
&& storeParams.includes(wallet.storeId)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}).map((user) => ({
|
||||
id: user.id,
|
||||
status: user.status,
|
||||
nickname: user.nickname,
|
||||
phone: user.phone,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt
|
||||
}));
|
||||
}
|
||||
|
||||
const tenantAdmin = {
|
||||
tenantId: '7',
|
||||
userId: '1',
|
||||
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }
|
||||
};
|
||||
const storeReader = {
|
||||
tenantId: '7',
|
||||
userId: '2',
|
||||
access: { roles: ['STORE_ADMIN'], capabilities: ['user.read'], storeIds: ['11'] }
|
||||
};
|
||||
const forbiddenReader = {
|
||||
tenantId: '7',
|
||||
userId: '3',
|
||||
access: { roles: ['STAFF'], capabilities: ['user.read'], storeIds: [] }
|
||||
};
|
||||
|
||||
{
|
||||
const service = createService();
|
||||
const list = await service.listMembers({
|
||||
actor: tenantAdmin,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
assert.equal(list.total, 2);
|
||||
assert.equal(list.items[0].memberId, '22');
|
||||
assert.equal(list.items[1].wallet.totalBalanceCents, 17_500);
|
||||
assert.equal(list.items[1].benefits.availableCoupons, 1);
|
||||
assert.equal(list.items[1].benefits.frozenPackages, 1);
|
||||
assert.equal(list.items[1].recharge.creditedRechargeCents, 10_000);
|
||||
assert.equal(list.items[1].orders.orderCount, 2);
|
||||
assert.equal(list.items[1].orders.paidAmountCents, 12_000);
|
||||
assert.equal(list.items[1].maskedPhone, '138****8000');
|
||||
}
|
||||
|
||||
{
|
||||
const service = createService();
|
||||
const member = await service.getMember({ actor: tenantAdmin, memberId: '21' });
|
||||
assert.equal(member.recentLedger.length, 2);
|
||||
assert.equal(member.recentLedger[0].ledgerId, '1002');
|
||||
assert.equal(member.wallet.cashBalanceCents, 15_000);
|
||||
assert.equal(member.benefits.packageMinutes, 180);
|
||||
}
|
||||
|
||||
{
|
||||
const service = createService();
|
||||
const scoped = await service.listMembers({
|
||||
actor: storeReader,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
assert.equal(scoped.total, 1);
|
||||
assert.equal(scoped.items[0].memberId, '21');
|
||||
await assert.rejects(
|
||||
() => service.getMember({ actor: storeReader, memberId: '22' }),
|
||||
(error) => error instanceof MemberProfileError && error.code === 'MEMBER_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const service = createService();
|
||||
const scoped = await service.listMembers({
|
||||
actor: forbiddenReader,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
assert.equal(scoped.total, 0);
|
||||
}
|
||||
|
||||
console.log('PASS: M07-D member profiles aggregate wallets, benefits, recharge and scoped access.');
|
||||
Reference in New Issue
Block a user