From 4ae9296f8345f8fa592fc9d41b6e3b72dbc3f33e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 24 Jun 2026 15:45:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(M07-C):=20=E5=BB=BA=E7=AB=8B=E4=BC=98?= =?UTF-8?q?=E6=83=A0=E5=88=B8=E5=A5=97=E9=A4=90=E6=9D=83=E7=9B=8A=E6=A0=B8?= =?UTF-8?q?=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/db/migration-runner.ts | 10 +- .../src/wallets/marketing-benefit-service.ts | 461 ++++++++++++++++++ .../tests/marketing-benefit-service.test.mjs | 250 ++++++++++ backend/tests/migration-contract.test.mjs | 20 +- backend/tests/migration-runner.test.mjs | 3 +- .../2026062423_m07c_benefits.down.sql | 6 + .../2026062423_m07c_benefits.up.sql | 149 ++++++ .../2026062423_m07c_benefits.verify.sql | 41 ++ 9 files changed, 936 insertions(+), 6 deletions(-) create mode 100644 backend/src/wallets/marketing-benefit-service.ts create mode 100644 backend/tests/marketing-benefit-service.test.mjs create mode 100644 database/migrations/2026062423_m07c_benefits.down.sql create mode 100644 database/migrations/2026062423_m07c_benefits.up.sql create mode 100644 database/migrations/2026062423_m07c_benefits.verify.sql diff --git a/backend/package.json b/backend/package.json index fc7dcbb..bd6a183 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-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" + "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" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 5a6b0ee..e94c11b 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -42,7 +42,8 @@ const migrationFiles: Record = { 'database/migrations/2026062219_m06b_device_topology.up.sql', 'database/migrations/2026062220_m06c_iot_messages.up.sql', 'database/migrations/2026062421_m07a_wallet_ledger.up.sql', - 'database/migrations/2026062422_m07b_recharge_plans.up.sql' + 'database/migrations/2026062422_m07b_recharge_plans.up.sql', + 'database/migrations/2026062423_m07c_benefits.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -66,9 +67,11 @@ const migrationFiles: Record = { 'database/migrations/2026062219_m06b_device_topology.verify.sql', 'database/migrations/2026062220_m06c_iot_messages.verify.sql', 'database/migrations/2026062421_m07a_wallet_ledger.verify.sql', - 'database/migrations/2026062422_m07b_recharge_plans.verify.sql' + 'database/migrations/2026062422_m07b_recharge_plans.verify.sql', + 'database/migrations/2026062423_m07c_benefits.verify.sql' ], down: [ + 'database/migrations/2026062423_m07c_benefits.down.sql', 'database/migrations/2026062422_m07b_recharge_plans.down.sql', 'database/migrations/2026062421_m07a_wallet_ledger.down.sql', 'database/migrations/2026062220_m06c_iot_messages.down.sql', @@ -228,7 +231,8 @@ export async function executeMigrationPlan( 5, 8, 5, 2, 1, 3, 3, 9, 1, 1, 1, 1, 4, 7, 1, - 1, 1, 7, 7, 1 + 1, 1, 7, 7, 1, + 5, 10, 7, 3, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/wallets/marketing-benefit-service.ts b/backend/src/wallets/marketing-benefit-service.ts new file mode 100644 index 0000000..48fedd7 --- /dev/null +++ b/backend/src/wallets/marketing-benefit-service.ts @@ -0,0 +1,461 @@ +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; + +interface CouponRow extends RowDataPacket { + id: string; + status: string; + validFrom: Date; + validTo: Date; + remainingUses: number; + couponType: 'TIME' | 'FULL_REDUCTION'; + discountAmountCents: number; + timeMinutes: number; + minOrderAmountCents: number; + storeId: string | null; + roomCategoryId: string | null; + roomId: string | null; + weekdaysJson: string | number[] | null; + holidayOnly: number; +} + +interface PackageRow extends RowDataPacket { + id: string; + status: string; + validFrom: Date; + validTo: Date; + remainingMinutes: number; + remainingAmountCents: number; + storeId: string | null; + roomCategoryId: string | null; + roomId: string | null; + weekdaysJson: string | number[] | null; + holidayOnly: number; +} + +interface UsageRow extends RowDataPacket { + id: string; + benefitType: 'COUPON' | 'PACKAGE'; + benefitId: string; + status: string; + discountCents: number; + packageCreditCents: number; + minutes: number; +} + +export class MarketingBenefitError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class MarketingBenefitService { + constructor(private readonly pool: MySqlPool) {} + + async reserveForOrder(input: { + tenantId: string; + userId: string; + orderId: string; + storeId: string; + roomId?: string | null; + roomCategoryId?: string | null; + orderAmountCents: number; + orderStartAt: Date; + isHoliday?: boolean; + couponGrantId?: string | null; + packageHoldingId?: string | null; + packageMinutes?: number; + packageCreditCents?: number; + clientRequestId: string; + traceId: string; + }) { + return this.transaction(async (connection) => { + const duplicate = await this.findUsageByRequest(connection, input); + if (duplicate.length > 0) return reserveResponse(duplicate, true); + + const usages: UsageRow[] = []; + if (input.couponGrantId) { + const coupon = await this.loadCoupon(connection, input, input.couponGrantId); + assertUsableBenefit(coupon, input, 'COUPON'); + const discountCents = coupon.couponType === 'FULL_REDUCTION' + ? Math.min(Number(coupon.discountAmountCents), input.orderAmountCents) + : 0; + const minutes = coupon.couponType === 'TIME' ? Number(coupon.timeMinutes) : 0; + const usage = await this.insertUsage(connection, { + ...input, + benefitType: 'COUPON', + benefitId: coupon.id, + discountCents, + packageCreditCents: 0, + minutes + }); + await connection.execute( + `UPDATE qipai_coupon_grants + SET status = 'FROZEN', frozen_order_id = ? + WHERE tenant_id = ? AND id = ?`, + [input.orderId, input.tenantId, coupon.id] + ); + usages.push(usage); + } + + if (input.packageHoldingId) { + const holding = await this.loadPackageHolding(connection, input, input.packageHoldingId); + assertUsableBenefit(holding, input, 'PACKAGE'); + const packageCreditCents = input.packageCreditCents ?? 0; + const minutes = input.packageMinutes ?? 0; + if (minutes <= 0 && packageCreditCents <= 0) { + throw new MarketingBenefitError('BENEFIT_PACKAGE_USAGE_EMPTY'); + } + if (minutes > Number(holding.remainingMinutes)) { + throw new MarketingBenefitError('BENEFIT_PACKAGE_MINUTES_INSUFFICIENT'); + } + if (packageCreditCents > Number(holding.remainingAmountCents)) { + throw new MarketingBenefitError('BENEFIT_PACKAGE_AMOUNT_INSUFFICIENT'); + } + const usage = await this.insertUsage(connection, { + ...input, + benefitType: 'PACKAGE', + benefitId: holding.id, + discountCents: 0, + packageCreditCents, + minutes + }); + await connection.execute( + `UPDATE qipai_package_holdings + SET status = 'FROZEN', frozen_order_id = ? + WHERE tenant_id = ? AND id = ?`, + [input.orderId, input.tenantId, holding.id] + ); + usages.push(usage); + } + + if (usages.length === 0) { + throw new MarketingBenefitError('BENEFIT_EMPTY'); + } + return reserveResponse(usages, false); + }); + } + + async confirmReserved(input: { + tenantId: string; + userId: string; + orderId: string; + traceId: string; + }) { + return this.transaction(async (connection) => { + const usages = await this.loadOrderUsages(connection, input, true); + if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND'); + if (usages.every((usage) => usage.status === 'CONFIRMED')) { + return reserveResponse(usages, true); + } + for (const usage of usages) { + if (usage.status !== 'FROZEN') { + throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID'); + } + if (usage.benefitType === 'COUPON') { + await connection.execute( + `UPDATE qipai_coupon_grants + SET status = CASE WHEN remaining_uses <= 1 THEN 'USED' ELSE 'AVAILABLE' END, + remaining_uses = GREATEST(remaining_uses - 1, 0), + used_count = used_count + 1, + used_at = UTC_TIMESTAMP(3), + frozen_order_id = NULL + WHERE tenant_id = ? AND id = ?`, + [input.tenantId, usage.benefitId] + ); + } else { + await connection.execute( + `UPDATE qipai_package_holdings + SET remaining_minutes = GREATEST(remaining_minutes - ?, 0), + remaining_amount_cents = GREATEST(remaining_amount_cents - ?, 0), + status = CASE + WHEN remaining_minutes <= ? AND remaining_amount_cents <= ? THEN 'EXHAUSTED' + ELSE 'ACTIVE' + END, + frozen_order_id = NULL + WHERE tenant_id = ? AND id = ?`, + [ + usage.minutes, usage.packageCreditCents, + usage.minutes, usage.packageCreditCents, + input.tenantId, usage.benefitId + ] + ); + } + await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED'); + } + return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false); + }); + } + + async releaseReserved(input: { + tenantId: string; + userId: string; + orderId: string; + traceId: string; + }) { + return this.transaction(async (connection) => { + const usages = await this.loadOrderUsages(connection, input, true); + if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND'); + if (usages.every((usage) => usage.status === 'RELEASED')) { + return reserveResponse(usages, true); + } + for (const usage of usages) { + if (usage.status !== 'FROZEN') { + throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID'); + } + if (usage.benefitType === 'COUPON') { + await connection.execute( + `UPDATE qipai_coupon_grants + SET status = 'AVAILABLE', frozen_order_id = NULL + WHERE tenant_id = ? AND id = ?`, + [input.tenantId, usage.benefitId] + ); + } else { + await connection.execute( + `UPDATE qipai_package_holdings + SET status = 'ACTIVE', frozen_order_id = NULL + WHERE tenant_id = ? AND id = ?`, + [input.tenantId, usage.benefitId] + ); + } + await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED'); + } + return reserveResponse(usages.map((usage) => ({ ...usage, status: 'RELEASED' })), false); + }); + } + + private async loadCoupon( + connection: PoolConnection, + input: { tenantId: string; userId: string }, + couponGrantId: string + ) { + const [rows] = await connection.execute( + `SELECT g.id, g.status, g.valid_from AS validFrom, g.valid_to AS validTo, + g.remaining_uses AS remainingUses, + t.coupon_type AS couponType, t.discount_amount_cents AS discountAmountCents, + t.time_minutes AS timeMinutes, t.min_order_amount_cents AS minOrderAmountCents, + t.store_id AS storeId, t.room_category_id AS roomCategoryId, + t.room_id AS roomId, t.weekdays_json AS weekdaysJson, t.holiday_only AS holidayOnly + FROM qipai_coupon_grants g + INNER JOIN qipai_coupon_templates t + ON t.tenant_id = g.tenant_id AND t.id = g.template_id + WHERE g.tenant_id = ? AND g.user_id = ? AND g.id = ? + AND t.status = 'ACTIVE' AND t.deleted_at IS NULL + FOR UPDATE`, + [input.tenantId, input.userId, couponGrantId] + ); + if (!rows[0]) throw new MarketingBenefitError('BENEFIT_COUPON_NOT_FOUND'); + return normalizeScopeRow(rows[0]); + } + + private async loadPackageHolding( + connection: PoolConnection, + input: { tenantId: string; userId: string }, + packageHoldingId: string + ) { + const [rows] = await connection.execute( + `SELECT h.id, h.status, h.valid_from AS validFrom, h.valid_to AS validTo, + h.remaining_minutes AS remainingMinutes, + h.remaining_amount_cents AS remainingAmountCents, + p.store_id AS storeId, p.room_category_id AS roomCategoryId, + p.room_id AS roomId, p.weekdays_json AS weekdaysJson, p.holiday_only AS holidayOnly + FROM qipai_package_holdings h + INNER JOIN qipai_package_plans p + ON p.tenant_id = h.tenant_id AND p.id = h.plan_id + WHERE h.tenant_id = ? AND h.user_id = ? AND h.id = ? + AND p.status = 'ACTIVE' AND p.deleted_at IS NULL + FOR UPDATE`, + [input.tenantId, input.userId, packageHoldingId] + ); + if (!rows[0]) throw new MarketingBenefitError('BENEFIT_PACKAGE_NOT_FOUND'); + return normalizeScopeRow(rows[0]); + } + + private async findUsageByRequest( + connection: PoolConnection, + input: { tenantId: string; userId: string; clientRequestId: string } + ) { + const [rows] = await connection.execute( + `SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status, + discount_cents AS discountCents, + package_credit_cents AS packageCreditCents, minutes + FROM qipai_benefit_usages + WHERE tenant_id = ? AND user_id = ? AND client_request_id = ?`, + [input.tenantId, input.userId, input.clientRequestId] + ); + return rows.map(normalizeUsageRow); + } + + private async loadOrderUsages( + connection: PoolConnection, + input: { tenantId: string; userId: string; orderId: string }, + lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status, + discount_cents AS discountCents, + package_credit_cents AS packageCreditCents, minutes + FROM qipai_benefit_usages + WHERE tenant_id = ? AND user_id = ? AND order_id = ? + ${lock ? 'FOR UPDATE' : ''}`, + [input.tenantId, input.userId, input.orderId] + ); + return rows.map(normalizeUsageRow); + } + + private async insertUsage( + connection: PoolConnection, + input: { + tenantId: string; + userId: string; + orderId: string; + clientRequestId: string; + traceId: string; + benefitType: 'COUPON' | 'PACKAGE'; + benefitId: string; + discountCents: number; + packageCreditCents: number; + minutes: number; + } + ) { + const [result] = await connection.execute( + `INSERT INTO qipai_benefit_usages + (tenant_id, user_id, order_id, benefit_type, benefit_id, + client_request_id, discount_cents, package_credit_cents, minutes, trace_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + input.tenantId, input.userId, input.orderId, input.benefitType, input.benefitId, + input.clientRequestId, input.discountCents, input.packageCreditCents, + input.minutes, input.traceId + ] + ); + return { + id: String(result.insertId), + benefitType: input.benefitType, + benefitId: input.benefitId, + status: 'FROZEN', + discountCents: input.discountCents, + packageCreditCents: input.packageCreditCents, + minutes: input.minutes + } as UsageRow; + } + + private async updateUsageStatus( + connection: PoolConnection, + tenantId: string, + usageId: string, + status: 'CONFIRMED' | 'RELEASED' + ) { + await connection.execute( + `UPDATE qipai_benefit_usages + SET status = ? + WHERE tenant_id = ? AND id = ?`, + [status, tenantId, usageId] + ); + } + + private async transaction(work: (connection: PoolConnection) => Promise) { + const connection = await this.pool.getConnection(); + try { + await connection.beginTransaction(); + const result = await work(connection); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } +} + +function assertUsableBenefit( + row: CouponRow | PackageRow, + input: { + storeId: string; + roomId?: string | null; + roomCategoryId?: string | null; + orderAmountCents: number; + orderStartAt: Date; + isHoliday?: boolean; + }, + type: 'COUPON' | 'PACKAGE' +) { + if (row.status !== 'AVAILABLE' && row.status !== 'ACTIVE') { + throw new MarketingBenefitError(`BENEFIT_${type}_STATUS_INVALID`); + } + const orderTime = input.orderStartAt.getTime(); + if (row.validFrom.getTime() > orderTime || row.validTo.getTime() <= orderTime) { + throw new MarketingBenefitError(`BENEFIT_${type}_EXPIRED`); + } + if ('remainingUses' in row && Number(row.remainingUses) <= 0) { + throw new MarketingBenefitError('BENEFIT_COUPON_USED_UP'); + } + if ('minOrderAmountCents' in row && input.orderAmountCents < Number(row.minOrderAmountCents)) { + throw new MarketingBenefitError('BENEFIT_COUPON_MIN_AMOUNT_NOT_MET'); + } + if (row.storeId && row.storeId !== input.storeId) { + throw new MarketingBenefitError(`BENEFIT_${type}_STORE_MISMATCH`); + } + if (row.roomCategoryId && row.roomCategoryId !== (input.roomCategoryId ?? null)) { + throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_CATEGORY_MISMATCH`); + } + if (row.roomId && row.roomId !== (input.roomId ?? null)) { + throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_MISMATCH`); + } + if (Number(row.holidayOnly) === 1 && input.isHoliday !== true) { + throw new MarketingBenefitError(`BENEFIT_${type}_HOLIDAY_ONLY`); + } + const weekdays = parseWeekdays(row.weekdaysJson); + if (weekdays.length > 0 && !weekdays.includes(input.orderStartAt.getUTCDay())) { + throw new MarketingBenefitError(`BENEFIT_${type}_WEEKDAY_MISMATCH`); + } +} + +function normalizeScopeRow(row: T): T { + return { + ...row, + id: String(row.id), + storeId: row.storeId === null ? null : String(row.storeId), + roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId), + roomId: row.roomId === null ? null : String(row.roomId) + }; +} + +function normalizeUsageRow(row: UsageRow): UsageRow { + return { + ...row, + id: String(row.id), + benefitId: String(row.benefitId), + discountCents: Number(row.discountCents), + packageCreditCents: Number(row.packageCreditCents), + minutes: Number(row.minutes) + }; +} + +function parseWeekdays(value: string | number[] | null): number[] { + if (!value) return []; + if (Array.isArray(value)) return value.map(Number); + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map(Number) : []; + } catch { + return []; + } +} + +function reserveResponse(usages: UsageRow[], idempotent: boolean) { + return { + idempotent, + status: usages.every((usage) => usage.status === usages[0]?.status) + ? usages[0]?.status ?? 'EMPTY' + : 'MIXED', + discountCents: usages.reduce((sum, usage) => sum + Number(usage.discountCents), 0), + packageCreditCents: usages.reduce((sum, usage) => sum + Number(usage.packageCreditCents), 0), + minutes: usages.reduce((sum, usage) => sum + Number(usage.minutes), 0), + usages: usages.map((usage) => ({ + usageId: String(usage.id), + benefitType: usage.benefitType, + benefitId: String(usage.benefitId), + status: usage.status + })) + }; +} diff --git a/backend/tests/marketing-benefit-service.test.mjs b/backend/tests/marketing-benefit-service.test.mjs new file mode 100644 index 0000000..8b0abc0 --- /dev/null +++ b/backend/tests/marketing-benefit-service.test.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { + MarketingBenefitError, + MarketingBenefitService +} from '../dist/wallets/marketing-benefit-service.js'; + +const validFrom = new Date(Date.now() - 86_400_000); +const validTo = new Date(Date.now() + 86_400_000); + +function createHarness(overrides = {}) { + const state = { + coupon: { + id: '401', + status: 'AVAILABLE', + validFrom, + validTo, + remainingUses: 1, + couponType: 'FULL_REDUCTION', + discountAmountCents: 3000, + timeMinutes: 0, + minOrderAmountCents: 10000, + storeId: '11', + roomCategoryId: null, + roomId: null, + weekdaysJson: null, + holidayOnly: 0, + ...overrides.coupon + }, + packageHolding: { + id: '501', + status: 'ACTIVE', + validFrom, + validTo, + remainingMinutes: 180, + remainingAmountCents: 6000, + storeId: '11', + roomCategoryId: null, + roomId: null, + weekdaysJson: null, + holidayOnly: 0, + ...overrides.packageHolding + }, + usages: [], + nextUsageId: 700 + }; + + const connection = { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async execute(sql, params) { + if (sql.includes('FROM qipai_benefit_usages') && sql.includes('client_request_id')) { + return [state.usages.filter((usage) => + usage.tenantId === String(params[0]) + && usage.userId === String(params[1]) + && usage.clientRequestId === params[2] + ).map(toUsageRow), []]; + } + if (sql.includes('FROM qipai_coupon_grants')) { + if (state.coupon && String(params[2]) === state.coupon.id) { + return [[state.coupon], []]; + } + return [[], []]; + } + if (sql.includes('FROM qipai_package_holdings')) { + if (state.packageHolding && String(params[2]) === state.packageHolding.id) { + return [[state.packageHolding], []]; + } + return [[], []]; + } + if (sql.includes('INSERT INTO qipai_benefit_usages')) { + const usage = { + id: String(state.nextUsageId++), + tenantId: String(params[0]), + userId: String(params[1]), + orderId: String(params[2]), + benefitType: params[3], + benefitId: String(params[4]), + clientRequestId: params[5], + discountCents: Number(params[6]), + packageCreditCents: Number(params[7]), + minutes: Number(params[8]), + traceId: params[9], + status: 'FROZEN' + }; + state.usages.push(usage); + return [{ insertId: usage.id, affectedRows: 1 }, []]; + } + if (sql.includes("SET status = 'FROZEN'") && sql.includes('qipai_coupon_grants')) { + state.coupon.status = 'FROZEN'; + state.coupon.frozenOrderId = String(params[0]); + return [{ affectedRows: 1 }, []]; + } + if (sql.includes("SET status = 'FROZEN'") && sql.includes('qipai_package_holdings')) { + state.packageHolding.status = 'FROZEN'; + state.packageHolding.frozenOrderId = String(params[0]); + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('FROM qipai_benefit_usages') && sql.includes('order_id')) { + return [state.usages.filter((usage) => + usage.tenantId === String(params[0]) + && usage.userId === String(params[1]) + && usage.orderId === String(params[2]) + ).map(toUsageRow), []]; + } + if (sql.includes('UPDATE qipai_coupon_grants') && sql.includes("used_count = used_count + 1")) { + state.coupon.remainingUses = Math.max(state.coupon.remainingUses - 1, 0); + state.coupon.status = state.coupon.remainingUses === 0 ? 'USED' : 'AVAILABLE'; + state.coupon.frozenOrderId = null; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('UPDATE qipai_package_holdings') && sql.includes('remaining_minutes')) { + const usage = state.usages.find((item) => + item.benefitType === 'PACKAGE' && item.benefitId === state.packageHolding.id + ); + state.packageHolding.remainingMinutes -= usage.minutes; + state.packageHolding.remainingAmountCents -= usage.packageCreditCents; + state.packageHolding.status = + state.packageHolding.remainingMinutes === 0 + && state.packageHolding.remainingAmountCents === 0 + ? 'EXHAUSTED' + : 'ACTIVE'; + state.packageHolding.frozenOrderId = null; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('UPDATE qipai_benefit_usages')) { + const usage = state.usages.find((item) => + item.tenantId === String(params[1]) && item.id === String(params[2]) + ); + usage.status = params[0]; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes("SET status = 'AVAILABLE'")) { + state.coupon.status = 'AVAILABLE'; + state.coupon.frozenOrderId = null; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes("SET status = 'ACTIVE'")) { + state.packageHolding.status = 'ACTIVE'; + state.packageHolding.frozenOrderId = null; + return [{ affectedRows: 1 }, []]; + } + throw new Error(`Unexpected SQL: ${sql}`); + } + }; + const pool = { + async getConnection() { + return connection; + } + }; + return { service: new MarketingBenefitService(pool), state }; +} + +function toUsageRow(usage) { + return { + id: usage.id, + benefitType: usage.benefitType, + benefitId: usage.benefitId, + status: usage.status, + discountCents: usage.discountCents, + packageCreditCents: usage.packageCreditCents, + minutes: usage.minutes + }; +} + +const baseInput = { + tenantId: '7', + userId: '21', + orderId: '31', + storeId: '11', + orderAmountCents: 12000, + orderStartAt: new Date('2026-06-25T02:00:00.000Z'), + couponGrantId: '401', + packageHoldingId: '501', + packageMinutes: 60, + packageCreditCents: 2000, + clientRequestId: 'benefit-001', + traceId: 'benefit-test' +}; + +{ + const { service, state } = createHarness(); + const reserved = await service.reserveForOrder(baseInput); + assert.equal(reserved.status, 'FROZEN'); + assert.equal(reserved.discountCents, 3000); + assert.equal(reserved.packageCreditCents, 2000); + assert.equal(reserved.minutes, 60); + assert.equal(state.coupon.status, 'FROZEN'); + assert.equal(state.packageHolding.status, 'FROZEN'); + + const duplicate = await service.reserveForOrder(baseInput); + assert.equal(duplicate.idempotent, true); + assert.equal(state.usages.length, 2); + + const confirmed = await service.confirmReserved({ + tenantId: '7', + userId: '21', + orderId: '31', + traceId: 'benefit-test-confirm' + }); + assert.equal(confirmed.status, 'CONFIRMED'); + assert.equal(state.coupon.status, 'USED'); + assert.equal(state.packageHolding.status, 'ACTIVE'); + assert.equal(state.packageHolding.remainingMinutes, 120); + assert.equal(state.packageHolding.remainingAmountCents, 4000); + + const confirmAgain = await service.confirmReserved({ + tenantId: '7', + userId: '21', + orderId: '31', + traceId: 'benefit-test-confirm' + }); + assert.equal(confirmAgain.idempotent, true); +} + +{ + const { service } = createHarness({ coupon: { minOrderAmountCents: 20000 } }); + await assert.rejects( + () => service.reserveForOrder(baseInput), + (error) => error instanceof MarketingBenefitError + && error.code === 'BENEFIT_COUPON_MIN_AMOUNT_NOT_MET' + ); +} + +{ + const { service } = createHarness({ packageHolding: { remainingMinutes: 30 } }); + await assert.rejects( + () => service.reserveForOrder(baseInput), + (error) => error instanceof MarketingBenefitError + && error.code === 'BENEFIT_PACKAGE_MINUTES_INSUFFICIENT' + ); +} + +{ + const { service, state } = createHarness(); + await service.reserveForOrder({ ...baseInput, clientRequestId: 'benefit-release' }); + const released = await service.releaseReserved({ + tenantId: '7', + userId: '21', + orderId: '31', + traceId: 'benefit-test-release' + }); + assert.equal(released.status, 'RELEASED'); + assert.equal(state.coupon.status, 'AVAILABLE'); + assert.equal(state.packageHolding.status, 'ACTIVE'); + assert.equal(state.packageHolding.remainingMinutes, 180); +} + +console.log('PASS: M07-C benefits freeze, confirm, release and validate coupon/package usage.'); diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 4ca4c8d..77c1aa5 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -75,6 +75,9 @@ const walletVerifySql = read('database/migrations/2026062421_m07a_wallet_ledger. const rechargeUpSql = read('database/migrations/2026062422_m07b_recharge_plans.up.sql'); const rechargeDownSql = read('database/migrations/2026062422_m07b_recharge_plans.down.sql'); const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_plans.verify.sql'); +const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql'); +const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql'); +const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -346,4 +349,19 @@ assert.match(rechargeUpSql, /UNIQUE KEY uq_qipai_recharge_order_request/); assert.match(rechargeUpSql, /payment_id BIGINT UNSIGNED NULL/); assert.match(rechargeUpSql, /credited_at DATETIME\(3\) NULL/); -console.log('PASS: M01-B through M07-B migration contracts are present.'); +for (const table of [ + 'qipai_coupon_templates', 'qipai_coupon_grants', 'qipai_package_plans', + 'qipai_package_holdings', 'qipai_benefit_usages' +]) { + assert.match(benefitUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); + assert.match(benefitDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`)); + assert.match(benefitVerifySql, new RegExp(`'${table}'`)); +} +assert.match(benefitUpSql, /coupon_type VARCHAR/); +assert.match(benefitUpSql, /weekdays_json JSON/); +assert.match(benefitUpSql, /remaining_minutes INT UNSIGNED/); +assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/); +assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/); +assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/); + +console.log('PASS: M01-B through M07-C migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 2cabe50..bd731d5 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -33,7 +33,8 @@ assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql/); assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/); assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/); assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/); -assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql$/); +assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/); +assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/database/migrations/2026062423_m07c_benefits.down.sql b/database/migrations/2026062423_m07c_benefits.down.sql new file mode 100644 index 0000000..0d1d234 --- /dev/null +++ b/database/migrations/2026062423_m07c_benefits.down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS qipai_benefit_usages; +DROP TABLE IF EXISTS qipai_package_holdings; +DROP TABLE IF EXISTS qipai_package_plans; +DROP TABLE IF EXISTS qipai_coupon_grants; +DROP TABLE IF EXISTS qipai_coupon_templates; +DELETE FROM qipai_schema_migrations WHERE version = '2026062423'; diff --git a/database/migrations/2026062423_m07c_benefits.up.sql b/database/migrations/2026062423_m07c_benefits.up.sql new file mode 100644 index 0000000..4bfe043 --- /dev/null +++ b/database/migrations/2026062423_m07c_benefits.up.sql @@ -0,0 +1,149 @@ +CREATE TABLE IF NOT EXISTS qipai_coupon_templates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NULL, + name VARCHAR(128) NOT NULL, + coupon_type VARCHAR(32) NOT NULL, + discount_amount_cents INT UNSIGNED NOT NULL DEFAULT 0, + time_minutes INT UNSIGNED NOT NULL DEFAULT 0, + min_order_amount_cents INT UNSIGNED NOT NULL DEFAULT 0, + room_category_id BIGINT UNSIGNED NULL, + room_id BIGINT UNSIGNED NULL, + weekdays_json JSON NULL, + holiday_only TINYINT(1) NOT NULL DEFAULT 0, + starts_at DATETIME(3) NULL, + ends_at DATETIME(3) NULL, + valid_days INT UNSIGNED NULL, + usage_limit_per_user INT UNSIGNED NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_coupon_template_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_coupon_template_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_coupon_template_room_category + FOREIGN KEY (room_category_id) REFERENCES qipai_room_categories(id), + CONSTRAINT fk_qipai_coupon_template_room + FOREIGN KEY (room_id) REFERENCES qipai_rooms(id), + KEY idx_qipai_coupon_template_scope + (tenant_id, store_id, coupon_type, status, starts_at, ends_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_coupon_grants ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + coupon_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE', + valid_from DATETIME(3) NOT NULL, + valid_to DATETIME(3) NOT NULL, + remaining_uses INT UNSIGNED NOT NULL DEFAULT 1, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + frozen_order_id BIGINT UNSIGNED NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_coupon_grant_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_coupon_grant_user + FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_coupon_grant_template + FOREIGN KEY (template_id) REFERENCES qipai_coupon_templates(id), + CONSTRAINT fk_qipai_coupon_grant_order + FOREIGN KEY (frozen_order_id) REFERENCES qipai_orders(id), + UNIQUE KEY uq_qipai_coupon_grant_code (tenant_id, coupon_code), + KEY idx_qipai_coupon_grant_user (tenant_id, user_id, status, valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_package_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NULL, + name VARCHAR(128) NOT NULL, + price_cents INT UNSIGNED NOT NULL, + minutes_total INT UNSIGNED NOT NULL DEFAULT 0, + amount_cents_total INT UNSIGNED NOT NULL DEFAULT 0, + room_category_id BIGINT UNSIGNED NULL, + room_id BIGINT UNSIGNED NULL, + weekdays_json JSON NULL, + holiday_only TINYINT(1) NOT NULL DEFAULT 0, + valid_days INT UNSIGNED NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_package_plan_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_package_plan_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_package_plan_room_category + FOREIGN KEY (room_category_id) REFERENCES qipai_room_categories(id), + CONSTRAINT fk_qipai_package_plan_room + FOREIGN KEY (room_id) REFERENCES qipai_rooms(id), + KEY idx_qipai_package_plan_scope (tenant_id, store_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_package_holdings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + plan_id BIGINT UNSIGNED NOT NULL, + purchase_order_id BIGINT UNSIGNED NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + valid_from DATETIME(3) NOT NULL, + valid_to DATETIME(3) NOT NULL, + remaining_minutes INT UNSIGNED NOT NULL DEFAULT 0, + remaining_amount_cents INT UNSIGNED NOT NULL DEFAULT 0, + frozen_order_id BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_package_holding_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_package_holding_user + FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_package_holding_plan + FOREIGN KEY (plan_id) REFERENCES qipai_package_plans(id), + CONSTRAINT fk_qipai_package_holding_purchase_order + FOREIGN KEY (purchase_order_id) REFERENCES qipai_orders(id), + CONSTRAINT fk_qipai_package_holding_frozen_order + FOREIGN KEY (frozen_order_id) REFERENCES qipai_orders(id), + KEY idx_qipai_package_holding_user (tenant_id, user_id, status, valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_benefit_usages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + benefit_type VARCHAR(32) NOT NULL, + benefit_id BIGINT UNSIGNED NOT NULL, + client_request_id VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'FROZEN', + discount_cents INT UNSIGNED NOT NULL DEFAULT 0, + package_credit_cents INT UNSIGNED NOT NULL DEFAULT 0, + minutes INT UNSIGNED NOT NULL DEFAULT 0, + trace_id VARCHAR(128) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_benefit_usage_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_benefit_usage_user + FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_benefit_usage_order + FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + UNIQUE KEY uq_qipai_benefit_usage_request (tenant_id, user_id, client_request_id), + UNIQUE KEY uq_qipai_benefit_usage_order_benefit + (tenant_id, order_id, benefit_type, benefit_id), + KEY idx_qipai_benefit_usage_order (tenant_id, order_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062423', 'm07c_benefits'); diff --git a/database/migrations/2026062423_m07c_benefits.verify.sql b/database/migrations/2026062423_m07c_benefits.verify.sql new file mode 100644 index 0000000..d2e93d2 --- /dev/null +++ b/database/migrations/2026062423_m07c_benefits.verify.sql @@ -0,0 +1,41 @@ +SELECT table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qipai_coupon_templates', 'qipai_coupon_grants', + 'qipai_package_plans', 'qipai_package_holdings', + 'qipai_benefit_usages' + ); + +SELECT column_name +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_coupon_templates' + AND column_name IN ( + 'coupon_type', 'discount_amount_cents', 'time_minutes', + 'min_order_amount_cents', 'room_category_id', 'room_id', + 'weekdays_json', 'holiday_only', 'valid_days', 'usage_limit_per_user' + ); + +SELECT column_name +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_package_holdings' + AND column_name IN ( + 'purchase_order_id', 'remaining_minutes', 'remaining_amount_cents', + 'frozen_order_id', 'valid_from', 'valid_to', 'status' + ); + +SELECT index_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'qipai_benefit_usages' + AND index_name IN ( + 'uq_qipai_benefit_usage_request', + 'uq_qipai_benefit_usage_order_benefit', + 'idx_qipai_benefit_usage_order' + ); + +SELECT '2026062423' AS version +FROM qipai_schema_migrations +WHERE version = '2026062423';