perf: optimize portfolio queries and workflows
This commit is contained in:
1 parent
da98d02661
commit
a7e0a0fe58
45 files changed
+4446
-637
No files matched your search
+129
-91
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Req,
|
||||
Query,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
@@ -13,109 +14,146 @@ import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { transferInput, toBusinessDate } from './validation';
|
||||
import { businessTime } from './calculation';
|
||||
import { movementDeltas } from './movement';
|
||||
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
|
||||
@Controller('api/transfers')
|
||||
export class TransfersController {
|
||||
constructor(private db: Database) {}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
const visibility = { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) };
|
||||
const rows = await this.db.transfer.findMany({
|
||||
where: { userId: r.userId, source: visibility, target: visibility },
|
||||
include: { source: { select: { name: true } }, target: { select: { name: true } } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { createdAt: 'desc' }],
|
||||
@Get() async list(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
const q = pageInput(query);
|
||||
const rows = await this.db.$transaction(async (tx) => {
|
||||
const ids = await transferPageIds(tx, r.userId, r.revealed, q);
|
||||
return tx.transfer.findMany({
|
||||
where: { userId: r.userId, id: { in: ids.map((row) => row.id) } },
|
||||
include: {
|
||||
source: { select: { name: true, kind: true } },
|
||||
target: { select: { name: true, kind: true } },
|
||||
},
|
||||
orderBy: [{ effectiveDate: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
});
|
||||
return rows.map(({ userId, importedFromId, effectiveDate, ...v }) => ({
|
||||
const items = rows.slice(0, q.limit).map(({ userId, importedFromId, effectiveDate, ...v }) => ({
|
||||
...v,
|
||||
date: businessTime(effectiveDate),
|
||||
}));
|
||||
const last = rows[q.limit - 1];
|
||||
return {
|
||||
items,
|
||||
nextCursor: rows.length > q.limit ? encodeCursor(last.effectiveDate, last.id) : null,
|
||||
revealed: r.revealed,
|
||||
};
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
const v = transferInput.parse(body),
|
||||
when = toBusinessDate(v.date);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
if (v.requestId) {
|
||||
const existing = await tx.transfer.findFirst({
|
||||
where: { id: v.requestId, userId: r.userId },
|
||||
});
|
||||
if (existing) {
|
||||
if (
|
||||
existing.sourceId !== v.sourceId ||
|
||||
existing.targetId !== v.targetId ||
|
||||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
||||
!new Decimal(existing.received.toString()).eq(v.received) ||
|
||||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
||||
+existing.effectiveDate !== +when ||
|
||||
existing.notes !== v.notes
|
||||
)
|
||||
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
||||
return { id: existing.id };
|
||||
}
|
||||
return this.db.serial(async (tx) => {
|
||||
// Lock in a consistent order before reading balances or idempotency state.
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId}
|
||||
AND id IN (${Prisma.join([v.sourceId, v.targetId].sort())}) ORDER BY id FOR UPDATE`);
|
||||
if (v.requestId) {
|
||||
const existing = await tx.transfer.findFirst({
|
||||
where: { id: v.requestId, userId: r.userId },
|
||||
});
|
||||
if (existing) {
|
||||
if (
|
||||
existing.operation !== v.operation ||
|
||||
existing.sourceId !== v.sourceId ||
|
||||
existing.targetId !== v.targetId ||
|
||||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
||||
!new Decimal(existing.received.toString()).eq(v.received) ||
|
||||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
||||
+existing.effectiveDate !== +when ||
|
||||
existing.notes !== v.notes
|
||||
)
|
||||
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
||||
return { id: existing.id };
|
||||
}
|
||||
const accounts = await tx.position.findMany({
|
||||
where: {
|
||||
id: { in: [v.sourceId, v.targetId] },
|
||||
userId: r.userId,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
|
||||
},
|
||||
}
|
||||
const metadata = await tx.position.findMany({
|
||||
where: {
|
||||
id: { in: [v.sourceId, v.targetId] },
|
||||
userId: r.userId,
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
});
|
||||
const latest = await latestRevisions(
|
||||
tx,
|
||||
metadata.map((p) => p.id),
|
||||
new Date('9999-01-01'),
|
||||
);
|
||||
const accounts = metadata.map((p) => ({
|
||||
...p,
|
||||
revisions: latest.filter((r) => r.positionId === p.id),
|
||||
}));
|
||||
if (accounts.length !== 2)
|
||||
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
||||
const source = accounts.find((p) => p.id === v.sourceId)!,
|
||||
target = accounts.find((p) => p.id === v.targetId)!;
|
||||
if (
|
||||
source.kind !== 'account' ||
|
||||
source.side !== 'asset' ||
|
||||
(v.operation === 'transfer'
|
||||
? target.kind !== 'account' || target.side !== 'asset'
|
||||
: target.kind !== 'debt' ||
|
||||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
|
||||
)
|
||||
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
|
||||
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
||||
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
|
||||
const before = new Decimal(source.revisions[0].amount.toString());
|
||||
const sourceAfter = before.plus(deltas.source);
|
||||
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
|
||||
if (sourceAfter.isNegative()) throw new BadRequestException('转出账户余额不足(含手续费)');
|
||||
if (after.isNegative()) throw new BadRequestException('收款或还款不能超过剩余债务');
|
||||
if (after.gte('10000000000000000') || sourceAfter.gte('10000000000000000'))
|
||||
throw new BadRequestException('变更后的金额超出支持范围');
|
||||
const outgoing = await tx.revision.create({
|
||||
data: {
|
||||
positionId: source.id,
|
||||
amount: sourceAfter.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: deltas.sourceReason,
|
||||
},
|
||||
});
|
||||
const incoming = await tx.revision.create({
|
||||
data: {
|
||||
positionId: target.id,
|
||||
amount: after.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: deltas.targetReason,
|
||||
},
|
||||
});
|
||||
const row = await tx.transfer.create({
|
||||
data: {
|
||||
id: v.requestId,
|
||||
userId: r.userId,
|
||||
operation: v.operation,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
sourceRevisionId: outgoing.id,
|
||||
targetRevisionId: incoming.id,
|
||||
sourceCurrency: source.currency,
|
||||
targetCurrency: target.currency,
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
},
|
||||
});
|
||||
if (v.operation !== 'transfer')
|
||||
await tx.positionLink.upsert({
|
||||
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
|
||||
create: { sourceId: target.id, targetId: source.id },
|
||||
update: {},
|
||||
});
|
||||
if (accounts.length !== 2)
|
||||
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
||||
const source = accounts.find((p) => p.id === v.sourceId)!,
|
||||
target = accounts.find((p) => p.id === v.targetId)!;
|
||||
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
||||
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||
const debit = new Decimal(v.amount).plus(v.fee),
|
||||
before = new Decimal(source.revisions[0].amount.toString());
|
||||
if (before.lt(debit)) throw new BadRequestException('转出账户余额不足(含手续费)');
|
||||
const after = new Decimal(target.revisions[0].amount.toString()).plus(v.received);
|
||||
if (after.gte('10000000000000000'))
|
||||
throw new BadRequestException('到账后的金额超出支持范围');
|
||||
const outgoing = await tx.revision.create({
|
||||
data: {
|
||||
positionId: source.id,
|
||||
amount: before.minus(debit).toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_out',
|
||||
},
|
||||
});
|
||||
const incoming = await tx.revision.create({
|
||||
data: {
|
||||
positionId: target.id,
|
||||
amount: after.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_in',
|
||||
},
|
||||
});
|
||||
const row = await tx.transfer.create({
|
||||
data: {
|
||||
id: v.requestId,
|
||||
userId: r.userId,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
sourceRevisionId: outgoing.id,
|
||||
targetRevisionId: incoming.id,
|
||||
sourceCurrency: source.currency,
|
||||
targetCurrency: target.currency,
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
},
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
return { id: row.id };
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user