feat(trading-service): add burn system, Kafka events, and idempotency
- Add trading burn system with black hole, share pool, and price calculation - Implement per-minute auto burn and sell burn with multiplier - Add Kafka event publishing via outbox pattern (order, trade, burn events) - Add user.registered consumer to auto-create trading accounts - Implement Redis + DB dual idempotency for event processing - Add price, burn, and asset API controllers - Add migrations for burn tables and processed events Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f3d4799efc
commit
e1fb70e2ee
|
|
@ -47,14 +47,14 @@ CREATE TABLE "orders" (
|
||||||
CREATE TABLE "trades" (
|
CREATE TABLE "trades" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"tradeNo" TEXT NOT NULL,
|
"tradeNo" TEXT NOT NULL,
|
||||||
"buyOrderId" TEXT NOT NULL,
|
"buy_order_id" TEXT NOT NULL,
|
||||||
"sellOrderId" TEXT NOT NULL,
|
"sell_order_id" TEXT NOT NULL,
|
||||||
"buyerSequence" TEXT NOT NULL,
|
"buyer_sequence" TEXT NOT NULL,
|
||||||
"sellerSequence" TEXT NOT NULL,
|
"seller_sequence" TEXT NOT NULL,
|
||||||
"price" DECIMAL(30,18) NOT NULL,
|
"price" DECIMAL(30,18) NOT NULL,
|
||||||
"quantity" DECIMAL(30,8) NOT NULL,
|
"quantity" DECIMAL(30,8) NOT NULL,
|
||||||
"amount" DECIMAL(30,8) NOT NULL,
|
"amount" DECIMAL(30,8) NOT NULL,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "trades_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "trades_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
@ -229,13 +229,13 @@ CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt" DESC);
|
||||||
CREATE UNIQUE INDEX "trades_tradeNo_key" ON "trades"("tradeNo");
|
CREATE UNIQUE INDEX "trades_tradeNo_key" ON "trades"("tradeNo");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trades_buyerSequence_idx" ON "trades"("buyerSequence");
|
CREATE INDEX "trades_buyer_sequence_idx" ON "trades"("buyer_sequence");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trades_sellerSequence_idx" ON "trades"("sellerSequence");
|
CREATE INDEX "trades_seller_sequence_idx" ON "trades"("seller_sequence");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trades_createdAt_idx" ON "trades"("createdAt" DESC);
|
CREATE INDEX "trades_created_at_idx" ON "trades"("created_at" DESC);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "trading_transactions_accountSequence_createdAt_idx" ON "trading_transactions"("accountSequence", "createdAt" DESC);
|
CREATE INDEX "trading_transactions_accountSequence_createdAt_idx" ON "trading_transactions"("accountSequence", "createdAt" DESC);
|
||||||
|
|
@ -307,7 +307,7 @@ CREATE INDEX "outbox_events_created_at_idx" ON "outbox_events"("created_at");
|
||||||
ALTER TABLE "orders" ADD CONSTRAINT "orders_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "trades" ADD CONSTRAINT "trades_buyOrderId_fkey" FOREIGN KEY ("buyOrderId") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "trades" ADD CONSTRAINT "trades_buy_order_id_fkey" FOREIGN KEY ("buy_order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "trading_transactions" ADD CONSTRAINT "trading_transactions_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "trading_transactions" ADD CONSTRAINT "trading_transactions_accountSequence_fkey" FOREIGN KEY ("accountSequence") REFERENCES "trading_accounts"("accountSequence") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
-- ============================================================================
|
||||||
|
-- trading-service 添加交易销毁系统
|
||||||
|
-- 包含:交易配置、黑洞账户、积分股池、价格快照、订单销毁字段
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ==================== 交易配置表 ====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "trading_configs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"total_shares" DECIMAL(30,8) NOT NULL DEFAULT 100020000000,
|
||||||
|
"burn_target" DECIMAL(30,8) NOT NULL DEFAULT 10000000000,
|
||||||
|
"burn_period_minutes" INTEGER NOT NULL DEFAULT 2102400,
|
||||||
|
"minute_burn_rate" DECIMAL(30,18) NOT NULL DEFAULT 4756.468797564687,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"activated_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "trading_configs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== 黑洞账户(销毁池)====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "black_holes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"total_burned" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"target_burn" DECIMAL(30,8) NOT NULL,
|
||||||
|
"remaining_burn" DECIMAL(30,8) NOT NULL,
|
||||||
|
"last_burn_minute" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "black_holes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "burn_records" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"black_hole_id" TEXT NOT NULL,
|
||||||
|
"burn_minute" TIMESTAMP(3) NOT NULL,
|
||||||
|
"burn_amount" DECIMAL(30,18) NOT NULL,
|
||||||
|
"remaining_target" DECIMAL(30,8) NOT NULL,
|
||||||
|
"source_type" TEXT,
|
||||||
|
"source_account_seq" TEXT,
|
||||||
|
"source_order_no" TEXT,
|
||||||
|
"memo" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "burn_records_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_burn_minute_idx" ON "burn_records"("burn_minute");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_source_account_seq_idx" ON "burn_records"("source_account_seq");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_source_order_no_idx" ON "burn_records"("source_order_no");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "burn_records_source_type_idx" ON "burn_records"("source_type");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "burn_records" ADD CONSTRAINT "burn_records_black_hole_id_fkey" FOREIGN KEY ("black_hole_id") REFERENCES "black_holes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ==================== 积分股池(绿积分池)====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "share_pools" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"green_points" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"total_inflow" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"total_outflow" DECIMAL(30,8) NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "share_pools_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "share_pool_transactions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"pool_id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(30,8) NOT NULL,
|
||||||
|
"balance_before" DECIMAL(30,8) NOT NULL,
|
||||||
|
"balance_after" DECIMAL(30,8) NOT NULL,
|
||||||
|
"reference_id" TEXT,
|
||||||
|
"reference_type" TEXT,
|
||||||
|
"memo" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "share_pool_transactions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "share_pool_transactions_pool_id_created_at_idx" ON "share_pool_transactions"("pool_id", "created_at" DESC);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "share_pool_transactions" ADD CONSTRAINT "share_pool_transactions_pool_id_fkey" FOREIGN KEY ("pool_id") REFERENCES "share_pools"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ==================== 价格快照 ====================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "price_snapshots" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"snapshot_time" TIMESTAMP(3) NOT NULL,
|
||||||
|
"price" DECIMAL(30,18) NOT NULL,
|
||||||
|
"green_points" DECIMAL(30,8) NOT NULL,
|
||||||
|
"black_hole_amount" DECIMAL(30,8) NOT NULL,
|
||||||
|
"circulation_pool" DECIMAL(30,8) NOT NULL,
|
||||||
|
"effective_denominator" DECIMAL(30,8) NOT NULL,
|
||||||
|
"minute_burn_rate" DECIMAL(30,18) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "price_snapshots_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "price_snapshots_snapshot_time_key" ON "price_snapshots"("snapshot_time");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "price_snapshots_snapshot_time_idx" ON "price_snapshots"("snapshot_time" DESC);
|
||||||
|
|
||||||
|
-- ==================== 订单表添加销毁相关字段 ====================
|
||||||
|
|
||||||
|
-- AlterTable: 添加销毁相关字段到 orders 表
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "burn_quantity" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "burn_multiplier" DECIMAL(30,18) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "effective_quantity" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- ==================== 成交记录表添加销毁相关字段 ====================
|
||||||
|
|
||||||
|
-- 添加销毁相关字段到 trades 表
|
||||||
|
ALTER TABLE "trades" ADD COLUMN "burn_quantity" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "trades" ADD COLUMN "effective_qty" DECIMAL(30,8) NOT NULL DEFAULT 0;
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- ============================================================================
|
||||||
|
-- trading-service 添加已处理事件表(幂等性支持)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "processed_events" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"event_id" TEXT NOT NULL,
|
||||||
|
"event_type" TEXT NOT NULL,
|
||||||
|
"source_service" TEXT NOT NULL,
|
||||||
|
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "processed_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "processed_events_event_id_key" ON "processed_events"("event_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "processed_events_event_id_idx" ON "processed_events"("event_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "processed_events_processed_at_idx" ON "processed_events"("processed_at");
|
||||||
|
|
@ -7,6 +7,125 @@ datasource db {
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 交易配置 ====================
|
||||||
|
|
||||||
|
// 交易全局配置
|
||||||
|
model TradingConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
// 总积分股数量: 100.02B
|
||||||
|
totalShares Decimal @default(100020000000) @map("total_shares") @db.Decimal(30, 8)
|
||||||
|
// 目标销毁量: 100亿 (4年销毁完)
|
||||||
|
burnTarget Decimal @default(10000000000) @map("burn_target") @db.Decimal(30, 8)
|
||||||
|
// 销毁周期: 4年 (分钟数) 365*4*1440 = 2102400
|
||||||
|
burnPeriodMinutes Int @default(2102400) @map("burn_period_minutes")
|
||||||
|
// 每分钟基础销毁量: 100亿÷(365*4*1440) = 4756.468797564687
|
||||||
|
minuteBurnRate Decimal @default(4756.468797564687) @map("minute_burn_rate") @db.Decimal(30, 18)
|
||||||
|
// 是否启用交易
|
||||||
|
isActive Boolean @default(false) @map("is_active")
|
||||||
|
// 启动时间
|
||||||
|
activatedAt DateTime? @map("activated_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("trading_configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 黑洞账户(销毁池)====================
|
||||||
|
|
||||||
|
// 黑洞账户
|
||||||
|
model BlackHole {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
totalBurned Decimal @default(0) @map("total_burned") @db.Decimal(30, 8) // 已销毁总量
|
||||||
|
targetBurn Decimal @map("target_burn") @db.Decimal(30, 8) // 目标销毁量 (10B)
|
||||||
|
remainingBurn Decimal @map("remaining_burn") @db.Decimal(30, 8) // 剩余待销毁
|
||||||
|
lastBurnMinute DateTime? @map("last_burn_minute")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
records BurnRecord[]
|
||||||
|
|
||||||
|
@@map("black_holes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁记录
|
||||||
|
model BurnRecord {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
blackHoleId String @map("black_hole_id")
|
||||||
|
burnMinute DateTime @map("burn_minute")
|
||||||
|
burnAmount Decimal @map("burn_amount") @db.Decimal(30, 18)
|
||||||
|
remainingTarget Decimal @map("remaining_target") @db.Decimal(30, 8) // 销毁后剩余目标
|
||||||
|
|
||||||
|
// 来源信息
|
||||||
|
sourceType String? @map("source_type") // MINUTE_BURN (每分钟销毁), SELL_BURN (卖出销毁)
|
||||||
|
sourceAccountSeq String? @map("source_account_seq") // 来源账户序列号(卖出时)
|
||||||
|
sourceOrderNo String? @map("source_order_no") // 来源订单号(卖出时)
|
||||||
|
|
||||||
|
memo String? @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
blackHole BlackHole @relation(fields: [blackHoleId], references: [id])
|
||||||
|
|
||||||
|
@@index([burnMinute])
|
||||||
|
@@index([sourceAccountSeq])
|
||||||
|
@@index([sourceOrderNo])
|
||||||
|
@@index([sourceType])
|
||||||
|
@@map("burn_records")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 积分股池(绿积分池)====================
|
||||||
|
|
||||||
|
// 积分股池(存储绿积分用于计算价格)
|
||||||
|
model SharePool {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
// 绿积分总量(用于价格计算的分子)
|
||||||
|
greenPoints Decimal @default(0) @map("green_points") @db.Decimal(30, 8)
|
||||||
|
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
|
||||||
|
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
transactions SharePoolTransaction[]
|
||||||
|
|
||||||
|
@@map("share_pools")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 积分股池交易记录
|
||||||
|
model SharePoolTransaction {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
poolId String @map("pool_id")
|
||||||
|
type String // INJECT (注入), TRADE_IN (交易流入), TRADE_OUT (交易流出)
|
||||||
|
amount Decimal @db.Decimal(30, 8)
|
||||||
|
balanceBefore Decimal @map("balance_before") @db.Decimal(30, 8)
|
||||||
|
balanceAfter Decimal @map("balance_after") @db.Decimal(30, 8)
|
||||||
|
referenceId String? @map("reference_id")
|
||||||
|
referenceType String? @map("reference_type")
|
||||||
|
memo String? @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
pool SharePool @relation(fields: [poolId], references: [id])
|
||||||
|
|
||||||
|
@@index([poolId, createdAt(sort: Desc)])
|
||||||
|
@@map("share_pool_transactions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 价格快照 ====================
|
||||||
|
|
||||||
|
// 价格快照(每分钟)
|
||||||
|
model PriceSnapshot {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
snapshotTime DateTime @unique @map("snapshot_time")
|
||||||
|
price Decimal @db.Decimal(30, 18) // 当时价格
|
||||||
|
greenPoints Decimal @map("green_points") @db.Decimal(30, 8) // 绿积分(股池)
|
||||||
|
blackHoleAmount Decimal @map("black_hole_amount") @db.Decimal(30, 8) // 黑洞数量
|
||||||
|
circulationPool Decimal @map("circulation_pool") @db.Decimal(30, 8) // 流通池
|
||||||
|
effectiveDenominator Decimal @map("effective_denominator") @db.Decimal(30, 8) // 有效分母
|
||||||
|
minuteBurnRate Decimal @map("minute_burn_rate") @db.Decimal(30, 18) // 当时的每分钟销毁率
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([snapshotTime(sort: Desc)])
|
||||||
|
@@map("price_snapshots")
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 交易账户 ====================
|
// ==================== 交易账户 ====================
|
||||||
|
|
||||||
// 用户交易账户
|
// 用户交易账户
|
||||||
|
|
@ -43,6 +162,10 @@ model Order {
|
||||||
remainingQuantity Decimal @db.Decimal(30, 8) // 剩余数量
|
remainingQuantity Decimal @db.Decimal(30, 8) // 剩余数量
|
||||||
averagePrice Decimal @default(0) @db.Decimal(30, 18) // 平均成交价
|
averagePrice Decimal @default(0) @db.Decimal(30, 18) // 平均成交价
|
||||||
totalAmount Decimal @default(0) @db.Decimal(30, 8) // 总成交金额
|
totalAmount Decimal @default(0) @db.Decimal(30, 8) // 总成交金额
|
||||||
|
// 卖出销毁相关字段
|
||||||
|
burnQuantity Decimal @default(0) @map("burn_quantity") @db.Decimal(30, 8) // 卖出销毁量
|
||||||
|
burnMultiplier Decimal @default(0) @map("burn_multiplier") @db.Decimal(30, 18) // 销毁倍数
|
||||||
|
effectiveQuantity Decimal @default(0) @map("effective_quantity") @db.Decimal(30, 8) // 有效卖出量(含销毁)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
cancelledAt DateTime?
|
cancelledAt DateTime?
|
||||||
|
|
@ -61,14 +184,16 @@ model Order {
|
||||||
model Trade {
|
model Trade {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tradeNo String @unique
|
tradeNo String @unique
|
||||||
buyOrderId String
|
buyOrderId String @map("buy_order_id")
|
||||||
sellOrderId String
|
sellOrderId String @map("sell_order_id")
|
||||||
buyerSequence String
|
buyerSequence String @map("buyer_sequence")
|
||||||
sellerSequence String
|
sellerSequence String @map("seller_sequence")
|
||||||
price Decimal @db.Decimal(30, 18)
|
price Decimal @db.Decimal(30, 18)
|
||||||
quantity Decimal @db.Decimal(30, 8)
|
quantity Decimal @db.Decimal(30, 8) // 实际成交量
|
||||||
amount Decimal @db.Decimal(30, 8) // price * quantity
|
burnQuantity Decimal @default(0) @map("burn_quantity") @db.Decimal(30, 8) // 卖出销毁量
|
||||||
createdAt DateTime @default(now())
|
effectiveQty Decimal @default(0) @map("effective_qty") @db.Decimal(30, 8) // 有效量(quantity + burnQuantity)
|
||||||
|
amount Decimal @db.Decimal(30, 8) // effectiveQty * price(卖出交易额)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
buyOrder Order @relation(fields: [buyOrderId], references: [id])
|
buyOrder Order @relation(fields: [buyOrderId], references: [id])
|
||||||
|
|
||||||
|
|
@ -281,3 +406,18 @@ model OutboxEvent {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("outbox_events")
|
@@map("outbox_events")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 已处理事件(幂等性)====================
|
||||||
|
|
||||||
|
// 已处理事件记录(用于消费者幂等性检查)
|
||||||
|
model ProcessedEvent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
eventId String @unique @map("event_id") // 事件唯一ID
|
||||||
|
eventType String @map("event_type") // 事件类型
|
||||||
|
sourceService String @map("source_service") // 来源服务
|
||||||
|
processedAt DateTime @default(now()) @map("processed_at")
|
||||||
|
|
||||||
|
@@index([eventId])
|
||||||
|
@@index([processedAt])
|
||||||
|
@@map("processed_events")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,20 @@ import { TradingController } from './controllers/trading.controller';
|
||||||
import { TransferController } from './controllers/transfer.controller';
|
import { TransferController } from './controllers/transfer.controller';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
import { AdminController } from './controllers/admin.controller';
|
import { AdminController } from './controllers/admin.controller';
|
||||||
|
import { PriceController } from './controllers/price.controller';
|
||||||
|
import { BurnController } from './controllers/burn.controller';
|
||||||
|
import { AssetController } from './controllers/asset.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule, InfrastructureModule],
|
imports: [ApplicationModule, InfrastructureModule],
|
||||||
controllers: [TradingController, TransferController, HealthController, AdminController],
|
controllers: [
|
||||||
|
TradingController,
|
||||||
|
TransferController,
|
||||||
|
HealthController,
|
||||||
|
AdminController,
|
||||||
|
PriceController,
|
||||||
|
BurnController,
|
||||||
|
AssetController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Controller, Get, Param, Query, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AssetService } from '../../application/services/asset.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('Asset')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('asset')
|
||||||
|
export class AssetController {
|
||||||
|
constructor(private readonly assetService: AssetService) {}
|
||||||
|
|
||||||
|
@Get('my')
|
||||||
|
@ApiOperation({ summary: '获取我的资产显示' })
|
||||||
|
@ApiQuery({ name: 'dailyAllocation', required: false, type: String, description: '每日分配量(可选)' })
|
||||||
|
async getMyAsset(@Req() req: any, @Query('dailyAllocation') dailyAllocation?: string) {
|
||||||
|
const accountSequence = req.user?.accountSequence;
|
||||||
|
if (!accountSequence) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await this.assetService.getAssetDisplay(accountSequence, dailyAllocation);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('account/:accountSequence')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取指定账户资产显示' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
|
@ApiQuery({ name: 'dailyAllocation', required: false, type: String, description: '每日分配量(可选)' })
|
||||||
|
async getAccountAsset(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query('dailyAllocation') dailyAllocation?: string,
|
||||||
|
) {
|
||||||
|
const asset = await this.assetService.getAssetDisplay(accountSequence, dailyAllocation);
|
||||||
|
if (!asset) {
|
||||||
|
return { message: 'Account not found' };
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('estimate-sell')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '预估卖出收益' })
|
||||||
|
@ApiQuery({ name: 'quantity', required: true, type: String, description: '卖出数量' })
|
||||||
|
async estimateSellProceeds(@Query('quantity') quantity: string) {
|
||||||
|
return this.assetService.estimateSellProceeds(quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('market')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取市场概览' })
|
||||||
|
async getMarketOverview() {
|
||||||
|
return this.assetService.getMarketOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('growth-per-second')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '计算资产每秒增长量' })
|
||||||
|
@ApiQuery({ name: 'dailyAllocation', required: true, type: String, description: '每日分配量' })
|
||||||
|
async calculateGrowthPerSecond(@Query('dailyAllocation') dailyAllocation: string) {
|
||||||
|
const perSecond = this.assetService.calculateAssetGrowthPerSecond(dailyAllocation);
|
||||||
|
return { dailyAllocation, assetGrowthPerSecond: perSecond };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { BurnService } from '../../application/services/burn.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('Burn')
|
||||||
|
@Controller('burn')
|
||||||
|
export class BurnController {
|
||||||
|
constructor(private readonly burnService: BurnService) {}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取销毁状态' })
|
||||||
|
async getBurnStatus() {
|
||||||
|
return this.burnService.getBurnStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('records')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取销毁记录' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'sourceType', required: false, enum: ['MINUTE_BURN', 'SELL_BURN'] })
|
||||||
|
async getBurnRecords(
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
@Query('sourceType') sourceType?: 'MINUTE_BURN' | 'SELL_BURN',
|
||||||
|
) {
|
||||||
|
return this.burnService.getBurnRecords(page ?? 1, pageSize ?? 50, sourceType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { PriceService } from '../../application/services/price.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('Price')
|
||||||
|
@Controller('price')
|
||||||
|
export class PriceController {
|
||||||
|
constructor(private readonly priceService: PriceService) {}
|
||||||
|
|
||||||
|
@Get('current')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取当前价格信息' })
|
||||||
|
async getCurrentPrice() {
|
||||||
|
return this.priceService.getCurrentPrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('latest')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取最新价格快照' })
|
||||||
|
async getLatestSnapshot() {
|
||||||
|
const snapshot = await this.priceService.getLatestSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
return { message: 'No price snapshot available' };
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('history')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取价格历史' })
|
||||||
|
@ApiQuery({ name: 'startTime', required: true, type: String, description: 'ISO datetime' })
|
||||||
|
@ApiQuery({ name: 'endTime', required: true, type: String, description: 'ISO datetime' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
async getPriceHistory(
|
||||||
|
@Query('startTime') startTime: string,
|
||||||
|
@Query('endTime') endTime: string,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
) {
|
||||||
|
return this.priceService.getPriceHistory(
|
||||||
|
new Date(startTime),
|
||||||
|
new Date(endTime),
|
||||||
|
limit ?? 1440,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,25 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
import { OrderService } from './services/order.service';
|
import { OrderService } from './services/order.service';
|
||||||
import { TransferService } from './services/transfer.service';
|
import { TransferService } from './services/transfer.service';
|
||||||
|
import { PriceService } from './services/price.service';
|
||||||
|
import { BurnService } from './services/burn.service';
|
||||||
|
import { AssetService } from './services/asset.service';
|
||||||
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
|
import { BurnScheduler } from './schedulers/burn.scheduler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot(), InfrastructureModule],
|
imports: [ScheduleModule.forRoot(), InfrastructureModule],
|
||||||
providers: [OrderService, TransferService, OutboxScheduler],
|
providers: [
|
||||||
exports: [OrderService, TransferService],
|
// Services
|
||||||
|
PriceService,
|
||||||
|
BurnService,
|
||||||
|
AssetService,
|
||||||
|
OrderService,
|
||||||
|
TransferService,
|
||||||
|
// Schedulers
|
||||||
|
OutboxScheduler,
|
||||||
|
BurnScheduler,
|
||||||
|
],
|
||||||
|
exports: [OrderService, TransferService, PriceService, BurnService, AssetService],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { BurnService } from '../services/burn.service';
|
||||||
|
import { PriceService } from '../services/price.service';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BurnScheduler implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(BurnScheduler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly burnService: BurnService,
|
||||||
|
private readonly priceService: PriceService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Burn scheduler initialized');
|
||||||
|
|
||||||
|
// 初始化销毁系统
|
||||||
|
try {
|
||||||
|
await this.burnService.initialize();
|
||||||
|
this.logger.log('Burn system initialized');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to initialize burn system', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟执行销毁
|
||||||
|
* 每分钟销毁量 = 100亿 ÷ (365×4×1440) = 4756.468797564687 进黑洞
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async executeMinuteBurn(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const burnAmount = await this.burnService.executeMinuteBurn();
|
||||||
|
if (!burnAmount.isZero()) {
|
||||||
|
this.logger.debug(`Minute burn completed: ${burnAmount.toFixed(8)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to execute minute burn', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟创建价格快照
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async createPriceSnapshot(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.priceService.createSnapshot();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to create price snapshot', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每天清理旧的价格快照(保留30天)
|
||||||
|
*/
|
||||||
|
@Cron('0 3 * * *') // 每天凌晨3点
|
||||||
|
async cleanupOldSnapshots(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock('snapshot:cleanup:lock', 300);
|
||||||
|
if (!lockValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 通过 PriceService 调用 repository 清理
|
||||||
|
this.logger.log('Starting cleanup of old price snapshots');
|
||||||
|
// 这里可以添加清理逻辑
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to cleanup old snapshots', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock('snapshot:cleanup:lock', lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每小时记录销毁状态日志
|
||||||
|
*/
|
||||||
|
@Cron('0 * * * *') // 每小时整点
|
||||||
|
async logBurnStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await this.burnService.getBurnStatus();
|
||||||
|
this.logger.log(
|
||||||
|
`Burn status: burned=${status.totalBurned}, ` +
|
||||||
|
`remaining=${status.remainingBurn}, ` +
|
||||||
|
`progress=${status.burnProgress}%, ` +
|
||||||
|
`minuteRate=${status.minuteBurnRate}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to log burn status', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './outbox.scheduler';
|
export * from './outbox.scheduler';
|
||||||
|
export * from './burn.scheduler';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TradingCalculatorService } from '../../domain/services/trading-calculator.service';
|
||||||
|
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||||
|
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { SharePoolRepository } from '../../infrastructure/persistence/repositories/share-pool.repository';
|
||||||
|
import { PriceService } from './price.service';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface AssetDisplay {
|
||||||
|
// 账户积分股余额
|
||||||
|
shareBalance: string;
|
||||||
|
// 账户现金余额
|
||||||
|
cashBalance: string;
|
||||||
|
// 冻结积分股
|
||||||
|
frozenShares: string;
|
||||||
|
// 冻结现金
|
||||||
|
frozenCash: string;
|
||||||
|
// 可用积分股
|
||||||
|
availableShares: string;
|
||||||
|
// 可用现金
|
||||||
|
availableCash: string;
|
||||||
|
// 当前价格
|
||||||
|
currentPrice: string;
|
||||||
|
// 销毁倍数
|
||||||
|
burnMultiplier: string;
|
||||||
|
// 有效积分股(含销毁加成)
|
||||||
|
effectiveShares: string;
|
||||||
|
// 资产显示值 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
displayAssetValue: string;
|
||||||
|
// 每秒增长量(需要外部传入每日分配量)
|
||||||
|
assetGrowthPerSecond: string;
|
||||||
|
// 累计买入
|
||||||
|
totalBought: string;
|
||||||
|
// 累计卖出
|
||||||
|
totalSold: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetService {
|
||||||
|
private readonly logger = new Logger(AssetService.name);
|
||||||
|
private readonly calculator = new TradingCalculatorService();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tradingAccountRepository: TradingAccountRepository,
|
||||||
|
private readonly blackHoleRepository: BlackHoleRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly sharePoolRepository: SharePoolRepository,
|
||||||
|
private readonly priceService: PriceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户资产显示
|
||||||
|
* 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
*
|
||||||
|
* @param accountSequence 账户序号
|
||||||
|
* @param dailyAllocation 用户每天分配的积分股(可选,用于计算每秒增长)
|
||||||
|
*/
|
||||||
|
async getAssetDisplay(
|
||||||
|
accountSequence: string,
|
||||||
|
dailyAllocation?: string,
|
||||||
|
): Promise<AssetDisplay | null> {
|
||||||
|
const account = await this.tradingAccountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前价格信息
|
||||||
|
const priceInfo = await this.priceService.getCurrentPrice();
|
||||||
|
const price = new Money(priceInfo.price);
|
||||||
|
const burnMultiplier = new Decimal(priceInfo.burnMultiplier);
|
||||||
|
|
||||||
|
// 计算有效积分股 = 余额 × (1 + 倍数)
|
||||||
|
const multiplierFactor = new Decimal(1).plus(burnMultiplier);
|
||||||
|
const effectiveShares = account.shareBalance.value.times(multiplierFactor);
|
||||||
|
|
||||||
|
// 计算资产显示值
|
||||||
|
const displayAssetValue = this.calculator.calculateDisplayAssetValue(
|
||||||
|
account.shareBalance,
|
||||||
|
burnMultiplier,
|
||||||
|
price,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算每秒增长量
|
||||||
|
let assetGrowthPerSecond = Money.zero();
|
||||||
|
if (dailyAllocation) {
|
||||||
|
const dailyAmount = new Money(dailyAllocation);
|
||||||
|
assetGrowthPerSecond = this.calculator.calculateAssetGrowthPerSecond(dailyAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shareBalance: account.shareBalance.toFixed(8),
|
||||||
|
cashBalance: account.cashBalance.toFixed(8),
|
||||||
|
frozenShares: account.frozenShares.toFixed(8),
|
||||||
|
frozenCash: account.frozenCash.toFixed(8),
|
||||||
|
availableShares: account.availableShares.toFixed(8),
|
||||||
|
availableCash: account.availableCash.toFixed(8),
|
||||||
|
currentPrice: price.toFixed(18),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
effectiveShares: new Money(effectiveShares).toFixed(8),
|
||||||
|
displayAssetValue: displayAssetValue.toFixed(8),
|
||||||
|
assetGrowthPerSecond: assetGrowthPerSecond.toFixed(18),
|
||||||
|
totalBought: account.totalBought.toFixed(8),
|
||||||
|
totalSold: account.totalSold.toFixed(8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算资产每秒增长量
|
||||||
|
* 资产每秒增长量 = 用户每天分配的积分股 ÷ 24 ÷ 60 ÷ 60
|
||||||
|
*/
|
||||||
|
calculateAssetGrowthPerSecond(dailyAllocation: string): string {
|
||||||
|
const dailyAmount = new Money(dailyAllocation);
|
||||||
|
const perSecond = this.calculator.calculateAssetGrowthPerSecond(dailyAmount);
|
||||||
|
return perSecond.toFixed(18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预估卖出收益
|
||||||
|
* 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
*/
|
||||||
|
async estimateSellProceeds(sellQuantity: string): Promise<{
|
||||||
|
sellQuantity: string;
|
||||||
|
burnQuantity: string;
|
||||||
|
effectiveQuantity: string;
|
||||||
|
price: string;
|
||||||
|
proceeds: string;
|
||||||
|
burnMultiplier: string;
|
||||||
|
}> {
|
||||||
|
const quantity = new Money(sellQuantity);
|
||||||
|
const result = await this.priceService.calculateSellAmount(quantity);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sellQuantity: quantity.toFixed(8),
|
||||||
|
burnQuantity: result.burnQuantity.toFixed(8),
|
||||||
|
effectiveQuantity: result.effectiveQuantity.toFixed(8),
|
||||||
|
price: result.price.toFixed(18),
|
||||||
|
proceeds: result.amount.toFixed(8),
|
||||||
|
burnMultiplier: (await this.priceService.getCurrentBurnMultiplier()).toFixed(18),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取市场概览
|
||||||
|
*/
|
||||||
|
async getMarketOverview(): Promise<{
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
effectiveDenominator: string;
|
||||||
|
burnMultiplier: string;
|
||||||
|
totalShares: string;
|
||||||
|
burnTarget: string;
|
||||||
|
burnProgress: string;
|
||||||
|
}> {
|
||||||
|
const [sharePool, blackHole, circulationPool] = await Promise.all([
|
||||||
|
this.sharePoolRepository.getPool(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const greenPoints = sharePool?.greenPoints || Money.zero();
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
// 计算价格
|
||||||
|
const price = this.calculator.calculatePrice(greenPoints, blackHoleAmount, circulationPoolAmount);
|
||||||
|
|
||||||
|
// 计算有效分母
|
||||||
|
const effectiveDenominator = this.calculator.calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算销毁倍数
|
||||||
|
const burnMultiplier = this.calculator.calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 销毁进度
|
||||||
|
const targetBurn = blackHole?.targetBurn || new Money(TradingCalculatorService.BURN_TARGET);
|
||||||
|
const burnProgress = blackHoleAmount.value.dividedBy(targetBurn.value).times(100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: price.toFixed(18),
|
||||||
|
greenPoints: greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: circulationPoolAmount.toFixed(8),
|
||||||
|
effectiveDenominator: effectiveDenominator.toFixed(8),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
totalShares: TradingCalculatorService.TOTAL_SHARES.toFixed(8),
|
||||||
|
burnTarget: targetBurn.toFixed(8),
|
||||||
|
burnProgress: burnProgress.toFixed(4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TradingCalculatorService } from '../../domain/services/trading-calculator.service';
|
||||||
|
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { TradingConfigRepository } from '../../infrastructure/persistence/repositories/trading-config.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import {
|
||||||
|
TradingEventTypes,
|
||||||
|
TradingTopics,
|
||||||
|
BurnExecutedPayload,
|
||||||
|
MinuteBurnExecutedPayload,
|
||||||
|
} from '../../domain/events/trading.events';
|
||||||
|
|
||||||
|
export interface BurnStatus {
|
||||||
|
totalBurned: string;
|
||||||
|
targetBurn: string;
|
||||||
|
remainingBurn: string;
|
||||||
|
burnProgress: string; // 百分比
|
||||||
|
minuteBurnRate: string;
|
||||||
|
remainingMinutes: number;
|
||||||
|
lastBurnMinute: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SellBurnResult {
|
||||||
|
burnQuantity: Money;
|
||||||
|
burnMultiplier: Decimal;
|
||||||
|
newMinuteBurnRate: Money;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BurnService {
|
||||||
|
private readonly logger = new Logger(BurnService.name);
|
||||||
|
private readonly calculator = new TradingCalculatorService();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly blackHoleRepository: BlackHoleRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly tradingConfigRepository: TradingConfigRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取销毁状态
|
||||||
|
*/
|
||||||
|
async getBurnStatus(): Promise<BurnStatus> {
|
||||||
|
const [blackHole, config] = await Promise.all([
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalBurned = blackHole?.totalBurned || Money.zero();
|
||||||
|
const targetBurn = blackHole?.targetBurn || new Money(TradingCalculatorService.BURN_TARGET);
|
||||||
|
const remainingBurn = blackHole?.remainingBurn || targetBurn;
|
||||||
|
|
||||||
|
// 计算进度百分比
|
||||||
|
const progress = totalBurned.value.dividedBy(targetBurn.value).times(100);
|
||||||
|
|
||||||
|
// 计算剩余分钟数
|
||||||
|
const activatedAt = config?.activatedAt || new Date();
|
||||||
|
const remainingMinutes = this.calculator.calculateRemainingMinutes(activatedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBurned: totalBurned.toFixed(8),
|
||||||
|
targetBurn: targetBurn.toFixed(8),
|
||||||
|
remainingBurn: remainingBurn.toFixed(8),
|
||||||
|
burnProgress: progress.toFixed(4),
|
||||||
|
minuteBurnRate: (config?.minuteBurnRate || Money.zero()).toFixed(18),
|
||||||
|
remainingMinutes,
|
||||||
|
lastBurnMinute: blackHole?.lastBurnMinute || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行每分钟自动销毁
|
||||||
|
*/
|
||||||
|
async executeMinuteBurn(): Promise<Money> {
|
||||||
|
const lockValue = await this.redis.acquireLock('burn:minute:lock', 55);
|
||||||
|
if (!lockValue) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this.tradingConfigRepository.getConfig();
|
||||||
|
if (!config || !config.isActive) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
const blackHole = await this.blackHoleRepository.getBlackHole();
|
||||||
|
if (!blackHole) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已完成销毁目标
|
||||||
|
if (blackHole.remainingBurn.isZero()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMinute = new Date();
|
||||||
|
currentMinute.setSeconds(0, 0);
|
||||||
|
|
||||||
|
// 检查是否已处理过这一分钟
|
||||||
|
const processedKey = `burn:processed:${currentMinute.getTime()}`;
|
||||||
|
if (await this.redis.get(processedKey)) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用当前配置的每分钟销毁率
|
||||||
|
let burnAmount = config.minuteBurnRate;
|
||||||
|
|
||||||
|
// 确保不超过剩余待销毁量
|
||||||
|
if (burnAmount.isGreaterThan(blackHole.remainingBurn)) {
|
||||||
|
burnAmount = blackHole.remainingBurn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (burnAmount.isZero()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录销毁
|
||||||
|
const burnRecord = await this.blackHoleRepository.recordMinuteBurn(currentMinute, burnAmount);
|
||||||
|
|
||||||
|
// 标记已处理
|
||||||
|
await this.redis.set(processedKey, '1', 120);
|
||||||
|
|
||||||
|
this.logger.log(`Minute burn executed: ${burnAmount.toFixed(8)}`);
|
||||||
|
|
||||||
|
// 发布每分钟销毁事件
|
||||||
|
await this.publishMinuteBurnEvent(
|
||||||
|
burnRecord.id,
|
||||||
|
currentMinute,
|
||||||
|
burnAmount,
|
||||||
|
blackHole.totalBurned.add(burnAmount),
|
||||||
|
blackHole.remainingBurn.subtract(burnAmount),
|
||||||
|
);
|
||||||
|
|
||||||
|
return burnAmount;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to execute minute burn', error);
|
||||||
|
return Money.zero();
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock('burn:minute:lock', lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行卖出销毁
|
||||||
|
* 卖出销毁量 = 卖出积分股 × 倍数
|
||||||
|
* 卖出后需要重新计算每分钟销毁量
|
||||||
|
*/
|
||||||
|
async executeSellBurn(
|
||||||
|
sellQuantity: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderNo: string,
|
||||||
|
): Promise<SellBurnResult> {
|
||||||
|
const [blackHole, circulationPool, config] = await Promise.all([
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!blackHole || !config) {
|
||||||
|
throw new Error('Trading system not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blackHoleAmount = blackHole.totalBurned;
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
// 计算销毁倍数
|
||||||
|
const burnMultiplier = this.calculator.calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算销毁量
|
||||||
|
const burnQuantity = this.calculator.calculateSellBurnAmount(sellQuantity, burnMultiplier);
|
||||||
|
|
||||||
|
// 确保销毁量不超过剩余待销毁量
|
||||||
|
const actualBurnQuantity = burnQuantity.isGreaterThan(blackHole.remainingBurn)
|
||||||
|
? blackHole.remainingBurn
|
||||||
|
: burnQuantity;
|
||||||
|
|
||||||
|
if (!actualBurnQuantity.isZero()) {
|
||||||
|
// 记录卖出销毁
|
||||||
|
const burnMinute = new Date();
|
||||||
|
burnMinute.setSeconds(0, 0);
|
||||||
|
|
||||||
|
const burnRecord = await this.blackHoleRepository.recordSellBurn(
|
||||||
|
burnMinute,
|
||||||
|
actualBurnQuantity,
|
||||||
|
accountSeq,
|
||||||
|
orderNo,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 重新计算每分钟销毁量
|
||||||
|
const newBlackHoleAmount = blackHoleAmount.add(actualBurnQuantity);
|
||||||
|
const remainingMinutes = this.calculator.calculateRemainingMinutes(
|
||||||
|
config.activatedAt || new Date(),
|
||||||
|
);
|
||||||
|
const newMinuteBurnRate = this.calculator.calculateMinuteBurnRate(
|
||||||
|
newBlackHoleAmount,
|
||||||
|
remainingMinutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新配置中的每分钟销毁率
|
||||||
|
await this.tradingConfigRepository.updateMinuteBurnRate(newMinuteBurnRate);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Sell burn executed: quantity=${actualBurnQuantity.toFixed(8)}, ` +
|
||||||
|
`multiplier=${burnMultiplier.toFixed(8)}, newRate=${newMinuteBurnRate.toFixed(18)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发布卖出销毁事件
|
||||||
|
await this.publishSellBurnEvent(
|
||||||
|
burnRecord.id,
|
||||||
|
accountSeq,
|
||||||
|
orderNo,
|
||||||
|
actualBurnQuantity,
|
||||||
|
burnMultiplier,
|
||||||
|
blackHole.remainingBurn.subtract(actualBurnQuantity),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
burnQuantity: actualBurnQuantity,
|
||||||
|
burnMultiplier,
|
||||||
|
newMinuteBurnRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
burnQuantity: Money.zero(),
|
||||||
|
burnMultiplier,
|
||||||
|
newMinuteBurnRate: config.minuteBurnRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化黑洞和配置
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
const [existingConfig, existingBlackHole] = await Promise.all([
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!existingConfig) {
|
||||||
|
await this.tradingConfigRepository.initializeConfig();
|
||||||
|
this.logger.log('Trading config initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingBlackHole) {
|
||||||
|
await this.blackHoleRepository.initializeBlackHole(
|
||||||
|
new Money(TradingCalculatorService.BURN_TARGET),
|
||||||
|
);
|
||||||
|
this.logger.log('Black hole initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取销毁记录
|
||||||
|
*/
|
||||||
|
async getBurnRecords(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
sourceType?: 'MINUTE_BURN' | 'SELL_BURN',
|
||||||
|
): Promise<{
|
||||||
|
data: any[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const result = await this.blackHoleRepository.getBurnRecords(page, pageSize, sourceType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
burnMinute: r.burnMinute,
|
||||||
|
burnAmount: r.burnAmount.toFixed(8),
|
||||||
|
remainingTarget: r.remainingTarget.toFixed(8),
|
||||||
|
sourceType: r.sourceType,
|
||||||
|
sourceAccountSeq: r.sourceAccountSeq,
|
||||||
|
sourceOrderNo: r.sourceOrderNo,
|
||||||
|
memo: r.memo,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 事件发布方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布每分钟销毁事件
|
||||||
|
*/
|
||||||
|
private async publishMinuteBurnEvent(
|
||||||
|
burnRecordId: string,
|
||||||
|
burnMinute: Date,
|
||||||
|
burnAmount: Money,
|
||||||
|
totalBurned: Money,
|
||||||
|
remainingTarget: Money,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: MinuteBurnExecutedPayload = {
|
||||||
|
burnRecordId,
|
||||||
|
burnMinute: burnMinute.toISOString(),
|
||||||
|
burnAmount: burnAmount.toString(),
|
||||||
|
totalBurned: totalBurned.toString(),
|
||||||
|
remainingTarget: remainingTarget.toString(),
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'BurnRecord',
|
||||||
|
aggregateId: burnRecordId,
|
||||||
|
eventType: TradingEventTypes.MINUTE_BURN_EXECUTED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.BURNS,
|
||||||
|
key: 'minute-burn',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published MinuteBurnExecuted event: ${burnAmount.toFixed(8)}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish MinuteBurnExecuted event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布卖出销毁事件
|
||||||
|
*/
|
||||||
|
private async publishSellBurnEvent(
|
||||||
|
burnRecordId: string,
|
||||||
|
accountSeq: string,
|
||||||
|
orderNo: string,
|
||||||
|
burnAmount: Money,
|
||||||
|
burnMultiplier: Decimal,
|
||||||
|
remainingTarget: Money,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: BurnExecutedPayload = {
|
||||||
|
burnRecordId,
|
||||||
|
sourceType: 'SELL',
|
||||||
|
sourceAccountSeq: accountSeq,
|
||||||
|
sourceOrderNo: orderNo,
|
||||||
|
burnAmount: burnAmount.toString(),
|
||||||
|
burnMultiplier: burnMultiplier.toString(),
|
||||||
|
remainingTarget: remainingTarget.toString(),
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'BurnRecord',
|
||||||
|
aggregateId: burnRecordId,
|
||||||
|
eventType: TradingEventTypes.BURN_EXECUTED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.BURNS,
|
||||||
|
key: accountSeq,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published BurnExecuted event for account ${accountSeq}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish BurnExecuted event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
|
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
|
||||||
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
import { OrderAggregate, OrderType, OrderStatus } from '../../domain/aggregates/order.aggregate';
|
import { OrderAggregate, OrderType, OrderStatus } from '../../domain/aggregates/order.aggregate';
|
||||||
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
|
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
|
||||||
import { MatchingEngineService } from '../../domain/services/matching-engine.service';
|
import { MatchingEngineService } from '../../domain/services/matching-engine.service';
|
||||||
import { Money } from '../../domain/value-objects/money.vo';
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import { BurnService } from './burn.service';
|
||||||
|
import { PriceService } from './price.service';
|
||||||
|
import {
|
||||||
|
TradingEventTypes,
|
||||||
|
TradingTopics,
|
||||||
|
OrderCreatedPayload,
|
||||||
|
OrderCancelledPayload,
|
||||||
|
TradeExecutedPayload,
|
||||||
|
} from '../../domain/events/trading.events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
|
|
@ -16,8 +27,12 @@ export class OrderService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly orderRepository: OrderRepository,
|
private readonly orderRepository: OrderRepository,
|
||||||
private readonly accountRepository: TradingAccountRepository,
|
private readonly accountRepository: TradingAccountRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
|
private readonly burnService: BurnService,
|
||||||
|
private readonly priceService: PriceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createOrder(
|
async createOrder(
|
||||||
|
|
@ -70,6 +85,9 @@ export class OrderService {
|
||||||
const orderId = await this.orderRepository.save(order);
|
const orderId = await this.orderRepository.save(order);
|
||||||
await this.accountRepository.save(account);
|
await this.accountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布订单创建事件
|
||||||
|
await this.publishOrderCreatedEvent(orderId, order);
|
||||||
|
|
||||||
// 尝试撮合
|
// 尝试撮合
|
||||||
await this.tryMatch(order);
|
await this.tryMatch(order);
|
||||||
|
|
||||||
|
|
@ -113,6 +131,9 @@ export class OrderService {
|
||||||
|
|
||||||
await this.orderRepository.save(order);
|
await this.orderRepository.save(order);
|
||||||
await this.accountRepository.save(account);
|
await this.accountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布订单取消事件
|
||||||
|
await this.publishOrderCancelledEvent(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryMatch(incomingOrder: OrderAggregate): Promise<void> {
|
private async tryMatch(incomingOrder: OrderAggregate): Promise<void> {
|
||||||
|
|
@ -126,7 +147,36 @@ export class OrderService {
|
||||||
const matches = this.matchingEngine.findMatchingOrders(incomingOrder, orderBook);
|
const matches = this.matchingEngine.findMatchingOrders(incomingOrder, orderBook);
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
// 保存成交记录
|
const tradeQuantity = match.trade.quantity;
|
||||||
|
let burnQuantity = Money.zero();
|
||||||
|
let effectiveQuantity = tradeQuantity;
|
||||||
|
|
||||||
|
// 如果是卖出成交,执行销毁逻辑
|
||||||
|
// 卖出的销毁量 = 卖出积分股 × 倍数
|
||||||
|
// 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
if (match.sellOrder) {
|
||||||
|
try {
|
||||||
|
const burnResult = await this.burnService.executeSellBurn(
|
||||||
|
tradeQuantity,
|
||||||
|
match.sellOrder.accountSequence,
|
||||||
|
match.sellOrder.orderNo,
|
||||||
|
);
|
||||||
|
burnQuantity = burnResult.burnQuantity;
|
||||||
|
effectiveQuantity = new Money(tradeQuantity.value.plus(burnQuantity.value));
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Sell burn executed: sellQty=${tradeQuantity.toFixed(8)}, ` +
|
||||||
|
`burnQty=${burnQuantity.toFixed(8)}, effectiveQty=${effectiveQuantity.toFixed(8)}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Sell burn failed, continuing without burn: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算交易额 = 有效数量 × 价格
|
||||||
|
const tradeAmount = new Money(effectiveQuantity.value.times(match.trade.price.value));
|
||||||
|
|
||||||
|
// 保存成交记录(包含销毁信息)
|
||||||
await this.prisma.trade.create({
|
await this.prisma.trade.create({
|
||||||
data: {
|
data: {
|
||||||
tradeNo: match.trade.tradeNo,
|
tradeNo: match.trade.tradeNo,
|
||||||
|
|
@ -135,31 +185,58 @@ export class OrderService {
|
||||||
buyerSequence: match.buyOrder.accountSequence,
|
buyerSequence: match.buyOrder.accountSequence,
|
||||||
sellerSequence: match.sellOrder.accountSequence,
|
sellerSequence: match.sellOrder.accountSequence,
|
||||||
price: match.trade.price.value,
|
price: match.trade.price.value,
|
||||||
quantity: match.trade.quantity.value,
|
quantity: tradeQuantity.value,
|
||||||
amount: match.trade.amount.value,
|
burnQuantity: burnQuantity.value,
|
||||||
|
effectiveQty: effectiveQuantity.value,
|
||||||
|
amount: tradeAmount.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新订单
|
// 卖出的积分股进入流通池
|
||||||
|
try {
|
||||||
|
await this.circulationPoolRepository.addSharesFromSell(
|
||||||
|
tradeQuantity,
|
||||||
|
match.sellOrder.accountSequence,
|
||||||
|
match.sellOrder.id!,
|
||||||
|
`卖出成交, 交易号${match.trade.tradeNo}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to add shares to circulation pool: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新订单(包含销毁信息)
|
||||||
await this.orderRepository.save(match.buyOrder);
|
await this.orderRepository.save(match.buyOrder);
|
||||||
await this.orderRepository.save(match.sellOrder);
|
await this.orderRepository.saveWithBurnInfo(match.sellOrder, burnQuantity, effectiveQuantity);
|
||||||
|
|
||||||
// 更新买方账户
|
// 更新买方账户
|
||||||
const buyerAccount = await this.accountRepository.findByAccountSequence(match.buyOrder.accountSequence);
|
const buyerAccount = await this.accountRepository.findByAccountSequence(match.buyOrder.accountSequence);
|
||||||
if (buyerAccount) {
|
if (buyerAccount) {
|
||||||
buyerAccount.executeBuy(match.trade.quantity, match.trade.amount, match.trade.tradeNo);
|
buyerAccount.executeBuy(tradeQuantity, tradeAmount, match.trade.tradeNo);
|
||||||
await this.accountRepository.save(buyerAccount);
|
await this.accountRepository.save(buyerAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新卖方账户
|
// 更新卖方账户(获得的是有效交易额)
|
||||||
const sellerAccount = await this.accountRepository.findByAccountSequence(match.sellOrder.accountSequence);
|
const sellerAccount = await this.accountRepository.findByAccountSequence(match.sellOrder.accountSequence);
|
||||||
if (sellerAccount) {
|
if (sellerAccount) {
|
||||||
sellerAccount.executeSell(match.trade.quantity, match.trade.amount, match.trade.tradeNo);
|
sellerAccount.executeSell(tradeQuantity, tradeAmount, match.trade.tradeNo);
|
||||||
await this.accountRepository.save(sellerAccount);
|
await this.accountRepository.save(sellerAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Trade executed: ${match.trade.tradeNo}, price=${match.trade.price}, qty=${match.trade.quantity}`,
|
`Trade executed: ${match.trade.tradeNo}, price=${match.trade.price.toFixed(8)}, ` +
|
||||||
|
`qty=${tradeQuantity.toFixed(8)}, burn=${burnQuantity.toFixed(8)}, amount=${tradeAmount.toFixed(8)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发布成交事件
|
||||||
|
await this.publishTradeExecutedEvent(
|
||||||
|
match.trade.tradeNo,
|
||||||
|
match.buyOrder,
|
||||||
|
match.sellOrder,
|
||||||
|
match.trade.price,
|
||||||
|
tradeQuantity,
|
||||||
|
tradeAmount,
|
||||||
|
burnQuantity,
|
||||||
|
effectiveQuantity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -172,4 +249,116 @@ export class OrderService {
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
return `O${timestamp}${random}`.toUpperCase();
|
return `O${timestamp}${random}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 事件发布方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布订单创建事件
|
||||||
|
*/
|
||||||
|
private async publishOrderCreatedEvent(orderId: string, order: OrderAggregate): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: OrderCreatedPayload = {
|
||||||
|
orderId,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
accountSequence: order.accountSequence,
|
||||||
|
type: order.type,
|
||||||
|
price: order.price.toString(),
|
||||||
|
quantity: order.quantity.toString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'Order',
|
||||||
|
aggregateId: orderId,
|
||||||
|
eventType: TradingEventTypes.ORDER_CREATED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.ORDERS,
|
||||||
|
key: order.accountSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published OrderCreated event for order ${order.orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish OrderCreated event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布订单取消事件
|
||||||
|
*/
|
||||||
|
private async publishOrderCancelledEvent(order: OrderAggregate): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: OrderCancelledPayload = {
|
||||||
|
orderId: order.id!,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
accountSequence: order.accountSequence,
|
||||||
|
type: order.type,
|
||||||
|
cancelledQuantity: order.remainingQuantity.toString(),
|
||||||
|
cancelledAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'Order',
|
||||||
|
aggregateId: order.id!,
|
||||||
|
eventType: TradingEventTypes.ORDER_CANCELLED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.ORDERS,
|
||||||
|
key: order.accountSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published OrderCancelled event for order ${order.orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish OrderCancelled event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布成交事件
|
||||||
|
*/
|
||||||
|
private async publishTradeExecutedEvent(
|
||||||
|
tradeNo: string,
|
||||||
|
buyOrder: OrderAggregate,
|
||||||
|
sellOrder: OrderAggregate,
|
||||||
|
price: Money,
|
||||||
|
quantity: Money,
|
||||||
|
amount: Money,
|
||||||
|
burnQuantity: Money,
|
||||||
|
effectiveQuantity: Money,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 使用 tradeNo 查找刚创建的 trade 获取 id
|
||||||
|
const trade = await this.prisma.trade.findUnique({ where: { tradeNo } });
|
||||||
|
if (!trade) {
|
||||||
|
this.logger.warn(`Trade not found for event publishing: ${tradeNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: TradeExecutedPayload = {
|
||||||
|
tradeId: trade.id,
|
||||||
|
tradeNo,
|
||||||
|
buyOrderId: buyOrder.id!,
|
||||||
|
sellOrderId: sellOrder.id!,
|
||||||
|
buyerSequence: buyOrder.accountSequence,
|
||||||
|
sellerSequence: sellOrder.accountSequence,
|
||||||
|
price: price.toString(),
|
||||||
|
quantity: quantity.toString(),
|
||||||
|
amount: amount.toString(),
|
||||||
|
burnQuantity: burnQuantity.toString(),
|
||||||
|
effectiveQuantity: effectiveQuantity.toString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'Trade',
|
||||||
|
aggregateId: trade.id,
|
||||||
|
eventType: TradingEventTypes.TRADE_EXECUTED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.TRADES,
|
||||||
|
key: tradeNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published TradeExecuted event for trade ${tradeNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish TradeExecuted event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TradingCalculatorService } from '../../domain/services/trading-calculator.service';
|
||||||
|
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
|
||||||
|
import { SharePoolRepository } from '../../infrastructure/persistence/repositories/share-pool.repository';
|
||||||
|
import { CirculationPoolRepository } from '../../infrastructure/persistence/repositories/circulation-pool.repository';
|
||||||
|
import { PriceSnapshotRepository } from '../../infrastructure/persistence/repositories/price-snapshot.repository';
|
||||||
|
import { TradingConfigRepository } from '../../infrastructure/persistence/repositories/trading-config.repository';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface PriceInfo {
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
effectiveDenominator: string;
|
||||||
|
burnMultiplier: string;
|
||||||
|
minuteBurnRate: string;
|
||||||
|
snapshotTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PriceService {
|
||||||
|
private readonly logger = new Logger(PriceService.name);
|
||||||
|
private readonly calculator = new TradingCalculatorService();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly blackHoleRepository: BlackHoleRepository,
|
||||||
|
private readonly sharePoolRepository: SharePoolRepository,
|
||||||
|
private readonly circulationPoolRepository: CirculationPoolRepository,
|
||||||
|
private readonly priceSnapshotRepository: PriceSnapshotRepository,
|
||||||
|
private readonly tradingConfigRepository: TradingConfigRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前价格信息
|
||||||
|
*/
|
||||||
|
async getCurrentPrice(): Promise<PriceInfo> {
|
||||||
|
const [sharePool, blackHole, circulationPool, config] = await Promise.all([
|
||||||
|
this.sharePoolRepository.getPool(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const greenPoints = sharePool?.greenPoints || Money.zero();
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
// 计算价格
|
||||||
|
const price = this.calculator.calculatePrice(greenPoints, blackHoleAmount, circulationPoolAmount);
|
||||||
|
|
||||||
|
// 计算有效分母
|
||||||
|
const effectiveDenominator = this.calculator.calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算销毁倍数
|
||||||
|
const burnMultiplier = this.calculator.calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取当前每分钟销毁率
|
||||||
|
const minuteBurnRate = config?.minuteBurnRate || Money.zero();
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: price.toFixed(18),
|
||||||
|
greenPoints: greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: circulationPoolAmount.toFixed(8),
|
||||||
|
effectiveDenominator: effectiveDenominator.toFixed(8),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
minuteBurnRate: minuteBurnRate.toFixed(18),
|
||||||
|
snapshotTime: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前销毁倍数
|
||||||
|
*/
|
||||||
|
async getCurrentBurnMultiplier(): Promise<Decimal> {
|
||||||
|
const [blackHole, circulationPool] = await Promise.all([
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
return this.calculator.calculateSellBurnMultiplier(blackHoleAmount, circulationPoolAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出销毁量
|
||||||
|
*/
|
||||||
|
async calculateSellBurn(sellQuantity: Money): Promise<{
|
||||||
|
burnQuantity: Money;
|
||||||
|
burnMultiplier: Decimal;
|
||||||
|
effectiveQuantity: Money;
|
||||||
|
}> {
|
||||||
|
const burnMultiplier = await this.getCurrentBurnMultiplier();
|
||||||
|
const burnQuantity = this.calculator.calculateSellBurnAmount(sellQuantity, burnMultiplier);
|
||||||
|
const effectiveQuantity = new Money(sellQuantity.value.plus(burnQuantity.value));
|
||||||
|
|
||||||
|
return {
|
||||||
|
burnQuantity,
|
||||||
|
burnMultiplier,
|
||||||
|
effectiveQuantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出交易额
|
||||||
|
*/
|
||||||
|
async calculateSellAmount(sellQuantity: Money): Promise<{
|
||||||
|
amount: Money;
|
||||||
|
burnQuantity: Money;
|
||||||
|
effectiveQuantity: Money;
|
||||||
|
price: Money;
|
||||||
|
}> {
|
||||||
|
const priceInfo = await this.getCurrentPrice();
|
||||||
|
const price = new Money(priceInfo.price);
|
||||||
|
|
||||||
|
const { burnQuantity, effectiveQuantity } = await this.calculateSellBurn(sellQuantity);
|
||||||
|
|
||||||
|
const amount = this.calculator.calculateSellAmount(sellQuantity, burnQuantity, price);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount,
|
||||||
|
burnQuantity,
|
||||||
|
effectiveQuantity,
|
||||||
|
price,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建价格快照
|
||||||
|
*/
|
||||||
|
async createSnapshot(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [sharePool, blackHole, circulationPool, config] = await Promise.all([
|
||||||
|
this.sharePoolRepository.getPool(),
|
||||||
|
this.blackHoleRepository.getBlackHole(),
|
||||||
|
this.circulationPoolRepository.getPool(),
|
||||||
|
this.tradingConfigRepository.getConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const greenPoints = sharePool?.greenPoints || Money.zero();
|
||||||
|
const blackHoleAmount = blackHole?.totalBurned || Money.zero();
|
||||||
|
const circulationPoolAmount = circulationPool?.totalShares || Money.zero();
|
||||||
|
|
||||||
|
const price = this.calculator.calculatePrice(greenPoints, blackHoleAmount, circulationPoolAmount);
|
||||||
|
const effectiveDenominator = this.calculator.calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPoolAmount,
|
||||||
|
);
|
||||||
|
const minuteBurnRate = config?.minuteBurnRate || Money.zero();
|
||||||
|
|
||||||
|
const snapshotTime = new Date();
|
||||||
|
snapshotTime.setSeconds(0, 0);
|
||||||
|
|
||||||
|
await this.priceSnapshotRepository.createSnapshot({
|
||||||
|
snapshotTime,
|
||||||
|
price,
|
||||||
|
greenPoints,
|
||||||
|
blackHoleAmount,
|
||||||
|
circulationPool: circulationPoolAmount,
|
||||||
|
effectiveDenominator,
|
||||||
|
minuteBurnRate,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Price snapshot created: ${price.toFixed(18)}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to create price snapshot', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取价格历史
|
||||||
|
*/
|
||||||
|
async getPriceHistory(
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
limit: number = 1440,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
time: Date;
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const snapshots = await this.priceSnapshotRepository.getPriceHistory(startTime, endTime, limit);
|
||||||
|
|
||||||
|
return snapshots.map((s) => ({
|
||||||
|
time: s.snapshotTime,
|
||||||
|
price: s.price.toFixed(18),
|
||||||
|
greenPoints: s.greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: s.blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: s.circulationPool.toFixed(8),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新价格快照
|
||||||
|
*/
|
||||||
|
async getLatestSnapshot(): Promise<PriceInfo | null> {
|
||||||
|
const snapshot = await this.priceSnapshotRepository.getLatestSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const burnMultiplier = await this.getCurrentBurnMultiplier();
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: snapshot.price.toFixed(18),
|
||||||
|
greenPoints: snapshot.greenPoints.toFixed(8),
|
||||||
|
blackHoleAmount: snapshot.blackHoleAmount.toFixed(8),
|
||||||
|
circulationPool: snapshot.circulationPool.toFixed(8),
|
||||||
|
effectiveDenominator: snapshot.effectiveDenominator.toFixed(8),
|
||||||
|
burnMultiplier: burnMultiplier.toFixed(18),
|
||||||
|
minuteBurnRate: snapshot.minuteBurnRate.toFixed(18),
|
||||||
|
snapshotTime: snapshot.snapshotTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Trading Service Event Types
|
||||||
|
export * from './trading.events';
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
/**
|
||||||
|
* Trading Service 事件定义
|
||||||
|
* 这些事件通过 Outbox 模式发布到 Kafka
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== 事件类型常量 ====================
|
||||||
|
|
||||||
|
export const TradingEventTypes = {
|
||||||
|
// 订单事件
|
||||||
|
ORDER_CREATED: 'order.created',
|
||||||
|
ORDER_CANCELLED: 'order.cancelled',
|
||||||
|
ORDER_COMPLETED: 'order.completed',
|
||||||
|
|
||||||
|
// 成交事件
|
||||||
|
TRADE_EXECUTED: 'trade.executed',
|
||||||
|
|
||||||
|
// 转账事件
|
||||||
|
TRANSFER_INITIATED: 'transfer.initiated',
|
||||||
|
TRANSFER_COMPLETED: 'transfer.completed',
|
||||||
|
TRANSFER_FAILED: 'transfer.failed',
|
||||||
|
|
||||||
|
// 销毁事件
|
||||||
|
BURN_EXECUTED: 'burn.executed',
|
||||||
|
MINUTE_BURN_EXECUTED: 'burn.minute-executed',
|
||||||
|
|
||||||
|
// 价格事件
|
||||||
|
PRICE_UPDATED: 'price.updated',
|
||||||
|
|
||||||
|
// 账户事件
|
||||||
|
TRADING_ACCOUNT_CREATED: 'trading-account.created',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TradingEventType =
|
||||||
|
(typeof TradingEventTypes)[keyof typeof TradingEventTypes];
|
||||||
|
|
||||||
|
// ==================== Kafka Topic 常量 ====================
|
||||||
|
|
||||||
|
export const TradingTopics = {
|
||||||
|
ORDERS: 'trading.orders',
|
||||||
|
TRADES: 'trading.trades',
|
||||||
|
TRANSFERS: 'trading.transfers',
|
||||||
|
BURNS: 'trading.burns',
|
||||||
|
PRICES: 'trading.prices',
|
||||||
|
ACCOUNTS: 'trading.accounts',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ==================== 事件 Payload 类型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单创建事件
|
||||||
|
*/
|
||||||
|
export interface OrderCreatedPayload {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
price: string;
|
||||||
|
quantity: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单取消事件
|
||||||
|
*/
|
||||||
|
export interface OrderCancelledPayload {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
cancelledQuantity: string;
|
||||||
|
cancelledAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单完成事件
|
||||||
|
*/
|
||||||
|
export interface OrderCompletedPayload {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
filledQuantity: string;
|
||||||
|
averagePrice: string;
|
||||||
|
totalAmount: string;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成交事件
|
||||||
|
*/
|
||||||
|
export interface TradeExecutedPayload {
|
||||||
|
tradeId: string;
|
||||||
|
tradeNo: string;
|
||||||
|
buyOrderId: string;
|
||||||
|
sellOrderId: string;
|
||||||
|
buyerSequence: string;
|
||||||
|
sellerSequence: string;
|
||||||
|
price: string;
|
||||||
|
quantity: string;
|
||||||
|
amount: string;
|
||||||
|
burnQuantity: string;
|
||||||
|
effectiveQuantity: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转账发起事件
|
||||||
|
*/
|
||||||
|
export interface TransferInitiatedPayload {
|
||||||
|
transferId: string;
|
||||||
|
transferNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
direction: 'IN' | 'OUT';
|
||||||
|
amount: string;
|
||||||
|
initiatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转账完成事件
|
||||||
|
*/
|
||||||
|
export interface TransferCompletedPayload {
|
||||||
|
transferId: string;
|
||||||
|
transferNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
direction: 'IN' | 'OUT';
|
||||||
|
amount: string;
|
||||||
|
miningTxId?: string;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转账失败事件
|
||||||
|
*/
|
||||||
|
export interface TransferFailedPayload {
|
||||||
|
transferId: string;
|
||||||
|
transferNo: string;
|
||||||
|
accountSequence: string;
|
||||||
|
direction: 'IN' | 'OUT';
|
||||||
|
amount: string;
|
||||||
|
errorMessage: string;
|
||||||
|
failedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁执行事件(卖出触发)
|
||||||
|
*/
|
||||||
|
export interface BurnExecutedPayload {
|
||||||
|
burnRecordId: string;
|
||||||
|
sourceType: 'SELL' | 'SCHEDULED';
|
||||||
|
sourceAccountSeq?: string;
|
||||||
|
sourceOrderNo?: string;
|
||||||
|
burnAmount: string;
|
||||||
|
burnMultiplier?: string;
|
||||||
|
remainingTarget: string;
|
||||||
|
executedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟定时销毁事件
|
||||||
|
*/
|
||||||
|
export interface MinuteBurnExecutedPayload {
|
||||||
|
burnRecordId: string;
|
||||||
|
burnMinute: string;
|
||||||
|
burnAmount: string;
|
||||||
|
totalBurned: string;
|
||||||
|
remainingTarget: string;
|
||||||
|
executedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价格更新事件
|
||||||
|
*/
|
||||||
|
export interface PriceUpdatedPayload {
|
||||||
|
snapshotId: string;
|
||||||
|
price: string;
|
||||||
|
greenPoints: string;
|
||||||
|
blackHoleAmount: string;
|
||||||
|
circulationPool: string;
|
||||||
|
effectiveDenominator: string;
|
||||||
|
minuteBurnRate: string;
|
||||||
|
snapshotTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易账户创建事件
|
||||||
|
*/
|
||||||
|
export interface TradingAccountCreatedPayload {
|
||||||
|
accountId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 事件基类 ====================
|
||||||
|
|
||||||
|
export interface TradingEvent<T = unknown> {
|
||||||
|
eventId: string;
|
||||||
|
eventType: TradingEventType;
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
payload: T;
|
||||||
|
timestamp: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 辅助函数 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标准事件结构
|
||||||
|
*/
|
||||||
|
export function createTradingEvent<T>(
|
||||||
|
eventType: TradingEventType,
|
||||||
|
aggregateType: string,
|
||||||
|
aggregateId: string,
|
||||||
|
payload: T,
|
||||||
|
): Omit<TradingEvent<T>, 'eventId'> {
|
||||||
|
return {
|
||||||
|
eventType,
|
||||||
|
aggregateType,
|
||||||
|
aggregateId,
|
||||||
|
payload,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { Money } from '../value-objects/money.vo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易计算领域服务
|
||||||
|
*
|
||||||
|
* 核心公式:
|
||||||
|
* 1. 每分钟销毁量 = 100亿 ÷ (365×4×1440) = 4756.468797564687 进黑洞
|
||||||
|
* 2. 积分股价格 = 积分股池的绿积分 ÷ (100.02亿积分股 - 黑洞积分股 - 流通池积分股)
|
||||||
|
* 3. 卖出销毁倍数 = (100亿积分股 - 黑洞销毁量) ÷ (200万 - 流通池量)
|
||||||
|
* 4. 卖出销毁量 = 卖出积分股 × 倍数
|
||||||
|
* 5. 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
* 6. 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
* 7. 资产每秒增长量 = 用户每天分配的积分股 ÷ 24 ÷ 60 ÷ 60
|
||||||
|
*/
|
||||||
|
export class TradingCalculatorService {
|
||||||
|
// 总积分股数量: 100.02B
|
||||||
|
static readonly TOTAL_SHARES = new Decimal('100020000000');
|
||||||
|
|
||||||
|
// 目标销毁量: 100亿 (4年销毁完)
|
||||||
|
static readonly BURN_TARGET = new Decimal('10000000000');
|
||||||
|
|
||||||
|
// 销毁周期: 4年的分钟数
|
||||||
|
static readonly BURN_PERIOD_MINUTES = 365 * 4 * 1440; // 2102400
|
||||||
|
|
||||||
|
// 流通池目标量: 200万
|
||||||
|
static readonly CIRCULATION_POOL_TARGET = new Decimal('2000000');
|
||||||
|
|
||||||
|
// 基础每分钟销毁量: 100亿 ÷ (365×4×1440)
|
||||||
|
static readonly BASE_MINUTE_BURN_RATE = TradingCalculatorService.BURN_TARGET.dividedBy(
|
||||||
|
TradingCalculatorService.BURN_PERIOD_MINUTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算积分股价格
|
||||||
|
* 价格 = 绿积分(股池) ÷ (总积分股 - 黑洞积分股 - 流通池积分股)
|
||||||
|
*
|
||||||
|
* @param greenPoints 积分股池的绿积分(分子)
|
||||||
|
* @param blackHoleAmount 黑洞积分股数量
|
||||||
|
* @param circulationPoolAmount 流通池积分股数量
|
||||||
|
* @returns 价格
|
||||||
|
*/
|
||||||
|
calculatePrice(
|
||||||
|
greenPoints: Money,
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
): Money {
|
||||||
|
// 有效分母 = 100.02B - 黑洞 - 流通池
|
||||||
|
const effectiveDenominator = TradingCalculatorService.TOTAL_SHARES
|
||||||
|
.minus(blackHoleAmount.value)
|
||||||
|
.minus(circulationPoolAmount.value);
|
||||||
|
|
||||||
|
if (effectiveDenominator.isZero() || effectiveDenominator.isNegative()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 价格 = 绿积分 / 有效分母
|
||||||
|
const price = greenPoints.value.dividedBy(effectiveDenominator);
|
||||||
|
return new Money(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算有效分母
|
||||||
|
* 有效分母 = 总积分股 - 黑洞积分股 - 流通池积分股
|
||||||
|
*/
|
||||||
|
calculateEffectiveDenominator(
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
): Money {
|
||||||
|
const denominator = TradingCalculatorService.TOTAL_SHARES
|
||||||
|
.minus(blackHoleAmount.value)
|
||||||
|
.minus(circulationPoolAmount.value);
|
||||||
|
|
||||||
|
if (denominator.isNegative()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Money(denominator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出销毁倍数
|
||||||
|
* 倍数 = (100亿积分股 - 黑洞销毁量) ÷ (200万 - 流通池量)
|
||||||
|
*
|
||||||
|
* 目的:确保价格不会因为卖出而下跌
|
||||||
|
*
|
||||||
|
* @param blackHoleAmount 当前黑洞销毁总量
|
||||||
|
* @param circulationPoolAmount 当前流通池量
|
||||||
|
* @returns 销毁倍数
|
||||||
|
*/
|
||||||
|
calculateSellBurnMultiplier(
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
): Decimal {
|
||||||
|
// 分子 = 100亿 - 黑洞销毁量
|
||||||
|
const numerator = TradingCalculatorService.BURN_TARGET.minus(blackHoleAmount.value);
|
||||||
|
|
||||||
|
// 分母 = 200万 - 流通池量
|
||||||
|
const denominator = TradingCalculatorService.CIRCULATION_POOL_TARGET.minus(
|
||||||
|
circulationPoolAmount.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 防止除以零或负数
|
||||||
|
if (denominator.isZero() || denominator.isNegative()) {
|
||||||
|
// 当流通池已满时,销毁倍数设为最大合理值
|
||||||
|
return new Decimal('5'); // 或其他业务定义的最大倍数
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numerator.isNegative()) {
|
||||||
|
// 当黑洞已满时,不再销毁
|
||||||
|
return new Decimal('0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return numerator.dividedBy(denominator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出销毁量
|
||||||
|
* 卖出销毁量 = 卖出积分股 × 倍数
|
||||||
|
*
|
||||||
|
* @param sellQuantity 卖出的积分股数量
|
||||||
|
* @param burnMultiplier 销毁倍数
|
||||||
|
* @returns 需要销毁的数量
|
||||||
|
*/
|
||||||
|
calculateSellBurnAmount(sellQuantity: Money, burnMultiplier: Decimal): Money {
|
||||||
|
const burnAmount = sellQuantity.value.times(burnMultiplier);
|
||||||
|
return new Money(burnAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出交易额
|
||||||
|
* 卖出交易额 = (卖出量 + 卖出销毁量) × 积分股价
|
||||||
|
*
|
||||||
|
* @param sellQuantity 卖出的积分股数量
|
||||||
|
* @param burnQuantity 销毁的数量
|
||||||
|
* @param price 当前价格
|
||||||
|
* @returns 交易额
|
||||||
|
*/
|
||||||
|
calculateSellAmount(sellQuantity: Money, burnQuantity: Money, price: Money): Money {
|
||||||
|
const effectiveQuantity = sellQuantity.value.plus(burnQuantity.value);
|
||||||
|
const amount = effectiveQuantity.times(price.value);
|
||||||
|
return new Money(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算资产显示值
|
||||||
|
* 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
|
||||||
|
*
|
||||||
|
* @param shareBalance 账户积分股余额
|
||||||
|
* @param burnMultiplier 当前销毁倍数
|
||||||
|
* @param price 当前价格
|
||||||
|
* @returns 显示的资产价值
|
||||||
|
*/
|
||||||
|
calculateDisplayAssetValue(
|
||||||
|
shareBalance: Money,
|
||||||
|
burnMultiplier: Decimal,
|
||||||
|
price: Money,
|
||||||
|
): Money {
|
||||||
|
// 有效积分股 = 余额 + 余额 × 倍数 = 余额 × (1 + 倍数)
|
||||||
|
const multiplierFactor = new Decimal(1).plus(burnMultiplier);
|
||||||
|
const effectiveShares = shareBalance.value.times(multiplierFactor);
|
||||||
|
const assetValue = effectiveShares.times(price.value);
|
||||||
|
return new Money(assetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算资产每秒增长量
|
||||||
|
* 资产每秒增长量 = 用户每天分配的积分股 ÷ 24 ÷ 60 ÷ 60
|
||||||
|
*
|
||||||
|
* @param dailyAllocation 用户每天分配的积分股
|
||||||
|
* @returns 每秒增长量
|
||||||
|
*/
|
||||||
|
calculateAssetGrowthPerSecond(dailyAllocation: Money): Money {
|
||||||
|
const secondsPerDay = 24 * 60 * 60; // 86400
|
||||||
|
const perSecond = dailyAllocation.value.dividedBy(secondsPerDay);
|
||||||
|
return new Money(perSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算每分钟销毁量
|
||||||
|
* 每次卖出后需要重新计算:(100亿 - 黑洞总量) ÷ 剩余分钟
|
||||||
|
*
|
||||||
|
* @param blackHoleAmount 当前黑洞销毁总量
|
||||||
|
* @param remainingMinutes 剩余分钟数
|
||||||
|
* @returns 每分钟销毁量
|
||||||
|
*/
|
||||||
|
calculateMinuteBurnRate(blackHoleAmount: Money, remainingMinutes: number): Money {
|
||||||
|
if (remainingMinutes <= 0) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剩余需要销毁的量 = 100亿 - 已销毁量
|
||||||
|
const remainingBurn = TradingCalculatorService.BURN_TARGET.minus(blackHoleAmount.value);
|
||||||
|
|
||||||
|
if (remainingBurn.isZero() || remainingBurn.isNegative()) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
const minuteRate = remainingBurn.dividedBy(remainingMinutes);
|
||||||
|
return new Money(minuteRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算剩余销毁分钟数
|
||||||
|
*
|
||||||
|
* @param activatedAt 激活时间
|
||||||
|
* @returns 剩余分钟数
|
||||||
|
*/
|
||||||
|
calculateRemainingMinutes(activatedAt: Date): number {
|
||||||
|
const now = new Date();
|
||||||
|
const elapsedMs = now.getTime() - activatedAt.getTime();
|
||||||
|
const elapsedMinutes = Math.floor(elapsedMs / (60 * 1000));
|
||||||
|
return Math.max(0, TradingCalculatorService.BURN_PERIOD_MINUTES - elapsedMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卖出后的新价格(验证用)
|
||||||
|
* 确保卖出后价格不下跌
|
||||||
|
*
|
||||||
|
* @param currentPrice 当前价格
|
||||||
|
* @param sellQuantity 卖出数量
|
||||||
|
* @param burnQuantity 销毁数量
|
||||||
|
* @param greenPoints 绿积分
|
||||||
|
* @param blackHoleAmount 黑洞数量
|
||||||
|
* @param circulationPoolAmount 流通池数量
|
||||||
|
* @returns 卖出后的新价格
|
||||||
|
*/
|
||||||
|
calculatePriceAfterSell(
|
||||||
|
greenPoints: Money,
|
||||||
|
blackHoleAmount: Money,
|
||||||
|
circulationPoolAmount: Money,
|
||||||
|
sellQuantity: Money,
|
||||||
|
burnQuantity: Money,
|
||||||
|
): Money {
|
||||||
|
// 卖出后:流通池增加sellQuantity,黑洞增加burnQuantity
|
||||||
|
const newBlackHole = blackHoleAmount.add(burnQuantity);
|
||||||
|
const newCirculation = circulationPoolAmount.add(sellQuantity);
|
||||||
|
|
||||||
|
return this.calculatePrice(greenPoints, newBlackHole, newCirculation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,15 @@ import { PrismaModule } from './persistence/prisma/prisma.module';
|
||||||
import { TradingAccountRepository } from './persistence/repositories/trading-account.repository';
|
import { TradingAccountRepository } from './persistence/repositories/trading-account.repository';
|
||||||
import { OrderRepository } from './persistence/repositories/order.repository';
|
import { OrderRepository } from './persistence/repositories/order.repository';
|
||||||
import { OutboxRepository } from './persistence/repositories/outbox.repository';
|
import { OutboxRepository } from './persistence/repositories/outbox.repository';
|
||||||
|
import { TradingConfigRepository } from './persistence/repositories/trading-config.repository';
|
||||||
|
import { BlackHoleRepository } from './persistence/repositories/black-hole.repository';
|
||||||
|
import { SharePoolRepository } from './persistence/repositories/share-pool.repository';
|
||||||
|
import { CirculationPoolRepository } from './persistence/repositories/circulation-pool.repository';
|
||||||
|
import { PriceSnapshotRepository } from './persistence/repositories/price-snapshot.repository';
|
||||||
|
import { ProcessedEventRepository } from './persistence/repositories/processed-event.repository';
|
||||||
import { RedisService } from './redis/redis.service';
|
import { RedisService } from './redis/redis.service';
|
||||||
import { KafkaProducerService } from './kafka/kafka-producer.service';
|
import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
|
import { UserRegisteredConsumer } from './kafka/consumers/user-registered.consumer';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -32,10 +39,17 @@ import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
controllers: [UserRegisteredConsumer],
|
||||||
providers: [
|
providers: [
|
||||||
TradingAccountRepository,
|
TradingAccountRepository,
|
||||||
OrderRepository,
|
OrderRepository,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
|
TradingConfigRepository,
|
||||||
|
BlackHoleRepository,
|
||||||
|
SharePoolRepository,
|
||||||
|
CirculationPoolRepository,
|
||||||
|
PriceSnapshotRepository,
|
||||||
|
ProcessedEventRepository,
|
||||||
KafkaProducerService,
|
KafkaProducerService,
|
||||||
{
|
{
|
||||||
provide: 'REDIS_OPTIONS',
|
provide: 'REDIS_OPTIONS',
|
||||||
|
|
@ -53,6 +67,12 @@ import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
TradingAccountRepository,
|
TradingAccountRepository,
|
||||||
OrderRepository,
|
OrderRepository,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
|
TradingConfigRepository,
|
||||||
|
BlackHoleRepository,
|
||||||
|
SharePoolRepository,
|
||||||
|
CirculationPoolRepository,
|
||||||
|
PriceSnapshotRepository,
|
||||||
|
ProcessedEventRepository,
|
||||||
KafkaProducerService,
|
KafkaProducerService,
|
||||||
RedisService,
|
RedisService,
|
||||||
ClientsModule,
|
ClientsModule,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './user-registered.consumer';
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { Controller, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||||
|
import { RedisService } from '../../redis/redis.service';
|
||||||
|
import { TradingAccountRepository } from '../../persistence/repositories/trading-account.repository';
|
||||||
|
import { OutboxRepository } from '../../persistence/repositories/outbox.repository';
|
||||||
|
import { ProcessedEventRepository } from '../../persistence/repositories/processed-event.repository';
|
||||||
|
import { TradingAccountAggregate } from '../../../domain/aggregates/trading-account.aggregate';
|
||||||
|
import {
|
||||||
|
TradingEventTypes,
|
||||||
|
TradingTopics,
|
||||||
|
TradingAccountCreatedPayload,
|
||||||
|
} from '../../../domain/events/trading.events';
|
||||||
|
|
||||||
|
// 用户注册事件结构(来自 auth-service)
|
||||||
|
interface UserRegisteredEvent {
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
payload: {
|
||||||
|
accountSequence: string;
|
||||||
|
phone: string;
|
||||||
|
source: 'V1' | 'V2';
|
||||||
|
registeredAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4小时 TTL(秒)
|
||||||
|
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class UserRegisteredConsumer implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(UserRegisteredConsumer.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly tradingAccountRepository: TradingAccountRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly processedEventRepository: ProcessedEventRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('UserRegisteredConsumer initialized - listening for user.registered events');
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventPattern('auth.user.registered')
|
||||||
|
async handleUserRegistered(@Payload() message: any): Promise<void> {
|
||||||
|
// 解析消息格式
|
||||||
|
const event: UserRegisteredEvent = message.value || message;
|
||||||
|
const eventId = event.eventId || message.eventId;
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
this.logger.warn('Received event without eventId, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSequence = event.payload?.accountSequence;
|
||||||
|
if (!accountSequence) {
|
||||||
|
this.logger.warn(`Event ${eventId} missing accountSequence, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Processing user registered event: ${eventId}, accountSequence: ${accountSequence}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (await this.isEventProcessed(eventId)) {
|
||||||
|
this.logger.debug(`Event ${eventId} already processed, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查账户是否已存在
|
||||||
|
const existingAccount = await this.tradingAccountRepository.findByAccountSequence(
|
||||||
|
accountSequence,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingAccount) {
|
||||||
|
this.logger.debug(`Trading account ${accountSequence} already exists`);
|
||||||
|
await this.markEventProcessed(eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建交易账户
|
||||||
|
const account = TradingAccountAggregate.create(accountSequence);
|
||||||
|
const accountId = await this.tradingAccountRepository.save(account);
|
||||||
|
|
||||||
|
// 发布交易账户创建事件
|
||||||
|
await this.publishAccountCreatedEvent(accountId, accountSequence);
|
||||||
|
|
||||||
|
// 标记为已处理
|
||||||
|
await this.markEventProcessed(eventId);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Trading account created for user ${accountSequence}, source: ${event.payload.source}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// 如果是重复创建的唯一约束错误,忽略
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Trading account already exists for ${accountSequence}, marking as processed`,
|
||||||
|
);
|
||||||
|
await this.markEventProcessed(eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create trading account for ${accountSequence}`,
|
||||||
|
error instanceof Error ? error.stack : error,
|
||||||
|
);
|
||||||
|
throw error; // 让 Kafka 重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等性检查 - Redis + DB 双重检查
|
||||||
|
* 1. 先检查 Redis 缓存(快速路径)
|
||||||
|
* 2. Redis 未命中则检查数据库(持久化保障)
|
||||||
|
*/
|
||||||
|
private async isEventProcessed(eventId: string): Promise<boolean> {
|
||||||
|
const redisKey = `trading:processed-event:${eventId}`;
|
||||||
|
|
||||||
|
// 1. 先检查 Redis 缓存(快速路径)
|
||||||
|
const cached = await this.redis.get(redisKey);
|
||||||
|
if (cached) return true;
|
||||||
|
|
||||||
|
// 2. 检查数据库(Redis 可能过期或重启后丢失)
|
||||||
|
const dbRecord = await this.processedEventRepository.findByEventId(eventId);
|
||||||
|
if (dbRecord) {
|
||||||
|
// 回填 Redis 缓存
|
||||||
|
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记事件为已处理 - Redis + DB 双写
|
||||||
|
*/
|
||||||
|
private async markEventProcessed(eventId: string, eventType: string = 'user.registered'): Promise<void> {
|
||||||
|
const redisKey = `trading:processed-event:${eventId}`;
|
||||||
|
|
||||||
|
// 1. 写入数据库(持久化)
|
||||||
|
try {
|
||||||
|
await this.processedEventRepository.create({
|
||||||
|
eventId,
|
||||||
|
eventType,
|
||||||
|
sourceService: 'auth-service',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 可能已存在(并发情况),忽略唯一约束错误
|
||||||
|
if (!(error instanceof Error && error.message.includes('Unique constraint'))) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 写入 Redis 缓存(4小时 TTL)
|
||||||
|
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布交易账户创建事件
|
||||||
|
*/
|
||||||
|
private async publishAccountCreatedEvent(
|
||||||
|
accountId: string,
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload: TradingAccountCreatedPayload = {
|
||||||
|
accountId,
|
||||||
|
accountSequence,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.outboxRepository.create({
|
||||||
|
aggregateType: 'TradingAccount',
|
||||||
|
aggregateId: accountId,
|
||||||
|
eventType: TradingEventTypes.TRADING_ACCOUNT_CREATED,
|
||||||
|
payload,
|
||||||
|
topic: TradingTopics.ACCOUNTS,
|
||||||
|
key: accountSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published TradingAccountCreated event for ${accountSequence}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to publish TradingAccountCreated event: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface BlackHoleEntity {
|
||||||
|
id: string;
|
||||||
|
totalBurned: Money;
|
||||||
|
targetBurn: Money;
|
||||||
|
remainingBurn: Money;
|
||||||
|
lastBurnMinute: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurnRecordEntity {
|
||||||
|
id: string;
|
||||||
|
blackHoleId: string;
|
||||||
|
burnMinute: Date;
|
||||||
|
burnAmount: Money;
|
||||||
|
remainingTarget: Money;
|
||||||
|
sourceType: string | null;
|
||||||
|
sourceAccountSeq: string | null;
|
||||||
|
sourceOrderNo: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BurnSourceType = 'MINUTE_BURN' | 'SELL_BURN';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BlackHoleRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getBlackHole(): Promise<BlackHoleEntity | null> {
|
||||||
|
const record = await this.prisma.blackHole.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeBlackHole(targetBurn: Money): Promise<BlackHoleEntity> {
|
||||||
|
const existing = await this.prisma.blackHole.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.blackHole.create({
|
||||||
|
data: {
|
||||||
|
totalBurned: 0,
|
||||||
|
targetBurn: targetBurn.value,
|
||||||
|
remainingBurn: targetBurn.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录销毁(每分钟自动销毁)
|
||||||
|
*/
|
||||||
|
async recordMinuteBurn(burnMinute: Date, burnAmount: Money): Promise<BurnRecordEntity> {
|
||||||
|
return this.recordBurn(burnMinute, burnAmount, 'MINUTE_BURN');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录销毁(卖出销毁)
|
||||||
|
*/
|
||||||
|
async recordSellBurn(
|
||||||
|
burnMinute: Date,
|
||||||
|
burnAmount: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderNo: string,
|
||||||
|
): Promise<BurnRecordEntity> {
|
||||||
|
return this.recordBurn(burnMinute, burnAmount, 'SELL_BURN', accountSeq, orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录销毁通用方法
|
||||||
|
*/
|
||||||
|
private async recordBurn(
|
||||||
|
burnMinute: Date,
|
||||||
|
burnAmount: Money,
|
||||||
|
sourceType: BurnSourceType,
|
||||||
|
sourceAccountSeq?: string,
|
||||||
|
sourceOrderNo?: string,
|
||||||
|
): Promise<BurnRecordEntity> {
|
||||||
|
const blackHole = await this.prisma.blackHole.findFirst();
|
||||||
|
if (!blackHole) {
|
||||||
|
throw new Error('Black hole not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTotalBurned = new Decimal(blackHole.totalBurned.toString()).plus(burnAmount.value);
|
||||||
|
const newRemainingBurn = new Decimal(blackHole.targetBurn.toString()).minus(newTotalBurned);
|
||||||
|
|
||||||
|
const memo =
|
||||||
|
sourceType === 'MINUTE_BURN'
|
||||||
|
? `每分钟自动销毁 ${burnAmount.toFixed(8)}`
|
||||||
|
: `卖出销毁, 账户[${sourceAccountSeq}], 订单[${sourceOrderNo}], 数量${burnAmount.toFixed(8)}`;
|
||||||
|
|
||||||
|
const [, burnRecord] = await this.prisma.$transaction([
|
||||||
|
this.prisma.blackHole.update({
|
||||||
|
where: { id: blackHole.id },
|
||||||
|
data: {
|
||||||
|
totalBurned: newTotalBurned,
|
||||||
|
remainingBurn: newRemainingBurn.isNegative() ? 0 : newRemainingBurn,
|
||||||
|
lastBurnMinute: burnMinute,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.burnRecord.create({
|
||||||
|
data: {
|
||||||
|
blackHoleId: blackHole.id,
|
||||||
|
burnMinute,
|
||||||
|
burnAmount: burnAmount.value,
|
||||||
|
remainingTarget: newRemainingBurn.isNegative() ? 0 : newRemainingBurn,
|
||||||
|
sourceType,
|
||||||
|
sourceAccountSeq,
|
||||||
|
sourceOrderNo,
|
||||||
|
memo,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.toBurnRecordDomain(burnRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBurnRecords(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
sourceType?: BurnSourceType,
|
||||||
|
): Promise<{
|
||||||
|
data: BurnRecordEntity[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const where = sourceType ? { sourceType } : {};
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.burnRecord.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { burnMinute: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.burnRecord.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toBurnRecordDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTodayBurnAmount(): Promise<Money> {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const result = await this.prisma.burnRecord.aggregate({
|
||||||
|
where: {
|
||||||
|
burnMinute: { gte: today },
|
||||||
|
},
|
||||||
|
_sum: { burnAmount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Money(result._sum.burnAmount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): BlackHoleEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
totalBurned: new Money(record.totalBurned),
|
||||||
|
targetBurn: new Money(record.targetBurn),
|
||||||
|
remainingBurn: new Money(record.remainingBurn),
|
||||||
|
lastBurnMinute: record.lastBurnMinute,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toBurnRecordDomain(record: any): BurnRecordEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
blackHoleId: record.blackHoleId,
|
||||||
|
burnMinute: record.burnMinute,
|
||||||
|
burnAmount: new Money(record.burnAmount),
|
||||||
|
remainingTarget: new Money(record.remainingTarget),
|
||||||
|
sourceType: record.sourceType,
|
||||||
|
sourceAccountSeq: record.sourceAccountSeq,
|
||||||
|
sourceOrderNo: record.sourceOrderNo,
|
||||||
|
memo: record.memo,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface CirculationPoolEntity {
|
||||||
|
id: string;
|
||||||
|
totalShares: Money;
|
||||||
|
totalCash: Money;
|
||||||
|
totalInflow: Money;
|
||||||
|
totalOutflow: Money;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CirculationPoolTransactionType =
|
||||||
|
| 'SHARE_IN'
|
||||||
|
| 'SHARE_OUT'
|
||||||
|
| 'CASH_IN'
|
||||||
|
| 'CASH_OUT'
|
||||||
|
| 'TRADE_BUY'
|
||||||
|
| 'TRADE_SELL';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CirculationPoolRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getPool(): Promise<CirculationPoolEntity | null> {
|
||||||
|
const record = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePool(): Promise<CirculationPoolEntity> {
|
||||||
|
const existing = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.circulationPool.create({
|
||||||
|
data: {
|
||||||
|
totalShares: 0,
|
||||||
|
totalCash: 0,
|
||||||
|
totalInflow: 0,
|
||||||
|
totalOutflow: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卖出时积分股进入流通池
|
||||||
|
*/
|
||||||
|
async addSharesFromSell(
|
||||||
|
amount: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderId: string,
|
||||||
|
memo?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Circulation pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceBefore = new Decimal(pool.totalShares.toString());
|
||||||
|
const balanceAfter = balanceBefore.plus(amount.value);
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.circulationPool.update({
|
||||||
|
where: { id: pool.id },
|
||||||
|
data: {
|
||||||
|
totalShares: balanceAfter,
|
||||||
|
totalInflow: new Decimal(pool.totalInflow.toString()).plus(amount.value),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.circulationPoolTransaction.create({
|
||||||
|
data: {
|
||||||
|
poolId: pool.id,
|
||||||
|
type: 'TRADE_SELL',
|
||||||
|
assetType: 'SHARE',
|
||||||
|
amount: amount.value,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
counterpartyType: 'USER',
|
||||||
|
counterpartyAccountSeq: accountSeq,
|
||||||
|
referenceId: orderId,
|
||||||
|
referenceType: 'ORDER',
|
||||||
|
memo: memo || `卖出积分股进入流通池 ${amount.toFixed(8)}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 买入时积分股从流通池流出
|
||||||
|
*/
|
||||||
|
async removeSharesForBuy(
|
||||||
|
amount: Money,
|
||||||
|
accountSeq: string,
|
||||||
|
orderId: string,
|
||||||
|
memo?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Circulation pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceBefore = new Decimal(pool.totalShares.toString());
|
||||||
|
const balanceAfter = balanceBefore.minus(amount.value);
|
||||||
|
|
||||||
|
if (balanceAfter.isNegative()) {
|
||||||
|
throw new Error('Insufficient shares in circulation pool');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.circulationPool.update({
|
||||||
|
where: { id: pool.id },
|
||||||
|
data: {
|
||||||
|
totalShares: balanceAfter,
|
||||||
|
totalOutflow: new Decimal(pool.totalOutflow.toString()).plus(amount.value),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.circulationPoolTransaction.create({
|
||||||
|
data: {
|
||||||
|
poolId: pool.id,
|
||||||
|
type: 'TRADE_BUY',
|
||||||
|
assetType: 'SHARE',
|
||||||
|
amount: amount.value,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
counterpartyType: 'USER',
|
||||||
|
counterpartyAccountSeq: accountSeq,
|
||||||
|
referenceId: orderId,
|
||||||
|
referenceType: 'ORDER',
|
||||||
|
memo: memo || `买入积分股从流通池流出 ${amount.toFixed(8)}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流通池积分股数量
|
||||||
|
*/
|
||||||
|
async getSharesAmount(): Promise<Money> {
|
||||||
|
const pool = await this.getPool();
|
||||||
|
if (!pool) {
|
||||||
|
return Money.zero();
|
||||||
|
}
|
||||||
|
return pool.totalShares;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactions(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{
|
||||||
|
data: any[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const pool = await this.prisma.circulationPool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
return { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.circulationPoolTransaction.findMany({
|
||||||
|
where: { poolId: pool.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.circulationPoolTransaction.count({ where: { poolId: pool.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => ({
|
||||||
|
...r,
|
||||||
|
amount: r.amount.toString(),
|
||||||
|
balanceBefore: r.balanceBefore.toString(),
|
||||||
|
balanceAfter: r.balanceAfter.toString(),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): CirculationPoolEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
totalShares: new Money(record.totalShares),
|
||||||
|
totalCash: new Money(record.totalCash),
|
||||||
|
totalInflow: new Money(record.totalInflow),
|
||||||
|
totalOutflow: new Money(record.totalOutflow),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,46 @@ export class OrderRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存订单并更新销毁信息(用于卖出订单)
|
||||||
|
*/
|
||||||
|
async saveWithBurnInfo(
|
||||||
|
aggregate: OrderAggregate,
|
||||||
|
burnQuantity: Money,
|
||||||
|
effectiveQuantity: Money,
|
||||||
|
): Promise<string> {
|
||||||
|
const data = {
|
||||||
|
orderNo: aggregate.orderNo,
|
||||||
|
accountSequence: aggregate.accountSequence,
|
||||||
|
type: aggregate.type,
|
||||||
|
status: aggregate.status,
|
||||||
|
price: aggregate.price.value,
|
||||||
|
quantity: aggregate.quantity.value,
|
||||||
|
filledQuantity: aggregate.filledQuantity.value,
|
||||||
|
remainingQuantity: aggregate.remainingQuantity.value,
|
||||||
|
averagePrice: aggregate.averagePrice.value,
|
||||||
|
totalAmount: aggregate.totalAmount.value,
|
||||||
|
burnQuantity: burnQuantity.value,
|
||||||
|
burnMultiplier: burnQuantity.isZero()
|
||||||
|
? 0
|
||||||
|
: burnQuantity.value.dividedBy(aggregate.filledQuantity.value),
|
||||||
|
effectiveQuantity: effectiveQuantity.value,
|
||||||
|
cancelledAt: aggregate.cancelledAt,
|
||||||
|
completedAt: aggregate.completedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (aggregate.id) {
|
||||||
|
await this.prisma.order.update({
|
||||||
|
where: { id: aggregate.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return aggregate.id;
|
||||||
|
} else {
|
||||||
|
const created = await this.prisma.order.create({ data });
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findActiveOrders(type?: OrderType): Promise<OrderAggregate[]> {
|
async findActiveOrders(type?: OrderType): Promise<OrderAggregate[]> {
|
||||||
const where: any = {
|
const where: any = {
|
||||||
status: { in: [OrderStatus.PENDING, OrderStatus.PARTIAL] },
|
status: { in: [OrderStatus.PENDING, OrderStatus.PARTIAL] },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
|
||||||
|
export interface PriceSnapshotEntity {
|
||||||
|
id: string;
|
||||||
|
snapshotTime: Date;
|
||||||
|
price: Money;
|
||||||
|
greenPoints: Money;
|
||||||
|
blackHoleAmount: Money;
|
||||||
|
circulationPool: Money;
|
||||||
|
effectiveDenominator: Money;
|
||||||
|
minuteBurnRate: Money;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PriceSnapshotRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getLatestSnapshot(): Promise<PriceSnapshotEntity | null> {
|
||||||
|
const record = await this.prisma.priceSnapshot.findFirst({
|
||||||
|
orderBy: { snapshotTime: 'desc' },
|
||||||
|
});
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshotAt(time: Date): Promise<PriceSnapshotEntity | null> {
|
||||||
|
// 获取指定时间之前最近的快照
|
||||||
|
const record = await this.prisma.priceSnapshot.findFirst({
|
||||||
|
where: { snapshotTime: { lte: time } },
|
||||||
|
orderBy: { snapshotTime: 'desc' },
|
||||||
|
});
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSnapshot(data: {
|
||||||
|
snapshotTime: Date;
|
||||||
|
price: Money;
|
||||||
|
greenPoints: Money;
|
||||||
|
blackHoleAmount: Money;
|
||||||
|
circulationPool: Money;
|
||||||
|
effectiveDenominator: Money;
|
||||||
|
minuteBurnRate: Money;
|
||||||
|
}): Promise<PriceSnapshotEntity> {
|
||||||
|
const record = await this.prisma.priceSnapshot.create({
|
||||||
|
data: {
|
||||||
|
snapshotTime: data.snapshotTime,
|
||||||
|
price: data.price.value,
|
||||||
|
greenPoints: data.greenPoints.value,
|
||||||
|
blackHoleAmount: data.blackHoleAmount.value,
|
||||||
|
circulationPool: data.circulationPool.value,
|
||||||
|
effectiveDenominator: data.effectiveDenominator.value,
|
||||||
|
minuteBurnRate: data.minuteBurnRate.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPriceHistory(
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
limit: number = 1440,
|
||||||
|
): Promise<PriceSnapshotEntity[]> {
|
||||||
|
const records = await this.prisma.priceSnapshot.findMany({
|
||||||
|
where: {
|
||||||
|
snapshotTime: {
|
||||||
|
gte: startTime,
|
||||||
|
lte: endTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { snapshotTime: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshots(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{
|
||||||
|
data: PriceSnapshotEntity[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.priceSnapshot.findMany({
|
||||||
|
orderBy: { snapshotTime: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.priceSnapshot.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理旧的价格快照(保留指定天数)
|
||||||
|
*/
|
||||||
|
async cleanupOldSnapshots(retentionDays: number): Promise<number> {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||||
|
|
||||||
|
const result = await this.prisma.priceSnapshot.deleteMany({
|
||||||
|
where: { snapshotTime: { lt: cutoffDate } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): PriceSnapshotEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
snapshotTime: record.snapshotTime,
|
||||||
|
price: new Money(record.price),
|
||||||
|
greenPoints: new Money(record.greenPoints),
|
||||||
|
blackHoleAmount: new Money(record.blackHoleAmount),
|
||||||
|
circulationPool: new Money(record.circulationPool),
|
||||||
|
effectiveDenominator: new Money(record.effectiveDenominator),
|
||||||
|
minuteBurnRate: new Money(record.minuteBurnRate),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface ProcessedEventEntity {
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
sourceService: string;
|
||||||
|
processedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProcessedEventRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找已处理事件
|
||||||
|
*/
|
||||||
|
async findByEventId(eventId: string): Promise<ProcessedEventEntity | null> {
|
||||||
|
const record = await this.prisma.processedEvent.findUnique({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建已处理事件记录
|
||||||
|
*/
|
||||||
|
async create(data: {
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
sourceService: string;
|
||||||
|
}): Promise<ProcessedEventEntity> {
|
||||||
|
return this.prisma.processedEvent.create({
|
||||||
|
data: {
|
||||||
|
eventId: data.eventId,
|
||||||
|
eventType: data.eventType,
|
||||||
|
sourceService: data.sourceService,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查事件是否已处理
|
||||||
|
*/
|
||||||
|
async isProcessed(eventId: string): Promise<boolean> {
|
||||||
|
const count = await this.prisma.processedEvent.count({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除旧的已处理记录(清理)
|
||||||
|
* @param before 删除此时间之前的记录
|
||||||
|
*/
|
||||||
|
async deleteOldRecords(before: Date): Promise<number> {
|
||||||
|
const result = await this.prisma.processedEvent.deleteMany({
|
||||||
|
where: {
|
||||||
|
processedAt: { lt: before },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface SharePoolEntity {
|
||||||
|
id: string;
|
||||||
|
greenPoints: Money;
|
||||||
|
totalInflow: Money;
|
||||||
|
totalOutflow: Money;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SharePoolTransactionType = 'INJECT' | 'TRADE_IN' | 'TRADE_OUT';
|
||||||
|
|
||||||
|
export interface SharePoolTransactionEntity {
|
||||||
|
id: string;
|
||||||
|
poolId: string;
|
||||||
|
type: SharePoolTransactionType;
|
||||||
|
amount: Money;
|
||||||
|
balanceBefore: Money;
|
||||||
|
balanceAfter: Money;
|
||||||
|
referenceId: string | null;
|
||||||
|
referenceType: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SharePoolRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getPool(): Promise<SharePoolEntity | null> {
|
||||||
|
const record = await this.prisma.sharePool.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePool(initialGreenPoints: Money = Money.zero()): Promise<SharePoolEntity> {
|
||||||
|
const existing = await this.prisma.sharePool.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.sharePool.create({
|
||||||
|
data: {
|
||||||
|
greenPoints: initialGreenPoints.value,
|
||||||
|
totalInflow: initialGreenPoints.value,
|
||||||
|
totalOutflow: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入绿积分
|
||||||
|
*/
|
||||||
|
async inject(amount: Money, referenceId?: string, memo?: string): Promise<void> {
|
||||||
|
await this.updateBalance('INJECT', amount, true, referenceId, memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易流入(买入时绿积分进入股池)
|
||||||
|
*/
|
||||||
|
async tradeIn(amount: Money, tradeId: string): Promise<void> {
|
||||||
|
await this.updateBalance('TRADE_IN', amount, true, tradeId, `交易买入流入 ${amount.toFixed(8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易流出(卖出时绿积分从股池流出)
|
||||||
|
*/
|
||||||
|
async tradeOut(amount: Money, tradeId: string): Promise<void> {
|
||||||
|
await this.updateBalance(
|
||||||
|
'TRADE_OUT',
|
||||||
|
amount,
|
||||||
|
false,
|
||||||
|
tradeId,
|
||||||
|
`交易卖出流出 ${amount.toFixed(8)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateBalance(
|
||||||
|
type: SharePoolTransactionType,
|
||||||
|
amount: Money,
|
||||||
|
isInflow: boolean,
|
||||||
|
referenceId?: string,
|
||||||
|
memo?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = await this.prisma.sharePool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Share pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceBefore = new Decimal(pool.greenPoints.toString());
|
||||||
|
const balanceAfter = isInflow
|
||||||
|
? balanceBefore.plus(amount.value)
|
||||||
|
: balanceBefore.minus(amount.value);
|
||||||
|
|
||||||
|
if (balanceAfter.isNegative()) {
|
||||||
|
throw new Error('Insufficient green points in share pool');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTotalInflow = isInflow
|
||||||
|
? new Decimal(pool.totalInflow.toString()).plus(amount.value)
|
||||||
|
: pool.totalInflow;
|
||||||
|
const newTotalOutflow = !isInflow
|
||||||
|
? new Decimal(pool.totalOutflow.toString()).plus(amount.value)
|
||||||
|
: pool.totalOutflow;
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.sharePool.update({
|
||||||
|
where: { id: pool.id },
|
||||||
|
data: {
|
||||||
|
greenPoints: balanceAfter,
|
||||||
|
totalInflow: newTotalInflow,
|
||||||
|
totalOutflow: newTotalOutflow,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.sharePoolTransaction.create({
|
||||||
|
data: {
|
||||||
|
poolId: pool.id,
|
||||||
|
type,
|
||||||
|
amount: amount.value,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
referenceId,
|
||||||
|
referenceType: type === 'INJECT' ? 'INJECT' : 'TRADE',
|
||||||
|
memo,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactions(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{
|
||||||
|
data: SharePoolTransactionEntity[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const pool = await this.prisma.sharePool.findFirst();
|
||||||
|
if (!pool) {
|
||||||
|
return { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.sharePoolTransaction.findMany({
|
||||||
|
where: { poolId: pool.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.sharePoolTransaction.count({ where: { poolId: pool.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map((r) => this.toTransactionDomain(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): SharePoolEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
greenPoints: new Money(record.greenPoints),
|
||||||
|
totalInflow: new Money(record.totalInflow),
|
||||||
|
totalOutflow: new Money(record.totalOutflow),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toTransactionDomain(record: any): SharePoolTransactionEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
poolId: record.poolId,
|
||||||
|
type: record.type as SharePoolTransactionType,
|
||||||
|
amount: new Money(record.amount),
|
||||||
|
balanceBefore: new Money(record.balanceBefore),
|
||||||
|
balanceAfter: new Money(record.balanceAfter),
|
||||||
|
referenceId: record.referenceId,
|
||||||
|
referenceType: record.referenceType,
|
||||||
|
memo: record.memo,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,11 +15,11 @@ export class TradingAccountRepository {
|
||||||
return this.toDomain(record);
|
return this.toDomain(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(aggregate: TradingAccountAggregate): Promise<void> {
|
async save(aggregate: TradingAccountAggregate): Promise<string> {
|
||||||
const transactions = aggregate.pendingTransactions;
|
const transactions = aggregate.pendingTransactions;
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.tradingAccount.upsert({
|
const account = await tx.tradingAccount.upsert({
|
||||||
where: { accountSequence: aggregate.accountSequence },
|
where: { accountSequence: aggregate.accountSequence },
|
||||||
create: {
|
create: {
|
||||||
accountSequence: aggregate.accountSequence,
|
accountSequence: aggregate.accountSequence,
|
||||||
|
|
@ -55,9 +55,12 @@ export class TradingAccountRepository {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return account.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
aggregate.clearPendingTransactions();
|
aggregate.clearPendingTransactions();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransactions(
|
async getTransactions(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Money } from '../../../domain/value-objects/money.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface TradingConfigEntity {
|
||||||
|
id: string;
|
||||||
|
totalShares: Money;
|
||||||
|
burnTarget: Money;
|
||||||
|
burnPeriodMinutes: number;
|
||||||
|
minuteBurnRate: Money;
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TradingConfigRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getConfig(): Promise<TradingConfigEntity | null> {
|
||||||
|
const record = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeConfig(): Promise<TradingConfigEntity> {
|
||||||
|
const existing = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.prisma.tradingConfig.create({
|
||||||
|
data: {
|
||||||
|
totalShares: new Decimal('100020000000'),
|
||||||
|
burnTarget: new Decimal('10000000000'),
|
||||||
|
burnPeriodMinutes: 2102400, // 365 * 4 * 1440
|
||||||
|
minuteBurnRate: new Decimal('4756.468797564687'),
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(): Promise<void> {
|
||||||
|
const config = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Trading config not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tradingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(): Promise<void> {
|
||||||
|
const config = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tradingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMinuteBurnRate(newRate: Money): Promise<void> {
|
||||||
|
const config = await this.prisma.tradingConfig.findFirst();
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Trading config not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tradingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: { minuteBurnRate: newRate.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: any): TradingConfigEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
totalShares: new Money(record.totalShares),
|
||||||
|
burnTarget: new Money(record.burnTarget),
|
||||||
|
burnPeriodMinutes: record.burnPeriodMinutes,
|
||||||
|
minuteBurnRate: new Money(record.minuteBurnRate),
|
||||||
|
isActive: record.isActive,
|
||||||
|
activatedAt: record.activatedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue