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
+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;
}