feat(M08-C): 补会员检索与员工概况

This commit is contained in:
Codex
2026-08-10 10:35:44 +08:00
parent 8b46863cd8
commit 1b0839e881
22 changed files with 758 additions and 74 deletions
@@ -72,6 +72,7 @@ export class UserManagementRepository {
status?: 'ACTIVE' | 'DISABLED';
role?: AssignableRole;
search?: string;
userType?: 'STAFF';
}): Promise<{ items: ManagedUser[]; total: number }> {
const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL'];
const params: Array<string | number> = [input.actor.tenantId];
@@ -79,6 +80,10 @@ export class UserManagementRepository {
filters.push('u.status = ?');
params.push(input.status);
}
if (input.userType) {
filters.push('u.user_type = ?');
params.push(input.userType);
}
if (input.role) {
filters.push(`EXISTS (
SELECT 1 FROM qipai_user_roles fur
@@ -102,6 +107,8 @@ export class UserManagementRepository {
WHERE ${filters.join(' AND ')}`,
params
);
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
const [rows] = await this.pool.execute<UserRow[]>(
`SELECT u.id, u.user_type AS userType, u.status, u.nickname,
u.avatar_url AS avatarUrl, u.phone,
@@ -119,8 +126,8 @@ export class UserManagementRepository {
LEFT JOIN qipai_user_admin_profiles p
ON p.tenant_id = u.tenant_id AND p.user_id = u.id
WHERE ${filters.join(' AND ')}
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
[...params, input.pageSize, (input.page - 1) * input.pageSize]
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
params
);
const items = await Promise.all(rows.map(async (row) => ({
id: String(row.id),
@@ -517,7 +517,8 @@ export class CleaningTaskRepository {
params.push(input.cleanerUserId);
}
const whereSql = where.join(' AND ');
const offset = (input.page - 1) * input.pageSize;
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
params
@@ -538,14 +539,14 @@ export class CleaningTaskRepository {
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
WHERE ${whereSql}
ORDER BY s.created_at DESC, s.id DESC
LIMIT ? OFFSET ?`,
[...params, input.pageSize, offset]
LIMIT ${pageSize} OFFSET ${offset}`,
params
);
return {
items: rows.map(publicSettlement),
total: Number(counts[0]?.total ?? 0),
page: input.page,
pageSize: input.pageSize
pageSize
};
}
@@ -1046,7 +1047,8 @@ export class CleaningTaskRepository {
orderBy: string
) {
const whereSql = where.join(' AND ');
const offset = (page - 1) * pageSize;
const safePageSize = Math.max(1, Math.min(100, Math.trunc(pageSize)));
const offset = Math.max(0, (Math.trunc(page) - 1) * safePageSize);
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`,
params
@@ -1069,10 +1071,13 @@ export class CleaningTaskRepository {
LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id
WHERE ${whereSql}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
LIMIT ${safePageSize} OFFSET ${offset}`,
params
);
return { items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), page, pageSize };
return {
items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0),
page, pageSize: safePageSize
};
}
private async getMineTask(input: CleaningActor, taskId: string) {
+5 -4
View File
@@ -58,7 +58,8 @@ export class OrderQueryRepository {
params.push(input.storeId);
}
const whereSql = where.join(' AND ');
const offset = (input.page - 1) * input.pageSize;
const pageSize = Math.max(1, Math.min(50, Math.trunc(input.pageSize)));
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT o.id) AS total
FROM qipai_orders o
@@ -89,14 +90,14 @@ export class OrderQueryRepository {
)
WHERE ${whereSql}
ORDER BY o.created_at DESC, o.id DESC
LIMIT ? OFFSET ?`,
[...params, input.pageSize, offset]
LIMIT ${pageSize} OFFSET ${offset}`,
params
);
return {
items: rows.map(publicOrder),
total: Number(counts[0]?.total ?? 0),
page: input.page,
pageSize: input.pageSize
pageSize
};
}
+26 -22
View File
@@ -54,29 +54,33 @@ export async function registerMemberRoutes(
}));
});
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
}));
});
for (const path of ['/admin-api/members', '/app-api/management/members']) {
app.get(path, 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
}));
});
for (const path of ['/admin-api/members/:id', '/app-api/management/members/:id']) {
app.get(path, 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 requireProfileReader(
+17 -8
View File
@@ -47,14 +47,23 @@ export async function registerUserManagementRoutes(
app: FastifyInstance,
options: UserManagementRouteOptions
): Promise<void> {
app.get('/admin-api/users', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
const data = await options.repository.listUsers({ actor, ...query.data });
return { code: 0, data, traceId: request.traceId };
});
for (const [path, staffOnly] of [
['/admin-api/users', false],
['/app-api/management/staff', true]
] as const) {
app.get(path, async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
const data = await options.repository.listUsers({
actor,
...query.data,
...(staffOnly ? { userType: 'STAFF' as const } : {})
});
return { code: 0, data, traceId: request.traceId };
});
}
app.post('/admin-api/staff', async (request, reply) => {
const actor = await requireManager(request, reply, options);
+16 -7
View File
@@ -138,13 +138,15 @@ export class MemberProfileService {
WHERE ${where}`,
params
);
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
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]
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
params
);
const items = await Promise.all(rows.map((row) => this.memberCard(input.actor, row)));
return { items, total: Number(counts[0]?.total ?? 0) };
@@ -341,9 +343,9 @@ export class MemberProfileService {
);
const [orderRows] = await this.pool.execute<OrderSummaryRow[]>(
`SELECT COUNT(*) AS orderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
THEN 1 ELSE 0 END), 0) AS paidOrderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
THEN o.paid_amount_cents ELSE 0 END), 0) AS paidAmountCents,
MAX(o.created_at) AS lastOrderAt
FROM qipai_orders o
@@ -433,12 +435,19 @@ function memberScopeClause(actor: MemberActor, userExpression: string) {
}
const placeholders = actor.access.storeIds.map(() => '?').join(',');
return {
sql: `EXISTS (
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
) OR EXISTS (
SELECT 1 FROM qipai_order_user_access oa
INNER JOIN qipai_orders mo
ON mo.tenant_id = oa.tenant_id AND mo.id = oa.order_id
AND mo.deleted_at IS NULL
WHERE oa.tenant_id = u.tenant_id AND oa.user_id = ${userExpression}
AND oa.access_type = 'OWNER' AND mo.store_id IN (${placeholders})
))`,
params: [...actor.access.storeIds, ...actor.access.storeIds]
};
}
+52 -4
View File
@@ -11,9 +11,15 @@ const forbiddenToken = signAccessToken({
sub: '22', sid: '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const managerToken = signAccessToken({
sub: '23', sid: '7c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let profileInput;
let benefitsInput;
let memberListInput;
let memberDetailInput;
const app = await buildApp({
members: {
jwtSecret: secret,
@@ -25,7 +31,8 @@ const app = await buildApp({
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22' : '21',
id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22'
: sessionId === '7c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '23' : '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
@@ -39,14 +46,28 @@ const app = await buildApp({
},
accessControl: {
async getAccessProfile(tenantId, userId) {
return userId === '21'
return userId === '23'
? { roles: ['STORE_ADMIN'], capabilities: ['user.read'], storeIds: ['11'] }
: userId === '21'
? { roles: ['CUSTOMER'], capabilities: ['profile.read', 'order.self.read'], storeIds: [] }
: { roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] };
}
},
service: {
async listMembers() { throw new Error('not called'); },
async getMember() { throw new Error('not called'); },
async listMembers(input) {
memberListInput = input;
return {
items: [{
memberId: '31', nickname: '门店会员', maskedPhone: '138****8000',
wallet: { totalBalanceCents: 1200 }, orders: { orderCount: 2 }
}],
total: 1
};
},
async getMember(input) {
memberDetailInput = input;
return { memberId: input.memberId, nickname: '门店会员', recentLedger: [] };
},
async getMyBenefits(input) {
benefitsInput = input;
return {
@@ -100,6 +121,33 @@ assert.equal(benefitsInput.userId, '21');
assert.equal(benefits.json().data.coupons[0].couponGrantId, '401');
assert.equal(benefits.json().data.packages[0].packageHoldingId, '501');
const members = await app.inject({
method: 'GET',
url: '/app-api/management/members?page=1&pageSize=20&status=ACTIVE&search=138',
headers: { authorization: `Bearer ${managerToken}` }
});
assert.equal(members.statusCode, 200);
assert.equal(memberListInput.actor.userId, '23');
assert.deepEqual(memberListInput.actor.access.storeIds, ['11']);
assert.equal(memberListInput.status, 'ACTIVE');
assert.equal(memberListInput.search, '138');
assert.equal(members.json().data.items[0].memberId, '31');
const memberDetail = await app.inject({
method: 'GET',
url: '/app-api/management/members/31',
headers: { authorization: `Bearer ${managerToken}` }
});
assert.equal(memberDetail.statusCode, 200);
assert.equal(memberDetailInput.memberId, '31');
const memberForbidden = await app.inject({
method: 'GET',
url: '/app-api/management/members',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(memberForbidden.statusCode, 403);
const unauthorized = await app.inject({
method: 'GET',
url: '/app-api/profile'
+23 -9
View File
@@ -210,7 +210,8 @@ const orders = [
id: '301',
tenantId: '7',
userId: '21',
status: 'COMPLETED',
storeId: '11',
status: 'FINISHED',
paidAmountCents: 12_000,
createdAt: new Date('2026-06-23T00:00:00.000Z')
},
@@ -218,9 +219,19 @@ const orders = [
id: '302',
tenantId: '7',
userId: '21',
storeId: '11',
status: 'CANCELLED',
paidAmountCents: 0,
createdAt: new Date('2026-06-24T00:00:00.000Z')
},
{
id: '303',
tenantId: '7',
userId: '22',
storeId: '11',
status: 'CANCELLED',
paidAmountCents: 0,
createdAt: new Date('2026-06-25T00:00:00.000Z')
}
];
@@ -335,7 +346,7 @@ function createService() {
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));
const paid = rows.filter((item) => ['PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED'].includes(item.status));
return [[{
orderCount: rows.length,
paidOrderCount: paid.length,
@@ -379,11 +390,17 @@ function queryMembers(sql, params) {
if (user.tenantId !== tenantId || user.userType !== 'CUSTOMER' || user.deleted) return false;
if (statusFilter && user.status !== statusFilter) return false;
if (hasStoreScope) {
return wallets.some((wallet) =>
const walletMatch = wallets.some((wallet) =>
wallet.tenantId === tenantId
&& wallet.userId === user.id
&& storeParams.includes(wallet.storeId)
);
const orderMatch = orders.some((order) =>
order.tenantId === tenantId
&& order.userId === user.id
&& storeParams.includes(order.storeId)
);
return walletMatch || orderMatch;
}
return true;
}).map((user) => ({
@@ -458,12 +475,9 @@ const forbiddenReader = {
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'
);
assert.equal(scoped.total, 2);
assert.equal(scoped.items[0].memberId, '22');
assert.equal((await service.getMember({ actor: storeReader, memberId: '22' })).memberId, '22');
}
{
@@ -37,6 +37,7 @@ import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import { DeviceRepository } from '../dist/devices/device-repository.js';
import { IotMessageService } from '../dist/devices/iot-message-service.js';
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -708,6 +709,25 @@ async function assertPricingAndReservations(pool, context) {
assert.equal(managedAudit[0].action, 'ORDER_CREATED_ON_BEHALF');
assert.equal(String(JSON.parse(managedAudit[0].metadata).targetUserId), customerId);
const scopedMembers = await new MemberProfileService(pool).listMembers({
actor: {
tenantId: context.tenantId,
userId: adminId,
access: {
roles: ['STORE_ADMIN'],
capabilities: ['user.read'],
storeIds: [String(targetRows[0].storeId)]
}
},
page: 1,
pageSize: 20,
status: 'ACTIVE',
search: customerId
});
assert.equal(scopedMembers.total, 1);
assert.equal(scopedMembers.items[0].memberId, customerId);
assert.ok(scopedMembers.items[0].orders.orderCount >= 1);
const [foreignUser] = await pool.query(
`INSERT INTO qipai_users (tenant_id, user_type, status)
VALUES (?, 'CUSTOMER', 'ACTIVE')`,
@@ -2000,6 +2020,7 @@ try {
'store-scoped on-behalf booking rejection',
'manager on-behalf booking history and audit attribution',
'cross-tenant on-behalf target user rejection',
'store-scoped member discovery through historical owner orders',
'share token stored as SHA-256 only',
'default view and door permissions',
'renew permission denied by default',
+12
View File
@@ -115,6 +115,18 @@ assert.equal(listedInput.role, 'CLEANER');
assert.equal(listedInput.status, 'ACTIVE');
assert.equal(listedInput.search, '保洁');
const miniappStaff = await app.inject({
method: 'GET',
url: '/app-api/management/staff?page=1&pageSize=20&status=ACTIVE&search=%E5%A4%9C%E7%8F%AD',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(miniappStaff.statusCode, 200);
assert.equal(listedInput.page, 1);
assert.equal(listedInput.pageSize, 20);
assert.equal(listedInput.status, 'ACTIVE');
assert.equal(listedInput.search, '夜班');
assert.equal(listedInput.userType, 'STAFF');
const updated = await app.inject({
method: 'PATCH',
url: '/admin-api/users/22',