diff --git a/backend/package.json b/backend/package.json index f61038e..fe01c08 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 && node tests/member-profile-route.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/recharge-route.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/app.ts b/backend/src/app.ts index 992494f..cae8f77 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -49,6 +49,7 @@ import { registerDeviceControlRoutes, type DeviceControlRouteOptions } from './routes/device-control.js'; import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js'; +import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js'; export interface BuildAppOptions { config?: AppConfig; @@ -70,6 +71,7 @@ export interface BuildAppOptions { devices?: DeviceRouteOptions; deviceControl?: DeviceControlRouteOptions; members?: MemberRouteOptions; + recharge?: RechargeRouteOptions; } declare module 'fastify' { @@ -165,6 +167,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerRechargeRoutes( + app: FastifyInstance, + options: RechargeRouteOptions +) { + app.get('/app-api/recharge/plans', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const query = listSchema.safeParse(request.query); + if (!auth) return unauthorized(reply, request.traceId); + if (!query.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.listAvailablePlans({ + tenantId: auth.tenantId, + storeId: query.data.storeId ?? null + }), + traceId: request.traceId + })); + }); + + app.post('/app-api/recharge/orders', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const body = createSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => reply.status(201).send({ + code: 0, + data: await options.service.createRechargeOrder({ + tenantId: auth.tenantId, + userId: auth.userId, + planId: body.data.planId, + storeId: body.data.storeId ?? null, + clientRequestId: body.data.clientRequestId, + traceId: request.traceId + }), + traceId: request.traceId + })); + }); +} + +async function authenticate( + authorization: string | undefined, + options: RechargeRouteOptions +) { + const result = await authenticateAccessToken( + authorization, + options.authRepository, + options.jwtSecret + ); + if (!result) return null; + const access = await options.accessControl.getAccessProfile( + result.session.tenantId, + result.session.user.id + ); + if (!access.capabilities.includes('profile.read')) return null; + return { + tenantId: result.session.tenantId, + userId: result.session.user.id + }; +} + +async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof RechargeError)) throw error; + const status = error.code === 'RECHARGE_PLAN_NOT_FOUND' + || error.code === 'RECHARGE_ORDER_NOT_FOUND' + ? 404 + : error.code === 'RECHARGE_LIMIT_REACHED' ? 409 : 400; + return reply.status(status).send({ + code: error.code, + message: 'The recharge request is not available.', + traceId + }); + } +} + +function unauthorized(reply: FastifyReply, traceId: string) { + return reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', + message: 'Authentication required.', + traceId + }); +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_RECHARGE_REQUEST', + message: 'The recharge request is invalid.', + traceId + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 942b313..b434ebe 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -34,6 +34,8 @@ 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'; +import { RechargeService } from './wallets/recharge-service.js'; +import { WalletLedgerService } from './wallets/wallet-ledger-service.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -41,6 +43,7 @@ const authRepository = new AuthRepository(pool); const accessControl = new RbacRepository(pool); const orderManagementRepository = new OrderManagementRepository(pool); const paymentRepository = new PaymentRepository(pool); +const walletLedgerService = new WalletLedgerService(pool); const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson); const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport()); const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson); @@ -169,6 +172,12 @@ const app = await buildApp({ authRepository, accessControl, jwtSecret: config.auth.jwtSecret + }, + recharge: { + service: new RechargeService(pool, walletLedgerService), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/src/wallets/recharge-service.ts b/backend/src/wallets/recharge-service.ts index 775596a..84b9196 100644 --- a/backend/src/wallets/recharge-service.ts +++ b/backend/src/wallets/recharge-service.ts @@ -40,6 +40,43 @@ export class RechargeService { private readonly wallet: Pick ) {} + async listAvailablePlans(input: { + tenantId: string; + storeId?: string | null; + }) { + const params: Array = [input.tenantId]; + const storeFilter = input.storeId + ? 'AND (store_id IS NULL OR store_id = ?)' + : ''; + if (input.storeId) params.push(input.storeId); + const [rows] = await this.pool.execute( + `SELECT id, tenant_id AS tenantId, store_id AS storeId, name, + pay_amount_cents AS payAmountCents, + gift_amount_cents AS giftAmountCents, + scope_type AS scopeType, starts_at AS startsAt, ends_at AS endsAt, + purchase_limit_per_user AS purchaseLimitPerUser, status + FROM qipai_recharge_plans + WHERE tenant_id = ? AND deleted_at IS NULL AND status = 'ACTIVE' + AND (starts_at IS NULL OR starts_at <= UTC_TIMESTAMP(3)) + AND (ends_at IS NULL OR ends_at > UTC_TIMESTAMP(3)) + ${storeFilter} + ORDER BY pay_amount_cents ASC, id ASC`, + params + ); + return rows.map((row) => ({ + planId: String(row.id), + storeId: row.storeId === null ? null : String(row.storeId), + name: row.name, + payAmountCents: Number(row.payAmountCents), + giftAmountCents: Number(row.giftAmountCents), + scopeType: row.scopeType, + purchaseLimitPerUser: row.purchaseLimitPerUser === null + ? null : Number(row.purchaseLimitPerUser), + startsAt: row.startsAt, + endsAt: row.endsAt + })); + } + async createRechargeOrder(input: { tenantId: string; userId: string; diff --git a/backend/tests/recharge-route.test.mjs b/backend/tests/recharge-route.test.mjs new file mode 100644 index 0000000..a045927 --- /dev/null +++ b/backend/tests/recharge-route.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; + +const secret = 'test-only-recharge-route-secret-32'; +const token = signAccessToken({ + sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', aid: '9', rv: 1 +}, secret, 900); + +let createdInput; +let listInput; +const app = await buildApp({ + recharge: { + jwtSecret: secret, + authRepository: { + async validateSession() { + return { + id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tenantId: '7', + platformAppId: '9', + expiresAt: new Date(Date.now() + 60000), + user: { + id: '21', + tenantId: '7', + userType: 'CUSTOMER', + status: 'ACTIVE', + roleVersion: 1, + nickname: '', + avatarUrl: '', + phone: '' + } + }; + } + }, + accessControl: { + async getAccessProfile() { + return { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] }; + } + }, + service: { + async listAvailablePlans(input) { + listInput = input; + return [{ + planId: '31', + storeId: '11', + name: 'charge 100 gift 20', + payAmountCents: 10000, + giftAmountCents: 2000, + scopeType: 'STORE' + }]; + }, + async createRechargeOrder(input) { + createdInput = input; + return { + rechargeOrderId: '901', + planId: input.planId, + rechargeNo: 'RCHTEST', + status: 'PENDING_PAYMENT', + payAmountCents: 10000, + giftAmountCents: 2000, + idempotent: false + }; + } + } + } +}); + +const plans = await app.inject({ + method: 'GET', + url: '/app-api/recharge/plans?storeId=11', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(plans.statusCode, 200); +assert.deepEqual(listInput, { tenantId: '7', storeId: '11' }); +assert.equal(plans.json().data[0].payAmountCents, 10000); + +const created = await app.inject({ + method: 'POST', + url: '/app-api/recharge/orders', + headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-recharge-order' }, + payload: { + planId: '31', + storeId: '11', + clientRequestId: 'miniapp-recharge-001' + } +}); +assert.equal(created.statusCode, 201); +assert.equal(createdInput.tenantId, '7'); +assert.equal(createdInput.userId, '21'); +assert.equal(createdInput.planId, '31'); +assert.equal(createdInput.traceId, 'm08a-recharge-order'); + +const invalid = await app.inject({ + method: 'POST', + url: '/app-api/recharge/orders', + headers: { authorization: `Bearer ${token}` }, + payload: { planId: 'bad' } +}); +assert.equal(invalid.statusCode, 400); + +await app.close(); +console.log('PASS: M08-A recharge routes expose available plans and customer order creation.'); diff --git a/miniapp/app.json b/miniapp/app.json index 08fafde..340bbb5 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -6,6 +6,7 @@ "pages/orders/list", "pages/orders/detail", "pages/profile/index", + "pages/recharge/index", "pages/logs/logs" ], "window": { diff --git a/miniapp/pages/profile/index.js b/miniapp/pages/profile/index.js index 67e86c7..46c2ec2 100644 --- a/miniapp/pages/profile/index.js +++ b/miniapp/pages/profile/index.js @@ -32,6 +32,10 @@ Page({ openOrders() { wx.navigateTo({ url: '/pages/orders/list' }) }, + + openRecharge() { + wx.navigateTo({ url: '/pages/recharge/index' }) + }, }) function formatProfile(profile) { diff --git a/miniapp/pages/profile/index.wxml b/miniapp/pages/profile/index.wxml index e3f2988..1cbf857 100644 --- a/miniapp/pages/profile/index.wxml +++ b/miniapp/pages/profile/index.wxml @@ -70,6 +70,7 @@ 已消费 {{profile.orders.paidAmountText}} + diff --git a/miniapp/pages/recharge/index.js b/miniapp/pages/recharge/index.js new file mode 100644 index 0000000..f418c0f --- /dev/null +++ b/miniapp/pages/recharge/index.js @@ -0,0 +1,90 @@ +const { + request, + ensureLogin, + cents, + clientRequestId, +} = require('../../utils/api.js') + +Page({ + data: { + loading: false, + errorMessage: '', + successMessage: '', + plans: [], + selectedPlanId: '', + rechargeOrder: null, + }, + + onLoad() { + this.loadPlans() + }, + + async loadPlans() { + this.setData({ loading: true, errorMessage: '', successMessage: '' }) + try { + await ensureLogin() + const response = await request('/recharge/plans') + const plans = (response.data || []).map((item) => ({ + ...item, + payText: cents(item.payAmountCents), + giftText: cents(item.giftAmountCents), + totalText: cents(Number(item.payAmountCents || 0) + Number(item.giftAmountCents || 0)), + })) + this.setData({ + plans, + selectedPlanId: plans[0]?.planId || '', + }) + } catch (error) { + this.setData({ errorMessage: error.message || '充值计划加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + selectPlan(event) { + this.setData({ + selectedPlanId: event.currentTarget.dataset.planId || '', + rechargeOrder: null, + errorMessage: '', + successMessage: '', + }) + }, + + async createRechargeOrder() { + if (!this.data.selectedPlanId) { + this.setData({ errorMessage: '请选择充值计划' }) + return + } + const plan = this.data.plans.find((item) => item.planId === this.data.selectedPlanId) + await this.withRequest(async () => { + const response = await request('/recharge/orders', { + method: 'POST', + data: { + planId: this.data.selectedPlanId, + storeId: plan?.storeId || null, + clientRequestId: clientRequestId('miniapp-recharge'), + }, + }) + this.setData({ + rechargeOrder: { + ...response.data, + payText: cents(response.data.payAmountCents), + giftText: cents(response.data.giftAmountCents), + }, + successMessage: '充值单已创建', + }) + }) + }, + + async withRequest(work) { + this.setData({ loading: true, errorMessage: '', successMessage: '' }) + try { + await ensureLogin() + await work() + } catch (error) { + this.setData({ errorMessage: error.message || '充值操作失败' }) + } finally { + this.setData({ loading: false }) + } + }, +}) diff --git a/miniapp/pages/recharge/index.json b/miniapp/pages/recharge/index.json new file mode 100644 index 0000000..c36c6e6 --- /dev/null +++ b/miniapp/pages/recharge/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "余额充值" +} diff --git a/miniapp/pages/recharge/index.wxml b/miniapp/pages/recharge/index.wxml new file mode 100644 index 0000000..785c6e9 --- /dev/null +++ b/miniapp/pages/recharge/index.wxml @@ -0,0 +1,35 @@ + + + 余额充值 + {{errorMessage}} + {{successMessage}} + + 暂无可用充值计划 + + + {{item.name}} + + 实付 + {{item.payText}} + + + 赠送 + {{item.giftText}} + + + 到账 + {{item.totalText}} + + + + + + + 充值单 + 单号:{{rechargeOrder.rechargeNo}} + 状态:{{rechargeOrder.status}} + 实付:{{rechargeOrder.payText}} + 赠送:{{rechargeOrder.giftText}} + + + diff --git a/miniapp/pages/recharge/index.wxss b/miniapp/pages/recharge/index.wxss new file mode 100644 index 0000000..da5d187 --- /dev/null +++ b/miniapp/pages/recharge/index.wxss @@ -0,0 +1,71 @@ +.scrollarea { + height: 100vh; + background: #f5f6f8; +} + +.page { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.plan, +.card { + margin-top: 20rpx; + padding: 28rpx; + border: 2rpx solid transparent; + border-radius: 16rpx; + background: #ffffff; +} + +.selected { + border-color: #1f6feb; + background: #f0f6ff; +} + +.plan-title, +.section-title { + font-size: 32rpx; + font-weight: 600; +} + +.row { + display: flex; + justify-content: space-between; + margin-top: 16rpx; + color: #475467; +} + +.total { + color: #b42318; + font-weight: 700; +} + +button { + margin-top: 24rpx; +} + +.muted { + margin-top: 8rpx; + color: #667085; +} + +.success { + margin: 16rpx 0; + color: #17823b; +} + +.error { + margin: 16rpx 0; + color: #c73535; +} + +.empty { + padding: 80rpx 0; + color: #888888; + text-align: center; +} diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs index 7a6309a..2bc17e3 100644 --- a/scripts/check-miniapp-m08-a.mjs +++ b/scripts/check-miniapp-m08-a.mjs @@ -12,7 +12,8 @@ for (const page of [ 'pages/room/detail', 'pages/orders/list', 'pages/orders/detail', - 'pages/profile/index' + 'pages/profile/index', + 'pages/recharge/index' ]) { assert.ok(appJson.pages.includes(page), `${page} must be registered`); } @@ -72,4 +73,16 @@ for (const pattern of [ assert.match(profile, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } +const recharge = read('miniapp/pages/recharge/index.js') + + read('miniapp/pages/recharge/index.wxml'); +for (const pattern of [ + '/recharge/plans', + '/recharge/orders', + 'clientRequestId', + 'selectedPlanId', + 'rechargeOrder' +]) { + assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.');