feat(M08-A): 接入顾客端个人中心

This commit is contained in:
Codex
2026-06-25 11:43:25 +08:00
parent c7c869c18b
commit 7446852cca
14 changed files with 476 additions and 4 deletions
+1 -1
View File
@@ -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-query.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"
"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-query.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 && node tests/member-profile-route.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+32 -1
View File
@@ -18,7 +18,7 @@ const listSchema = z.object({
});
export interface MemberRouteOptions {
service: Pick<MemberProfileService, 'listMembers' | 'getMember'>;
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -28,6 +28,37 @@ export async function registerMemberRoutes(
app: FastifyInstance,
options: MemberRouteOptions
): Promise<void> {
app.get('/app-api/profile', async (request, reply) => {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
if (!access.capabilities.includes('profile.read')) {
return reply.status(403).send({
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
traceId: request.traceId
});
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMyProfile({
tenantId: auth.session.tenantId,
userId: auth.session.user.id
}),
traceId: request.traceId
}));
});
app.get('/admin-api/members', async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
+7
View File
@@ -33,6 +33,7 @@ import { CustomerDeviceAccessRepository } from './devices/customer-device-access
import { IotMessageService } from './devices/iot-message-service.js';
import { DeviceCommandService } from './devices/device-command-service.js';
import { DeviceControlService } from './devices/device-control-service.js';
import { MemberProfileService } from './wallets/member-profile-service.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -162,6 +163,12 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
members: {
service: new MemberProfileService(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
@@ -137,6 +137,36 @@ export class MemberProfileService {
};
}
async getMyProfile(input: {
tenantId: string;
userId: string;
ledgerLimit?: number;
}) {
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
LIMIT 1`,
[input.tenantId, input.userId]
);
if (!rows[0]) throw new MemberProfileError('MEMBER_NOT_FOUND');
const actor = {
tenantId: input.tenantId,
userId: input.userId,
access: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] }
};
return {
...await this.memberCard(actor, rows[0]),
recentLedger: await this.recentLedger(
input.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[]>(
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-member-profile-route-secret';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const forbiddenToken = signAccessToken({
sub: '22', sid: '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let profileInput;
const app = await buildApp({
members: {
jwtSecret: secret,
authRepository: {
async validateSession(sessionId) {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22' : '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
},
accessControl: {
async getAccessProfile(tenantId, userId) {
return 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 getMyProfile(input) {
profileInput = input;
return {
memberId: input.userId,
nickname: 'Alice',
maskedPhone: '138****8000',
wallet: { cashBalanceCents: 1000, giftBalanceCents: 200, totalBalanceCents: 1200 },
benefits: { availableCoupons: 2, activePackages: 1, packageMinutes: 90 },
recentLedger: []
};
}
}
}
});
const profile = await app.inject({
method: 'GET',
url: '/app-api/profile',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(profile.statusCode, 200);
assert.equal(profileInput.tenantId, '7');
assert.equal(profileInput.userId, '21');
assert.equal(profile.json().data.wallet.totalBalanceCents, 1200);
const unauthorized = await app.inject({
method: 'GET',
url: '/app-api/profile'
});
assert.equal(unauthorized.statusCode, 401);
const forbidden = await app.inject({
method: 'GET',
url: '/app-api/profile',
headers: { authorization: `Bearer ${forbiddenToken}` }
});
assert.equal(forbidden.statusCode, 403);
await app.close();
console.log('PASS: M08-A customer profile route exposes only the current member.');