feat(M07-D): 完成会员管理聚合查询
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<FastifyIn
|
||||
if (options.deviceControl) {
|
||||
await registerDeviceControlRoutes(app, options.deviceControl);
|
||||
}
|
||||
if (options.members) {
|
||||
await registerMemberRoutes(app, options.members);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ interface HardwareSmokeConfig {
|
||||
allowActions: boolean;
|
||||
mqttUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
mqttPassword: string;
|
||||
controlBoxDeviceId: string;
|
||||
smartSocketDeviceId: string;
|
||||
subLockSubId: string;
|
||||
@@ -45,7 +45,7 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
|
||||
allowActions: isTrue(env.QIPAI_HARDWARE_SMOKE_ALLOW_ACTIONS),
|
||||
mqttUrl: env.QIPAI_HARDWARE_MQTT_URL ?? '',
|
||||
username: env.QIPAI_HARDWARE_MQTT_USERNAME ?? '',
|
||||
password: env.QIPAI_HARDWARE_MQTT_PASSWORD ?? '',
|
||||
mqttPassword: env.QIPAI_HARDWARE_MQTT_PASSWORD ?? '',
|
||||
controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '',
|
||||
smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '',
|
||||
subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '',
|
||||
@@ -152,7 +152,7 @@ export async function runHardwareSmoke(
|
||||
commandId: readCommandId(item.payload)
|
||||
}));
|
||||
}
|
||||
if (!config.mqttUrl || !config.username || !config.password) {
|
||||
if (!config.mqttUrl || !config.username || !config.mqttPassword) {
|
||||
return [{ caseId: 'hardware-smoke-auth', status: 'SKIP', reason: 'MQTT credentials are not configured.' }];
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ async function connectMqtt(config: HardwareSmokeConfig) {
|
||||
return new Promise<MqttClient>((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,
|
||||
|
||||
@@ -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<MemberProfileService, 'listMembers' | 'getMember'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerMemberRoutes(
|
||||
app: FastifyInstance,
|
||||
options: MemberRouteOptions
|
||||
): Promise<void> {
|
||||
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<MemberActor | null> {
|
||||
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<unknown>) {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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