From 63711adafcf64b3f051d93bab9644826a46bd79d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 24 Jun 2026 15:30:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(M07-A):=20=E5=BB=BA=E7=AB=8B=E5=8F=8C?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E8=B4=A6=E6=9C=AC?= 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 +- backend/src/wallets/wallet-ledger-service.ts | 229 ++++++++++++++++++ backend/tests/migration-contract.test.mjs | 17 +- backend/tests/migration-runner.test.mjs | 3 +- backend/tests/wallet-ledger.test.mjs | 134 ++++++++++ .../2026062421_m07a_wallet_ledger.down.sql | 5 + .../2026062421_m07a_wallet_ledger.up.sql | 76 ++++++ .../2026062421_m07a_wallet_ledger.verify.sql | 30 +++ 9 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 backend/src/wallets/wallet-ledger-service.ts create mode 100644 backend/tests/wallet-ledger.test.mjs create mode 100644 database/migrations/2026062421_m07a_wallet_ledger.down.sql create mode 100644 database/migrations/2026062421_m07a_wallet_ledger.up.sql create mode 100644 database/migrations/2026062421_m07a_wallet_ledger.verify.sql diff --git a/backend/package.json b/backend/package.json index 672ee65..b638292 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" + "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" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 48605f3..b80b09b 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -40,7 +40,8 @@ const migrationFiles: Record = { 'database/migrations/2026062217_m05c_third_party.up.sql', '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/2026062220_m06c_iot_messages.up.sql', + 'database/migrations/2026062421_m07a_wallet_ledger.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -62,9 +63,11 @@ const migrationFiles: Record = { 'database/migrations/2026062217_m05c_third_party.verify.sql', '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/2026062220_m06c_iot_messages.verify.sql', + 'database/migrations/2026062421_m07a_wallet_ledger.verify.sql' ], down: [ + 'database/migrations/2026062421_m07a_wallet_ledger.down.sql', 'database/migrations/2026062220_m06c_iot_messages.down.sql', 'database/migrations/2026062219_m06b_device_topology.down.sql', 'database/migrations/2026062218_m05d_profit_sharing.down.sql', @@ -220,7 +223,8 @@ export async function executeMigrationPlan( 5, 6, 1, 3, 7, 5, 1, 5, 8, 5, 2, 1, - 3, 3, 9, 1 + 3, 3, 9, 1, + 1, 1, 1, 4, 7, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/wallets/wallet-ledger-service.ts b/backend/src/wallets/wallet-ledger-service.ts new file mode 100644 index 0000000..3aaa6b7 --- /dev/null +++ b/backend/src/wallets/wallet-ledger-service.ts @@ -0,0 +1,229 @@ +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; + +export type WalletScopeType = 'TENANT' | 'STORE'; +export type WalletEntryType = 'RECHARGE' | 'GIFT' | 'CONSUME' | 'REFUND' | 'ADJUST'; + +interface WalletAccountRow extends RowDataPacket { + id: string; + tenantId: string; + userId: string; + scopeType: WalletScopeType; + storeId: string | null; + cashBalanceCents: number; + giftBalanceCents: number; + status: string; +} + +interface WalletLedgerRow extends RowDataPacket { + id: string; + cashBalanceAfterCents: number; + giftBalanceAfterCents: number; + cashDeltaCents: number; + giftDeltaCents: number; +} + +export class WalletLedgerError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class WalletLedgerService { + constructor(private readonly pool: MySqlPool) {} + + async credit(input: WalletMutationInput & { + cashDeltaCents?: number; + giftDeltaCents?: number; + }) { + return this.transaction(async (connection) => { + const account = await this.ensureAccount(connection, input, true); + const duplicate = await this.findDuplicate(connection, account, input); + if (duplicate) return ledgerResponse(account, duplicate, true); + const cashDelta = nonNegative(input.cashDeltaCents ?? 0, 'WALLET_CASH_DELTA_INVALID'); + const giftDelta = nonNegative(input.giftDeltaCents ?? 0, 'WALLET_GIFT_DELTA_INVALID'); + if (cashDelta + giftDelta <= 0) throw new WalletLedgerError('WALLET_CREDIT_ZERO'); + return this.applyDelta(connection, account, input, cashDelta, giftDelta, false); + }); + } + + async debit(input: WalletMutationInput & { amountCents: number }) { + return this.transaction(async (connection) => { + const account = await this.ensureAccount(connection, input, true); + const duplicate = await this.findDuplicate(connection, account, input); + if (duplicate) return ledgerResponse(account, duplicate, true); + const amount = nonNegative(input.amountCents, 'WALLET_DEBIT_AMOUNT_INVALID'); + if (amount <= 0) throw new WalletLedgerError('WALLET_DEBIT_AMOUNT_INVALID'); + const giftUsed = Math.min(Number(account.giftBalanceCents), amount); + const cashUsed = amount - giftUsed; + if (cashUsed > Number(account.cashBalanceCents)) { + throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT'); + } + return this.applyDelta(connection, account, input, -cashUsed, -giftUsed, false); + }); + } + + async adjust(input: WalletMutationInput & { + cashDeltaCents?: number; + giftDeltaCents?: number; + }) { + return this.transaction(async (connection) => { + const account = await this.ensureAccount(connection, input, true); + const duplicate = await this.findDuplicate(connection, account, input); + if (duplicate) return ledgerResponse(account, duplicate, true); + const cashDelta = integer(input.cashDeltaCents ?? 0, 'WALLET_CASH_DELTA_INVALID'); + const giftDelta = integer(input.giftDeltaCents ?? 0, 'WALLET_GIFT_DELTA_INVALID'); + if (cashDelta === 0 && giftDelta === 0) throw new WalletLedgerError('WALLET_ADJUST_ZERO'); + return this.applyDelta(connection, account, input, cashDelta, giftDelta, true); + }); + } + + private async applyDelta( + connection: PoolConnection, + account: WalletAccountRow, + input: WalletMutationInput, + cashDelta: number, + giftDelta: number, + allowNegativeDelta: boolean + ) { + if (!allowNegativeDelta && (cashDelta < 0 || giftDelta < 0) && input.entryType !== 'CONSUME') { + throw new WalletLedgerError('WALLET_DELTA_DIRECTION_INVALID'); + } + const nextCash = Number(account.cashBalanceCents) + cashDelta; + const nextGift = Number(account.giftBalanceCents) + giftDelta; + if (nextCash < 0 || nextGift < 0) throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT'); + await connection.execute( + `UPDATE qipai_wallet_accounts + SET cash_balance_cents = ?, gift_balance_cents = ? + WHERE tenant_id = ? AND id = ?`, + [nextCash, nextGift, account.tenantId, account.id] + ); + const [result] = await connection.execute( + `INSERT INTO qipai_wallet_ledger_entries + (tenant_id, account_id, user_id, store_id, business_type, business_id, + entry_type, cash_delta_cents, gift_delta_cents, cash_balance_after_cents, + gift_balance_after_cents, operator_id, trace_id, note, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [account.tenantId, account.id, account.userId, account.storeId, + input.businessType, input.businessId, input.entryType, cashDelta, giftDelta, + nextCash, nextGift, input.operatorId ?? null, input.traceId, + (input.note ?? '').slice(0, 512), JSON.stringify(input.metadata ?? {})] + ); + return { + accountId: account.id, + ledgerId: String(result.insertId), + cashBalanceCents: nextCash, + giftBalanceCents: nextGift, + cashDeltaCents: cashDelta, + giftDeltaCents: giftDelta, + idempotent: false + }; + } + + private async ensureAccount( + connection: PoolConnection, + input: WalletMutationInput, + lock: boolean + ) { + await connection.execute( + `INSERT IGNORE INTO qipai_wallet_accounts + (tenant_id, user_id, scope_type, store_id) + VALUES (?, ?, ?, ?)`, + [input.tenantId, input.userId, input.scopeType, input.storeId ?? null] + ); + const [rows] = await connection.execute( + `SELECT id, tenant_id AS tenantId, user_id AS userId, scope_type AS scopeType, + store_id AS storeId, cash_balance_cents AS cashBalanceCents, + gift_balance_cents AS giftBalanceCents, status + FROM qipai_wallet_accounts + WHERE tenant_id = ? AND user_id = ? AND scope_type = ? + AND ${input.storeId ? 'store_id = ?' : 'store_id IS NULL'} + ${lock ? 'FOR UPDATE' : ''}`, + input.storeId + ? [input.tenantId, input.userId, input.scopeType, input.storeId] + : [input.tenantId, input.userId, input.scopeType] + ); + const account = rows[0]; + if (!account) throw new WalletLedgerError('WALLET_ACCOUNT_NOT_FOUND'); + if (account.status !== 'ACTIVE') throw new WalletLedgerError('WALLET_ACCOUNT_DISABLED'); + return { + ...account, + id: String(account.id), + tenantId: String(account.tenantId), + userId: String(account.userId), + storeId: account.storeId === null ? null : String(account.storeId) + }; + } + + private async findDuplicate( + connection: PoolConnection, + account: WalletAccountRow, + input: WalletMutationInput + ) { + const [rows] = await connection.execute( + `SELECT id, cash_delta_cents AS cashDeltaCents, + gift_delta_cents AS giftDeltaCents, + cash_balance_after_cents AS cashBalanceAfterCents, + gift_balance_after_cents AS giftBalanceAfterCents + FROM qipai_wallet_ledger_entries + WHERE tenant_id = ? AND account_id = ? + AND business_type = ? AND business_id = ? LIMIT 1`, + [account.tenantId, account.id, input.businessType, input.businessId] + ); + return rows[0] ?? null; + } + + 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(); + } + } +} + +export interface WalletMutationInput { + tenantId: string; + userId: string; + scopeType: WalletScopeType; + storeId?: string | null; + businessType: string; + businessId: string; + entryType: WalletEntryType; + operatorId?: string | null; + traceId: string; + note?: string; + metadata?: Record; +} + +function ledgerResponse( + account: WalletAccountRow, + row: WalletLedgerRow, + idempotent: boolean +) { + return { + accountId: String(account.id), + ledgerId: String(row.id), + cashBalanceCents: Number(row.cashBalanceAfterCents), + giftBalanceCents: Number(row.giftBalanceAfterCents), + cashDeltaCents: Number(row.cashDeltaCents), + giftDeltaCents: Number(row.giftDeltaCents), + idempotent + }; +} + +function integer(value: number, code: string) { + if (!Number.isSafeInteger(value)) throw new WalletLedgerError(code); + return value; +} + +function nonNegative(value: number, code: string) { + const parsed = integer(value, code); + if (parsed < 0) throw new WalletLedgerError(code); + return parsed; +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 3fef286..e9e54ef 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -69,6 +69,9 @@ const deviceTopologyVerifySql = read('database/migrations/2026062219_m06b_device const iotMessagesUpSql = read('database/migrations/2026062220_m06c_iot_messages.up.sql'); const iotMessagesDownSql = read('database/migrations/2026062220_m06c_iot_messages.down.sql'); const iotMessagesVerifySql = read('database/migrations/2026062220_m06c_iot_messages.verify.sql'); +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 coreTables = [ 'qipai_schema_migrations', @@ -315,5 +318,17 @@ assert.match(iotMessagesUpSql, /command_id VARCHAR\(13\)/); assert.match(iotMessagesUpSql, /uq_qipai_iot_event_dedup/); assert.match(iotMessagesUpSql, /receive_count INT UNSIGNED/); assert.match(iotMessagesUpSql, /PENDING/); +for (const table of [ + 'qipai_wallet_scope_policies', 'qipai_wallet_accounts', 'qipai_wallet_ledger_entries' +]) { + assert.match(walletUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); + assert.match(walletDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`)); + assert.match(walletVerifySql, new RegExp(`'${table}'`)); +} +assert.match(walletUpSql, /cash_balance_cents INT NOT NULL DEFAULT 0/); +assert.match(walletUpSql, /gift_balance_cents INT NOT NULL DEFAULT 0/); +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 M06-C migration contracts are present.'); +console.log('PASS: M01-B through M07-A migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 8062ff8..bfc5ca8 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -31,7 +31,8 @@ assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/); 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, /2026062220_m06c_iot_messages\.up\.sql/); +assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/wallet-ledger.test.mjs b/backend/tests/wallet-ledger.test.mjs new file mode 100644 index 0000000..8a15ab1 --- /dev/null +++ b/backend/tests/wallet-ledger.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { WalletLedgerError, WalletLedgerService } from '../dist/wallets/wallet-ledger-service.js'; + +const state = { + account: null, + ledger: [], + nextLedgerId: 100 +}; +const pool = { + async getConnection() { + return connection; + } +}; +const connection = { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async execute(sql, params) { + if (sql.includes('INSERT IGNORE INTO qipai_wallet_accounts')) { + if (!state.account) { + state.account = { + id: '51', + tenantId: String(params[0]), + userId: String(params[1]), + scopeType: params[2], + storeId: params[3] === null ? null : String(params[3]), + cashBalanceCents: 0, + giftBalanceCents: 0, + status: 'ACTIVE' + }; + } + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('FROM qipai_wallet_accounts')) { + return [[state.account], []]; + } + if (sql.includes('FROM qipai_wallet_ledger_entries')) { + const duplicate = state.ledger.find((item) => + item.businessType === params[2] && item.businessId === params[3] + ); + return [duplicate ? [duplicate] : [], []]; + } + if (sql.includes('UPDATE qipai_wallet_accounts')) { + state.account.cashBalanceCents = params[0]; + state.account.giftBalanceCents = params[1]; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('INSERT INTO qipai_wallet_ledger_entries')) { + const item = { + id: String(state.nextLedgerId++), + businessType: params[4], + businessId: params[5], + cashDeltaCents: params[7], + giftDeltaCents: params[8], + cashBalanceAfterCents: params[9], + giftBalanceAfterCents: params[10] + }; + state.ledger.push(item); + return [{ insertId: item.id, affectedRows: 1 }, []]; + } + throw new Error(`Unexpected SQL: ${sql}`); + } +}; + +const service = new WalletLedgerService(pool); +const base = { + tenantId: '7', + userId: '21', + scopeType: 'STORE', + storeId: '11', + operatorId: '22', + traceId: 'wallet-test' +}; + +const credited = await service.credit({ + ...base, + businessType: 'RECHARGE', + businessId: 'pay-001', + entryType: 'RECHARGE', + cashDeltaCents: 1000, + giftDeltaCents: 300 +}); +assert.equal(credited.cashBalanceCents, 1000); +assert.equal(credited.giftBalanceCents, 300); + +const duplicate = await service.credit({ + ...base, + businessType: 'RECHARGE', + businessId: 'pay-001', + entryType: 'RECHARGE', + cashDeltaCents: 1000, + giftDeltaCents: 300 +}); +assert.equal(duplicate.idempotent, true); +assert.equal(state.ledger.length, 1); + +const debited = await service.debit({ + ...base, + businessType: 'ORDER', + businessId: 'order-001', + entryType: 'CONSUME', + amountCents: 500 +}); +assert.equal(debited.giftDeltaCents, -300); +assert.equal(debited.cashDeltaCents, -200); +assert.equal(debited.cashBalanceCents, 800); +assert.equal(debited.giftBalanceCents, 0); + +await assert.rejects( + () => service.debit({ + ...base, + businessType: 'ORDER', + businessId: 'order-002', + entryType: 'CONSUME', + amountCents: 9999 + }), + (error) => error instanceof WalletLedgerError + && error.code === 'WALLET_BALANCE_INSUFFICIENT' +); + +const adjusted = await service.adjust({ + ...base, + businessType: 'ADMIN_ADJUST', + businessId: 'adjust-001', + entryType: 'ADJUST', + cashDeltaCents: -100, + giftDeltaCents: 50, + note: 'sanitized adjustment' +}); +assert.equal(adjusted.cashBalanceCents, 700); +assert.equal(adjusted.giftBalanceCents, 50); + +console.log('PASS: M07-A wallet ledger credits, gift-first debit, idempotency and adjustments work.'); diff --git a/database/migrations/2026062421_m07a_wallet_ledger.down.sql b/database/migrations/2026062421_m07a_wallet_ledger.down.sql new file mode 100644 index 0000000..a98457e --- /dev/null +++ b/database/migrations/2026062421_m07a_wallet_ledger.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS qipai_wallet_ledger_entries; +DROP TABLE IF EXISTS qipai_wallet_accounts; +DROP TABLE IF EXISTS qipai_wallet_scope_policies; + +DELETE FROM qipai_schema_migrations WHERE version = '2026062421'; diff --git a/database/migrations/2026062421_m07a_wallet_ledger.up.sql b/database/migrations/2026062421_m07a_wallet_ledger.up.sql new file mode 100644 index 0000000..7d39731 --- /dev/null +++ b/database/migrations/2026062421_m07a_wallet_ledger.up.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS qipai_wallet_scope_policies ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NULL, + scope_type VARCHAR(16) NOT NULL DEFAULT 'TENANT', + enabled TINYINT(1) NOT NULL DEFAULT 1, + 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_wallet_policy_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_wallet_policy_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_wallet_policy_store (tenant_id, store_id), + KEY idx_qipai_wallet_policy_tenant (tenant_id, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_wallet_accounts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + scope_type VARCHAR(16) NOT NULL, + store_id BIGINT UNSIGNED NULL, + cash_balance_cents INT NOT NULL DEFAULT 0, + gift_balance_cents INT NOT NULL DEFAULT 0, + 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), + CONSTRAINT fk_qipai_wallet_account_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_wallet_account_user + FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_wallet_account_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_wallet_account_scope + (tenant_id, user_id, scope_type, store_id), + KEY idx_qipai_wallet_account_user (tenant_id, user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_wallet_ledger_entries ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + account_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NULL, + business_type VARCHAR(64) NOT NULL, + business_id VARCHAR(128) NOT NULL, + entry_type VARCHAR(32) NOT NULL, + cash_delta_cents INT NOT NULL DEFAULT 0, + gift_delta_cents INT NOT NULL DEFAULT 0, + cash_balance_after_cents INT NOT NULL, + gift_balance_after_cents INT NOT NULL, + operator_id BIGINT UNSIGNED NULL, + trace_id VARCHAR(128) NOT NULL, + note VARCHAR(512) NOT NULL DEFAULT '', + metadata JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_wallet_ledger_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_wallet_ledger_account + FOREIGN KEY (account_id) REFERENCES qipai_wallet_accounts(id), + CONSTRAINT fk_qipai_wallet_ledger_user + FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_wallet_ledger_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_wallet_ledger_operator + FOREIGN KEY (operator_id) REFERENCES qipai_users(id), + UNIQUE KEY uq_qipai_wallet_ledger_business + (tenant_id, account_id, business_type, business_id), + KEY idx_qipai_wallet_ledger_user (tenant_id, user_id, created_at), + KEY idx_qipai_wallet_ledger_trace (tenant_id, trace_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062421', 'm07a_wallet_ledger'); diff --git a/database/migrations/2026062421_m07a_wallet_ledger.verify.sql b/database/migrations/2026062421_m07a_wallet_ledger.verify.sql new file mode 100644 index 0000000..3990125 --- /dev/null +++ b/database/migrations/2026062421_m07a_wallet_ledger.verify.sql @@ -0,0 +1,30 @@ +SELECT 'qipai_wallet_scope_policies' AS table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name = 'qipai_wallet_scope_policies'; + +SELECT 'qipai_wallet_accounts' AS table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name = 'qipai_wallet_accounts'; + +SELECT 'qipai_wallet_ledger_entries' AS table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name = 'qipai_wallet_ledger_entries'; + +SELECT column_name +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_wallet_accounts' + AND column_name IN ('cash_balance_cents', 'gift_balance_cents', 'scope_type', 'store_id'); + +SELECT column_name +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_wallet_ledger_entries' + AND column_name IN ( + 'business_type', 'business_id', 'cash_delta_cents', 'gift_delta_cents', + 'cash_balance_after_cents', 'gift_balance_after_cents', 'trace_id' + ); + +SELECT '2026062421' AS version +FROM qipai_schema_migrations +WHERE version = '2026062421';