feat(M07-A): 建立双余额账本

This commit is contained in:
Codex
2026-06-24 15:30:33 +08:00
parent 8de3d05da6
commit 63711adafc
9 changed files with 500 additions and 6 deletions
+1 -1
View File
@@ -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",
+7 -3
View File
@@ -40,7 +40,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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<MigrationDirection, readonly string[]> = {
'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(
@@ -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<ResultSetHeader>(
`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<WalletAccountRow[]>(
`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<WalletLedgerRow[]>(
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
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<string, unknown>;
}
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;
}
+16 -1
View File
@@ -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.');
+2 -1
View File
@@ -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);
+134
View File
@@ -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.');
@@ -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';
@@ -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');
@@ -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';