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:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "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: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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@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/2026062217_m05c_third_party.up.sql',
'database/migrations/2026062218_m05d_profit_sharing.up.sql', 'database/migrations/2026062218_m05d_profit_sharing.up.sql',
'database/migrations/2026062219_m06b_device_topology.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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/2026062217_m05c_third_party.verify.sql',
'database/migrations/2026062218_m05d_profit_sharing.verify.sql', 'database/migrations/2026062218_m05d_profit_sharing.verify.sql',
'database/migrations/2026062219_m06b_device_topology.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: [ down: [
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
'database/migrations/2026062220_m06c_iot_messages.down.sql', 'database/migrations/2026062220_m06c_iot_messages.down.sql',
'database/migrations/2026062219_m06b_device_topology.down.sql', 'database/migrations/2026062219_m06b_device_topology.down.sql',
'database/migrations/2026062218_m05d_profit_sharing.down.sql', 'database/migrations/2026062218_m05d_profit_sharing.down.sql',
@@ -220,7 +223,8 @@ export async function executeMigrationPlan(
5, 6, 1, 5, 6, 1,
3, 7, 5, 1, 3, 7, 5, 1,
5, 8, 5, 2, 1, 5, 8, 5, 2, 1,
3, 3, 9, 1 3, 3, 9, 1,
1, 1, 1, 4, 7, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( 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 iotMessagesUpSql = read('database/migrations/2026062220_m06c_iot_messages.up.sql');
const iotMessagesDownSql = read('database/migrations/2026062220_m06c_iot_messages.down.sql'); const iotMessagesDownSql = read('database/migrations/2026062220_m06c_iot_messages.down.sql');
const iotMessagesVerifySql = read('database/migrations/2026062220_m06c_iot_messages.verify.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 = [ const coreTables = [
'qipai_schema_migrations', '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, /uq_qipai_iot_event_dedup/);
assert.match(iotMessagesUpSql, /receive_count INT UNSIGNED/); assert.match(iotMessagesUpSql, /receive_count INT UNSIGNED/);
assert.match(iotMessagesUpSql, /PENDING/); 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, /2026062217_m05c_third_party\.up\.sql/);
assert.match(plan.file, /2026062218_m05d_profit_sharing\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); 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';