diff --git a/backend/package.json b/backend/package.json index 106aaf5..f61038e 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-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", diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts index 76532ce..250029e 100644 --- a/backend/src/routes/members.ts +++ b/backend/src/routes/members.ts @@ -18,7 +18,7 @@ const listSchema = z.object({ }); export interface MemberRouteOptions { - service: Pick; + service: Pick; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; @@ -28,6 +28,37 @@ export async function registerMemberRoutes( app: FastifyInstance, options: MemberRouteOptions ): Promise { + 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; diff --git a/backend/src/server.ts b/backend/src/server.ts index 700850b..942b313 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => { diff --git a/backend/src/wallets/member-profile-service.ts b/backend/src/wallets/member-profile-service.ts index 7a30f02..4fb247f 100644 --- a/backend/src/wallets/member-profile-service.ts +++ b/backend/src/wallets/member-profile-service.ts @@ -137,6 +137,36 @@ export class MemberProfileService { }; } + async getMyProfile(input: { + tenantId: string; + userId: string; + ledgerLimit?: number; + }) { + 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 + 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( diff --git a/backend/tests/member-profile-route.test.mjs b/backend/tests/member-profile-route.test.mjs new file mode 100644 index 0000000..0f028f5 --- /dev/null +++ b/backend/tests/member-profile-route.test.mjs @@ -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.'); diff --git a/miniapp/app.json b/miniapp/app.json index 46c3f8e..08fafde 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -5,6 +5,7 @@ "pages/room/detail", "pages/orders/list", "pages/orders/detail", + "pages/profile/index", "pages/logs/logs" ], "window": { diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index d23a2e6..815730b 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -69,6 +69,10 @@ Page({ wx.navigateTo({ url: '/pages/orders/list' }) }, + openProfile() { + wx.navigateTo({ url: '/pages/profile/index' }) + }, + async resolveScene(code, sourceType) { this.setData({ loading: true, errorMessage: '' }) try { diff --git a/miniapp/pages/index/index.wxml b/miniapp/pages/index/index.wxml index 3f5a741..56c16e6 100644 --- a/miniapp/pages/index/index.wxml +++ b/miniapp/pages/index/index.wxml @@ -1,7 +1,10 @@ 选择门店 - + + + + diff --git a/miniapp/pages/index/index.wxss b/miniapp/pages/index/index.wxss index 8e7e0ce..be26073 100644 --- a/miniapp/pages/index/index.wxss +++ b/miniapp/pages/index/index.wxss @@ -13,6 +13,16 @@ font-weight: 600; } +.quick-actions { + display: flex; + gap: 16rpx; + margin-bottom: 20rpx; +} + +.quick-actions button { + flex: 1; +} + .city-search { display: flex; gap: 16rpx; diff --git a/miniapp/pages/profile/index.js b/miniapp/pages/profile/index.js new file mode 100644 index 0000000..67e86c7 --- /dev/null +++ b/miniapp/pages/profile/index.js @@ -0,0 +1,82 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + +Page({ + data: { + loading: false, + errorMessage: '', + profile: null, + recentLedger: [], + }, + + onLoad() { + this.loadProfile() + }, + + async loadProfile() { + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const response = await request('/profile') + const profile = formatProfile(response.data) + this.setData({ + profile, + recentLedger: (response.data.recentLedger || []).map(formatLedger), + }) + } catch (error) { + this.setData({ errorMessage: error.message || '个人中心加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + openOrders() { + wx.navigateTo({ url: '/pages/orders/list' }) + }, +}) + +function formatProfile(profile) { + return { + ...profile, + registeredText: formatDate(profile.registeredAt), + lastLoginText: profile.lastLoginAt ? formatDate(profile.lastLoginAt) : '暂无', + wallet: { + ...profile.wallet, + cashText: cents(profile.wallet.cashBalanceCents), + giftText: cents(profile.wallet.giftBalanceCents), + totalText: cents(profile.wallet.totalBalanceCents), + }, + benefits: { + ...profile.benefits, + packageAmountText: cents(profile.benefits.packageAmountCents), + }, + recharge: { + ...profile.recharge, + creditedText: cents(profile.recharge.creditedRechargeCents), + giftedText: cents(profile.recharge.giftedRechargeCents), + }, + orders: { + ...profile.orders, + paidAmountText: cents(profile.orders.paidAmountCents), + }, + } +} + +function formatLedger(item) { + return { + ...item, + deltaText: `${signedCents(item.cashDeltaCents)} / ${signedCents(item.giftDeltaCents)}`, + balanceText: `${cents(item.cashBalanceAfterCents)} / ${cents(item.giftBalanceAfterCents)}`, + createdText: formatDate(item.createdAt), + } +} + +function signedCents(value) { + const amount = Number(value || 0) + return `${amount >= 0 ? '+' : '-'}${cents(Math.abs(amount))}` +} + +function formatDate(value) { + const date = new Date(value) + const pad = (input) => String(input).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} diff --git a/miniapp/pages/profile/index.json b/miniapp/pages/profile/index.json new file mode 100644 index 0000000..a45c31d --- /dev/null +++ b/miniapp/pages/profile/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "个人中心" +} diff --git a/miniapp/pages/profile/index.wxml b/miniapp/pages/profile/index.wxml new file mode 100644 index 0000000..e3f2988 --- /dev/null +++ b/miniapp/pages/profile/index.wxml @@ -0,0 +1,88 @@ + + + 个人中心 + {{errorMessage}} + + + {{profile.nickname || '微信用户'}} + {{profile.maskedPhone || '未绑定手机号'}} + 注册:{{profile.registeredText}} + 最近登录:{{profile.lastLoginText}} + + + + + 总余额 + {{profile.wallet.totalText}} + + + 现金 {{profile.wallet.cashText}} + 赠送 {{profile.wallet.giftText}} + + + + + + {{profile.benefits.availableCoupons}} + 可用券 + + + {{profile.benefits.activePackages}} + 套餐 + + + {{profile.benefits.packageMinutes}} + 分钟 + + + {{profile.orders.orderCount}} + 订单 + + + + + 权益 + + 冻结券 + {{profile.benefits.frozenCoupons}} + + + 冻结套餐 + {{profile.benefits.frozenPackages}} + + + 套餐余额 + {{profile.benefits.packageAmountText}} + + + + + 充值与消费 + + 已充值 + {{profile.recharge.creditedText}} + + + 已赠送 + {{profile.recharge.giftedText}} + + + 已消费 + {{profile.orders.paidAmountText}} + + + + + 最近账单 + 暂无账单 + + + {{item.entryType}} + {{item.deltaText}} + + {{item.businessType}} {{item.businessId}} + 余额 {{item.balanceText}} + {{item.createdText}} + + + diff --git a/miniapp/pages/profile/index.wxss b/miniapp/pages/profile/index.wxss new file mode 100644 index 0000000..a119cf7 --- /dev/null +++ b/miniapp/pages/profile/index.wxss @@ -0,0 +1,112 @@ +.scrollarea { + height: 100vh; + background: #f5f6f8; +} + +.page { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.card, +.ledger-item { + margin-top: 20rpx; + padding: 28rpx; + border-radius: 16rpx; + background: #ffffff; +} + +.member-name, +.section-title { + font-size: 32rpx; + font-weight: 600; +} + +.muted { + margin-top: 8rpx; + color: #667085; +} + +.balance-band { + display: flex; + justify-content: space-between; + gap: 24rpx; + margin-top: 20rpx; + padding: 32rpx; + border-radius: 16rpx; + background: #174a3f; + color: #ffffff; +} + +.label, +.balance-split { + color: rgba(255, 255, 255, 0.76); +} + +.balance { + margin-top: 8rpx; + font-size: 48rpx; + font-weight: 700; +} + +.balance-split { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8rpx; + text-align: right; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12rpx; + margin-top: 20rpx; +} + +.metric { + min-height: 112rpx; + padding: 18rpx 8rpx; + border-radius: 12rpx; + background: #ffffff; + text-align: center; +} + +.metric-value { + color: #1f6feb; + font-size: 32rpx; + font-weight: 700; +} + +.metric-label { + margin-top: 6rpx; + color: #667085; + font-size: 24rpx; +} + +.row { + display: flex; + justify-content: space-between; + gap: 24rpx; + margin-top: 16rpx; +} + +.ledger-title { + margin-top: 28rpx; +} + +.empty { + padding: 80rpx 0; + color: #888888; + text-align: center; +} + +.error { + margin: 16rpx 0; + color: #c73535; +} diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs index a3aeef0..7a6309a 100644 --- a/scripts/check-miniapp-m08-a.mjs +++ b/scripts/check-miniapp-m08-a.mjs @@ -11,7 +11,8 @@ for (const page of [ 'pages/store/detail', 'pages/room/detail', 'pages/orders/list', - 'pages/orders/detail' + 'pages/orders/detail', + 'pages/profile/index' ]) { assert.ok(appJson.pages.includes(page), `${page} must be registered`); } @@ -59,4 +60,16 @@ for (const route of [ assert.match(orders, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } +const profile = read('miniapp/pages/profile/index.js') + + read('miniapp/pages/profile/index.wxml'); +for (const pattern of [ + '/profile', + 'wallet.totalText', + 'benefits.availableCoupons', + 'benefits.activePackages', + 'recentLedger' +]) { + assert.match(profile, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.');