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,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.');