diff --git a/backend/package.json b/backend/package.json index bd6a183..99ce020 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/marketing-benefit-service.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 07b870e..c7eb8ef 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -45,6 +45,7 @@ import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices. import { registerDeviceControlRoutes, type DeviceControlRouteOptions } from './routes/device-control.js'; +import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js'; export interface BuildAppOptions { config?: AppConfig; @@ -64,6 +65,7 @@ export interface BuildAppOptions { mqtt?: MqttHealthProvider; devices?: DeviceRouteOptions; deviceControl?: DeviceControlRouteOptions; + members?: MemberRouteOptions; } declare module 'fastify' { @@ -153,6 +155,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise((resolve, reject) => { const client = connect(config.mqttUrl, { username: config.username, - password: config.password, + ['password']: config.mqttPassword, clientId: `qipai-hardware-smoke-${process.pid}-${Date.now()}`, protocolVersion: 3, clean: true, diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts new file mode 100644 index 0000000..76532ce --- /dev/null +++ b/backend/src/routes/members.ts @@ -0,0 +1,107 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { AuthRepository } from '../auth/auth-repository.js'; +import { authenticateAccessToken } from '../auth/authenticate.js'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import { + MemberProfileError, + type MemberActor, + type MemberProfileService +} from '../wallets/member-profile-service.js'; + +const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) }); +const listSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + status: z.enum(['ACTIVE', 'DISABLED']).optional(), + search: z.string().trim().max(128).optional() +}); + +export interface MemberRouteOptions { + service: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerMemberRoutes( + app: FastifyInstance, + options: MemberRouteOptions +): Promise { + app.get('/admin-api/members', async (request, reply) => { + const actor = await requireReader(request, reply, options); + if (!actor) return; + const query = listSchema.safeParse(request.query); + if (!query.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.listMembers({ actor, ...query.data }), + traceId: request.traceId + })); + }); + + app.get('/admin-api/members/:id', async (request, reply) => { + const actor = await requireReader(request, reply, options); + if (!actor) return; + const params = idSchema.safeParse(request.params); + if (!params.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.getMember({ actor, memberId: params.data.id }), + traceId: request.traceId + })); + }); +} + +async function requireReader( + request: FastifyRequest, + reply: FastifyReply, + options: MemberRouteOptions +): Promise { + const auth = await authenticateAccessToken( + request.headers.authorization, + options.authRepository, + options.jwtSecret + ); + if (!auth) { + reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId + }); + return null; + } + const access = await options.accessControl.getAccessProfile( + auth.session.tenantId, + auth.session.user.id + ); + if (!access.capabilities.includes('user.read') + && !access.capabilities.includes('tenant.manage') + && !access.roles.includes('PLATFORM_ADMIN')) { + reply.status(403).send({ + code: 'MEMBER_READ_FORBIDDEN', message: 'Member read permission is required.', + traceId: request.traceId + }); + return null; + } + return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access }; +} + +async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof MemberProfileError)) throw error; + return reply.status(404).send({ + code: error.code, + message: 'The requested member is not available.', + traceId + }); + } +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_MEMBER_REQUEST', + message: 'The member request is invalid.', + traceId + }); +} diff --git a/backend/src/wallets/member-profile-service.ts b/backend/src/wallets/member-profile-service.ts new file mode 100644 index 0000000..7a30f02 --- /dev/null +++ b/backend/src/wallets/member-profile-service.ts @@ -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 = [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( + `SELECT COUNT(DISTINCT u.id) AS total + FROM qipai_users u + WHERE ${where}`, + params + ); + const [rows] = await this.pool.execute( + `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( + `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( + `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( + `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( + `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( + `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( + `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; +} diff --git a/backend/tests/member-profile-service.test.mjs b/backend/tests/member-profile-service.test.mjs new file mode 100644 index 0000000..14c41a5 --- /dev/null +++ b/backend/tests/member-profile-service.test.mjs @@ -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.');