diff --git a/backend/package.json b/backend/package.json index b638292..fc7dcbb 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" + "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" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index b80b09b..5a6b0ee 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -41,7 +41,8 @@ const migrationFiles: Record = { 'database/migrations/2026062218_m05d_profit_sharing.up.sql', '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/2026062421_m07a_wallet_ledger.up.sql', + 'database/migrations/2026062422_m07b_recharge_plans.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -64,9 +65,11 @@ const migrationFiles: Record = { 'database/migrations/2026062218_m05d_profit_sharing.verify.sql', '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/2026062421_m07a_wallet_ledger.verify.sql', + 'database/migrations/2026062422_m07b_recharge_plans.verify.sql' ], down: [ + 'database/migrations/2026062422_m07b_recharge_plans.down.sql', 'database/migrations/2026062421_m07a_wallet_ledger.down.sql', 'database/migrations/2026062220_m06c_iot_messages.down.sql', 'database/migrations/2026062219_m06b_device_topology.down.sql', @@ -224,7 +227,8 @@ export async function executeMigrationPlan( 3, 7, 5, 1, 5, 8, 5, 2, 1, 3, 3, 9, 1, - 1, 1, 1, 4, 7, 1 + 1, 1, 1, 4, 7, 1, + 1, 1, 7, 7, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/wallets/recharge-service.ts b/backend/src/wallets/recharge-service.ts new file mode 100644 index 0000000..775596a --- /dev/null +++ b/backend/src/wallets/recharge-service.ts @@ -0,0 +1,241 @@ +import { randomBytes } from 'node:crypto'; +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; +import type { WalletLedgerService } from './wallet-ledger-service.js'; + +interface PlanRow extends RowDataPacket { + id: string; + tenantId: string; + storeId: string | null; + name: string; + payAmountCents: number; + giftAmountCents: number; + scopeType: 'TENANT' | 'STORE'; + startsAt: Date | null; + endsAt: Date | null; + purchaseLimitPerUser: number | null; + status: string; +} + +interface RechargeOrderRow extends RowDataPacket { + id: string; + userId: string; + storeId: string | null; + planId: string; + rechargeNo: string; + payAmountCents: number; + giftAmountCents: number; + status: string; +} + +interface CountRow extends RowDataPacket { total: number } + +export class RechargeError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class RechargeService { + constructor( + private readonly pool: MySqlPool, + private readonly wallet: Pick + ) {} + + async createRechargeOrder(input: { + tenantId: string; + userId: string; + planId: string; + storeId?: string | null; + clientRequestId: string; + traceId: string; + }) { + return this.transaction(async (connection) => { + const duplicate = await this.findByRequest(connection, input); + if (duplicate) return rechargeResponse(duplicate, true); + const plan = await this.loadPlan(connection, input.tenantId, input.planId); + assertPlanAvailable(plan, input.storeId ?? null); + await this.assertPurchaseLimit(connection, input.tenantId, input.userId, plan); + const rechargeNo = `RCH${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`; + const [result] = await connection.execute( + `INSERT INTO qipai_recharge_orders + (tenant_id, user_id, store_id, plan_id, recharge_no, client_request_id, + pay_amount_cents, gift_amount_cents, trace_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [input.tenantId, input.userId, plan.storeId ?? input.storeId ?? null, plan.id, + rechargeNo, input.clientRequestId, plan.payAmountCents, plan.giftAmountCents, + input.traceId] + ); + return rechargeResponse({ + id: String(result.insertId), + userId: input.userId, + storeId: plan.storeId ?? input.storeId ?? null, + planId: plan.id, + rechargeNo, + payAmountCents: plan.payAmountCents, + giftAmountCents: plan.giftAmountCents, + status: 'PENDING_PAYMENT' + } as RechargeOrderRow, false); + }); + } + + async markPaidAndCredit(input: { + tenantId: string; + rechargeOrderId: string; + paymentId: string; + traceId: string; + }) { + return this.transaction(async (connection) => { + const order = await this.loadRechargeOrder(connection, input, true); + if (order.status === 'CREDITED') { + return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: true }; + } + if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') { + throw new RechargeError('RECHARGE_STATUS_INVALID'); + } + await connection.execute( + `UPDATE qipai_recharge_orders + SET status = 'PAID', payment_id = ?, paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3)) + WHERE tenant_id = ? AND id = ?`, + [input.paymentId, input.tenantId, order.id] + ); + const plan = await this.loadPlan(connection, input.tenantId, order.planId); + await this.wallet.credit({ + tenantId: input.tenantId, + userId: String(order.userId), + scopeType: plan.scopeType, + storeId: plan.scopeType === 'STORE' ? order.storeId : null, + businessType: 'RECHARGE', + businessId: order.id, + entryType: 'RECHARGE', + cashDeltaCents: Number(order.payAmountCents), + giftDeltaCents: Number(order.giftAmountCents), + traceId: input.traceId, + metadata: { paymentId: input.paymentId, rechargeNo: order.rechargeNo } + }); + await connection.execute( + `UPDATE qipai_recharge_orders + SET status = 'CREDITED', credited_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ?`, + [input.tenantId, order.id] + ); + return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: false }; + }); + } + + private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) { + const [rows] = await connection.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 id = ? AND deleted_at IS NULL`, + [tenantId, planId] + ); + if (!rows[0]) throw new RechargeError('RECHARGE_PLAN_NOT_FOUND'); + return { + ...rows[0], + id: String(rows[0].id), + tenantId: String(rows[0].tenantId), + storeId: rows[0].storeId === null ? null : String(rows[0].storeId) + }; + } + + private async findByRequest( + connection: PoolConnection, + input: { tenantId: string; userId: string; clientRequestId: string } + ) { + const [rows] = await connection.execute( + `SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId, + recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents, + gift_amount_cents AS giftAmountCents, status + FROM qipai_recharge_orders + WHERE tenant_id = ? AND user_id = ? AND client_request_id = ? LIMIT 1`, + [input.tenantId, input.userId, input.clientRequestId] + ); + return rows[0] ?? null; + } + + private async loadRechargeOrder( + connection: PoolConnection, + input: { tenantId: string; rechargeOrderId: string }, + lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId, + recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents, + gift_amount_cents AS giftAmountCents, status + FROM qipai_recharge_orders + WHERE tenant_id = ? AND id = ? + ${lock ? 'FOR UPDATE' : ''}`, + [input.tenantId, input.rechargeOrderId] + ); + if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND'); + return { + ...rows[0], + id: String(rows[0].id), + userId: String(rows[0].userId), + storeId: rows[0].storeId === null ? null : String(rows[0].storeId), + planId: String(rows[0].planId) + }; + } + + private async assertPurchaseLimit( + connection: PoolConnection, + tenantId: string, + userId: string, + plan: PlanRow + ) { + if (!plan.purchaseLimitPerUser) return; + const [rows] = await connection.execute( + `SELECT COUNT(*) AS total FROM qipai_recharge_orders + WHERE tenant_id = ? AND user_id = ? AND plan_id = ? + AND status IN ('PENDING_PAYMENT', 'PAID', 'CREDITED')`, + [tenantId, userId, plan.id] + ); + if (Number(rows[0]?.total ?? 0) >= Number(plan.purchaseLimitPerUser)) { + throw new RechargeError('RECHARGE_LIMIT_REACHED'); + } + } + + 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 assertPlanAvailable(plan: PlanRow, requestedStoreId: string | null) { + if (plan.status !== 'ACTIVE') throw new RechargeError('RECHARGE_PLAN_DISABLED'); + const now = Date.now(); + if (plan.startsAt && plan.startsAt.getTime() > now) { + throw new RechargeError('RECHARGE_PLAN_NOT_STARTED'); + } + if (plan.endsAt && plan.endsAt.getTime() <= now) { + throw new RechargeError('RECHARGE_PLAN_EXPIRED'); + } + if (plan.storeId && requestedStoreId && plan.storeId !== requestedStoreId) { + throw new RechargeError('RECHARGE_PLAN_STORE_MISMATCH'); + } +} + +function rechargeResponse(row: RechargeOrderRow, idempotent: boolean) { + return { + rechargeOrderId: String(row.id), + planId: String(row.planId), + rechargeNo: row.rechargeNo, + status: row.status, + payAmountCents: Number(row.payAmountCents), + giftAmountCents: Number(row.giftAmountCents), + idempotent + }; +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index e9e54ef..4ca4c8d 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -72,6 +72,9 @@ const iotMessagesVerifySql = read('database/migrations/2026062220_m06c_iot_messa const walletUpSql = read('database/migrations/2026062421_m07a_wallet_ledger.up.sql'); const walletDownSql = read('database/migrations/2026062421_m07a_wallet_ledger.down.sql'); const walletVerifySql = read('database/migrations/2026062421_m07a_wallet_ledger.verify.sql'); +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 coreTables = [ 'qipai_schema_migrations', @@ -331,4 +334,16 @@ assert.match(walletUpSql, /uq_qipai_wallet_ledger_business/); assert.match(walletUpSql, /cash_delta_cents INT NOT NULL DEFAULT 0/); assert.match(walletUpSql, /gift_delta_cents INT NOT NULL DEFAULT 0/); -console.log('PASS: M01-B through M07-A migration contracts are present.'); +for (const table of ['qipai_recharge_plans', 'qipai_recharge_orders']) { + assert.match(rechargeUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); + assert.match(rechargeDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`)); + assert.match(rechargeVerifySql, new RegExp(`'${table}'`)); +} +assert.match(rechargeUpSql, /pay_amount_cents INT UNSIGNED NOT NULL/); +assert.match(rechargeUpSql, /gift_amount_cents INT UNSIGNED NOT NULL DEFAULT 0/); +assert.match(rechargeUpSql, /purchase_limit_per_user INT UNSIGNED/); +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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index bfc5ca8..2cabe50 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -32,7 +32,8 @@ assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/); 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, /2026062421_m07a_wallet_ledger\.up\.sql/); +assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/recharge-service.test.mjs b/backend/tests/recharge-service.test.mjs new file mode 100644 index 0000000..3b2fb3b --- /dev/null +++ b/backend/tests/recharge-service.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { RechargeError, RechargeService } from '../dist/wallets/recharge-service.js'; + +const future = new Date(Date.now() + 86_400_000); +const past = new Date(Date.now() - 86_400_000); + +function createHarness(overrides = {}) { + const state = { + plan: { + id: '31', + tenantId: '7', + storeId: '11', + name: '充 100 送 20', + payAmountCents: 10000, + giftAmountCents: 2000, + scopeType: 'STORE', + startsAt: past, + endsAt: future, + purchaseLimitPerUser: 2, + status: 'ACTIVE', + ...overrides.plan + }, + orders: [], + nextOrderId: 900, + purchaseCount: overrides.purchaseCount ?? 0, + walletCredits: [] + }; + + const connection = { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async execute(sql, params) { + if (sql.includes('FROM qipai_recharge_orders') && sql.includes('client_request_id')) { + const duplicate = state.orders.find((order) => + order.tenantId === String(params[0]) + && order.userId === String(params[1]) + && order.clientRequestId === params[2] + ); + return [duplicate ? [toOrderRow(duplicate)] : [], []]; + } + if (sql.includes('FROM qipai_recharge_plans')) { + if ( + state.plan + && state.plan.tenantId === String(params[0]) + && state.plan.id === String(params[1]) + ) { + return [[state.plan], []]; + } + return [[], []]; + } + if (sql.includes('COUNT(*) AS total')) { + return [[{ total: state.purchaseCount }], []]; + } + if (sql.includes('INSERT INTO qipai_recharge_orders')) { + const order = { + id: String(state.nextOrderId++), + tenantId: String(params[0]), + userId: String(params[1]), + storeId: params[2] === null ? null : String(params[2]), + planId: String(params[3]), + rechargeNo: params[4], + clientRequestId: params[5], + payAmountCents: Number(params[6]), + giftAmountCents: Number(params[7]), + traceId: params[8], + status: 'PENDING_PAYMENT', + paymentId: null + }; + state.orders.push(order); + return [{ insertId: order.id, affectedRows: 1 }, []]; + } + if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) { + const order = state.orders.find((item) => + item.tenantId === String(params[0]) && item.id === String(params[1]) + ); + return [order ? [toOrderRow(order)] : [], []]; + } + if (sql.includes("SET status = 'PAID'")) { + const order = state.orders.find((item) => + item.tenantId === String(params[1]) && item.id === String(params[2]) + ); + order.status = 'PAID'; + order.paymentId = String(params[0]); + return [{ affectedRows: 1 }, []]; + } + if (sql.includes("SET status = 'CREDITED'")) { + const order = state.orders.find((item) => + item.tenantId === String(params[0]) && item.id === String(params[1]) + ); + order.status = 'CREDITED'; + return [{ affectedRows: 1 }, []]; + } + throw new Error(`Unexpected SQL: ${sql}`); + } + }; + + const pool = { + async getConnection() { + return connection; + } + }; + const wallet = { + async credit(input) { + state.walletCredits.push(input); + return { + ledgerEntryId: String(state.walletCredits.length), + cashBalanceCents: input.cashDeltaCents, + giftBalanceCents: input.giftDeltaCents, + idempotent: false + }; + } + }; + + return { service: new RechargeService(pool, wallet), state }; +} + +function toOrderRow(order) { + return { + id: order.id, + userId: order.userId, + storeId: order.storeId, + planId: order.planId, + rechargeNo: order.rechargeNo, + payAmountCents: order.payAmountCents, + giftAmountCents: order.giftAmountCents, + status: order.status + }; +} + +const baseOrder = { + tenantId: '7', + userId: '21', + planId: '31', + storeId: '11', + clientRequestId: 'client-001', + traceId: 'recharge-test' +}; + +{ + const { service, state } = createHarness(); + const created = await service.createRechargeOrder(baseOrder); + assert.equal(created.status, 'PENDING_PAYMENT'); + assert.equal(created.payAmountCents, 10000); + assert.equal(created.giftAmountCents, 2000); + assert.equal(created.idempotent, false); + assert.equal(state.orders.length, 1); + + const duplicate = await service.createRechargeOrder(baseOrder); + assert.equal(duplicate.rechargeOrderId, created.rechargeOrderId); + assert.equal(duplicate.idempotent, true); + assert.equal(state.orders.length, 1); +} + +{ + const { service } = createHarness({ plan: { status: 'DISABLED' } }); + await assert.rejects( + () => service.createRechargeOrder(baseOrder), + (error) => error instanceof RechargeError && error.code === 'RECHARGE_PLAN_DISABLED' + ); +} + +{ + const { service } = createHarness({ plan: { endsAt: past } }); + await assert.rejects( + () => service.createRechargeOrder(baseOrder), + (error) => error instanceof RechargeError && error.code === 'RECHARGE_PLAN_EXPIRED' + ); +} + +{ + const { service } = createHarness({ purchaseCount: 2 }); + await assert.rejects( + () => service.createRechargeOrder(baseOrder), + (error) => error instanceof RechargeError && error.code === 'RECHARGE_LIMIT_REACHED' + ); +} + +{ + const { service, state } = createHarness(); + const created = await service.createRechargeOrder(baseOrder); + const credited = await service.markPaidAndCredit({ + tenantId: '7', + rechargeOrderId: created.rechargeOrderId, + paymentId: '501', + traceId: 'payment-callback' + }); + assert.equal(credited.status, 'CREDITED'); + assert.equal(credited.idempotent, false); + assert.equal(state.orders[0].status, 'CREDITED'); + assert.equal(state.walletCredits.length, 1); + assert.deepEqual( + { + businessType: state.walletCredits[0].businessType, + businessId: state.walletCredits[0].businessId, + cashDeltaCents: state.walletCredits[0].cashDeltaCents, + giftDeltaCents: state.walletCredits[0].giftDeltaCents, + storeId: state.walletCredits[0].storeId + }, + { + businessType: 'RECHARGE', + businessId: created.rechargeOrderId, + cashDeltaCents: 10000, + giftDeltaCents: 2000, + storeId: '11' + } + ); + + const duplicateCallback = await service.markPaidAndCredit({ + tenantId: '7', + rechargeOrderId: created.rechargeOrderId, + paymentId: '501', + traceId: 'payment-callback' + }); + assert.equal(duplicateCallback.idempotent, true); + assert.equal(state.walletCredits.length, 1); +} + +console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.'); diff --git a/database/migrations/2026062422_m07b_recharge_plans.down.sql b/database/migrations/2026062422_m07b_recharge_plans.down.sql new file mode 100644 index 0000000..d7ef98d --- /dev/null +++ b/database/migrations/2026062422_m07b_recharge_plans.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS qipai_recharge_orders; +DROP TABLE IF EXISTS qipai_recharge_plans; + +DELETE FROM qipai_schema_migrations WHERE version = '2026062422'; diff --git a/database/migrations/2026062422_m07b_recharge_plans.up.sql b/database/migrations/2026062422_m07b_recharge_plans.up.sql new file mode 100644 index 0000000..4f8abae --- /dev/null +++ b/database/migrations/2026062422_m07b_recharge_plans.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS qipai_recharge_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, + pay_amount_cents INT UNSIGNED NOT NULL, + gift_amount_cents INT UNSIGNED NOT NULL DEFAULT 0, + scope_type VARCHAR(16) NOT NULL DEFAULT 'TENANT', + starts_at DATETIME(3) NULL, + ends_at DATETIME(3) NULL, + purchase_limit_per_user 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_recharge_plan_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_recharge_plan_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + KEY idx_qipai_recharge_plan_scope (tenant_id, store_id, status, starts_at, ends_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_recharge_orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NULL, + plan_id BIGINT UNSIGNED NOT NULL, + payment_id BIGINT UNSIGNED NULL, + recharge_no VARCHAR(64) NOT NULL, + client_request_id VARCHAR(128) NOT NULL, + pay_amount_cents INT UNSIGNED NOT NULL, + gift_amount_cents INT UNSIGNED NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PAYMENT', + paid_at DATETIME(3) NULL, + credited_at DATETIME(3) NULL, + 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_recharge_order_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_recharge_order_user + FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_recharge_order_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_recharge_order_plan + FOREIGN KEY (plan_id) REFERENCES qipai_recharge_plans(id), + CONSTRAINT fk_qipai_recharge_order_payment + FOREIGN KEY (payment_id) REFERENCES qipai_payments(id), + UNIQUE KEY uq_qipai_recharge_order_no (tenant_id, recharge_no), + UNIQUE KEY uq_qipai_recharge_order_request (tenant_id, user_id, client_request_id), + KEY idx_qipai_recharge_order_user (tenant_id, user_id, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062422', 'm07b_recharge_plans'); diff --git a/database/migrations/2026062422_m07b_recharge_plans.verify.sql b/database/migrations/2026062422_m07b_recharge_plans.verify.sql new file mode 100644 index 0000000..6e2b752 --- /dev/null +++ b/database/migrations/2026062422_m07b_recharge_plans.verify.sql @@ -0,0 +1,29 @@ +SELECT 'qipai_recharge_plans' AS table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name = 'qipai_recharge_plans'; + +SELECT 'qipai_recharge_orders' AS table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name = 'qipai_recharge_orders'; + +SELECT column_name +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_recharge_plans' + AND column_name IN ( + 'pay_amount_cents', 'gift_amount_cents', 'purchase_limit_per_user', + 'scope_type', 'starts_at', 'ends_at', 'status' + ); + +SELECT column_name +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_recharge_orders' + AND column_name IN ( + 'client_request_id', 'pay_amount_cents', 'gift_amount_cents', + 'payment_id', 'paid_at', 'credited_at', 'status' + ); + +SELECT '2026062422' AS version +FROM qipai_schema_migrations +WHERE version = '2026062422';