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