feat(M07-A): 建立双余额账本
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user