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
+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.');