feat(wallet-service): Implement complete wallet service with DDD architecture
- Add domain layer with aggregates (WalletAccount, LedgerEntry, DepositOrder, SettlementOrder) - Add value objects (Money, Balance, Hashpower, UserId, etc.) - Add domain events for event-driven architecture - Implement application layer with CQRS commands and queries - Add infrastructure layer with Prisma repositories - Implement REST API with NestJS controllers - Add JWT authentication with guards and strategies - Add comprehensive unit tests (69 tests) and E2E tests (23 tests) - Add documentation: ARCHITECTURE.md, API.md, DEVELOPMENT.md, TESTING.md, DEPLOYMENT.md - Add E2E testing guide for WSL2 environment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a966d71fa0
commit
845d841bca
|
|
@ -0,0 +1,4 @@
|
||||||
|
DATABASE_URL="postgresql://user:password@host:5432/database?schema=public"
|
||||||
|
NODE_ENV=development
|
||||||
|
APP_PORT=3002
|
||||||
|
JWT_SECRET=your-jwt-secret-key
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-journal
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
nul
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
@ -1,757 +0,0 @@
|
||||||
# Wallet Service 开发指导
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
Wallet Service 是 RWA 榴莲女皇平台的钱包账本微服务,负责管理用户的平台内部余额、充值入账、提现、资金流水记账等功能。
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **框架**: NestJS
|
|
||||||
- **数据库**: PostgreSQL + Prisma ORM
|
|
||||||
- **架构**: DDD + Hexagonal Architecture (六边形架构)
|
|
||||||
- **语言**: TypeScript
|
|
||||||
|
|
||||||
## 架构参考
|
|
||||||
|
|
||||||
请参考 `identity-service` 的架构模式,保持一致性:
|
|
||||||
|
|
||||||
```
|
|
||||||
wallet-service/
|
|
||||||
├── prisma/
|
|
||||||
│ └── schema.prisma # 数据库模型
|
|
||||||
├── src/
|
|
||||||
│ ├── api/ # Presentation Layer (API层)
|
|
||||||
│ │ ├── controllers/ # HTTP 控制器
|
|
||||||
│ │ │ ├── wallet.controller.ts
|
|
||||||
│ │ │ ├── ledger.controller.ts
|
|
||||||
│ │ │ ├── deposit.controller.ts
|
|
||||||
│ │ │ └── settlement.controller.ts
|
|
||||||
│ │ ├── dto/ # 数据传输对象
|
|
||||||
│ │ │ ├── wallet.dto.ts
|
|
||||||
│ │ │ ├── ledger.dto.ts
|
|
||||||
│ │ │ ├── deposit.dto.ts
|
|
||||||
│ │ │ └── settlement.dto.ts
|
|
||||||
│ │ └── api.module.ts
|
|
||||||
│ │
|
|
||||||
│ ├── application/ # Application Layer (应用层)
|
|
||||||
│ │ ├── commands/ # 命令对象
|
|
||||||
│ │ │ ├── handle-deposit.command.ts
|
|
||||||
│ │ │ ├── deduct-for-planting.command.ts
|
|
||||||
│ │ │ ├── allocate-funds.command.ts
|
|
||||||
│ │ │ ├── add-rewards.command.ts
|
|
||||||
│ │ │ ├── settle-rewards.command.ts
|
|
||||||
│ │ │ └── withdraw.command.ts
|
|
||||||
│ │ ├── queries/ # 查询对象
|
|
||||||
│ │ │ ├── get-my-wallet.query.ts
|
|
||||||
│ │ │ └── get-my-ledger.query.ts
|
|
||||||
│ │ └── services/
|
|
||||||
│ │ └── wallet-application.service.ts
|
|
||||||
│ │
|
|
||||||
│ ├── domain/ # Domain Layer (领域层)
|
|
||||||
│ │ ├── aggregates/ # 聚合根
|
|
||||||
│ │ │ ├── wallet-account.aggregate.ts
|
|
||||||
│ │ │ ├── ledger-entry.aggregate.ts
|
|
||||||
│ │ │ ├── deposit-order.aggregate.ts
|
|
||||||
│ │ │ └── settlement-order.aggregate.ts
|
|
||||||
│ │ ├── value-objects/ # 值对象
|
|
||||||
│ │ │ ├── wallet-id.vo.ts
|
|
||||||
│ │ │ ├── money.vo.ts
|
|
||||||
│ │ │ ├── balance.vo.ts
|
|
||||||
│ │ │ ├── wallet-balances.vo.ts
|
|
||||||
│ │ │ ├── wallet-rewards.vo.ts
|
|
||||||
│ │ │ ├── hashpower.vo.ts
|
|
||||||
│ │ │ ├── asset-type.enum.ts
|
|
||||||
│ │ │ ├── chain-type.enum.ts
|
|
||||||
│ │ │ ├── wallet-status.enum.ts
|
|
||||||
│ │ │ ├── ledger-entry-type.enum.ts
|
|
||||||
│ │ │ ├── deposit-status.enum.ts
|
|
||||||
│ │ │ └── settlement-status.enum.ts
|
|
||||||
│ │ ├── events/ # 领域事件
|
|
||||||
│ │ │ ├── deposit-completed.event.ts
|
|
||||||
│ │ │ ├── withdrawal-requested.event.ts
|
|
||||||
│ │ │ ├── reward-moved-to-settleable.event.ts
|
|
||||||
│ │ │ ├── reward-expired.event.ts
|
|
||||||
│ │ │ └── settlement-completed.event.ts
|
|
||||||
│ │ ├── repositories/ # 仓储接口 (Port)
|
|
||||||
│ │ │ ├── wallet-account.repository.interface.ts
|
|
||||||
│ │ │ ├── ledger-entry.repository.interface.ts
|
|
||||||
│ │ │ ├── deposit-order.repository.interface.ts
|
|
||||||
│ │ │ └── settlement-order.repository.interface.ts
|
|
||||||
│ │ └── services/ # 领域服务
|
|
||||||
│ │ └── wallet-ledger.service.ts
|
|
||||||
│ │
|
|
||||||
│ ├── infrastructure/ # Infrastructure Layer (基础设施层)
|
|
||||||
│ │ ├── persistence/ # 持久化实现 (Adapter)
|
|
||||||
│ │ │ ├── entities/ # Prisma 实体映射
|
|
||||||
│ │ │ ├── mappers/ # 领域模型与实体的映射
|
|
||||||
│ │ │ └── repositories/ # 仓储实现
|
|
||||||
│ │ └── infrastructure.module.ts
|
|
||||||
│ │
|
|
||||||
│ ├── app.module.ts
|
|
||||||
│ └── main.ts
|
|
||||||
├── .env.development
|
|
||||||
├── .env.example
|
|
||||||
├── package.json
|
|
||||||
└── tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第一阶段:项目初始化
|
|
||||||
|
|
||||||
### 1.1 创建 NestJS 项目
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/services
|
|
||||||
npx @nestjs/cli new wallet-service --skip-git --package-manager npm
|
|
||||||
cd wallet-service
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @nestjs/config @prisma/client class-validator class-transformer uuid
|
|
||||||
npm install -D prisma @types/uuid
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 配置环境变量
|
|
||||||
|
|
||||||
创建 `.env.development`:
|
|
||||||
```env
|
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_wallet?schema=public"
|
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3002
|
|
||||||
```
|
|
||||||
|
|
||||||
创建 `.env.example`:
|
|
||||||
```env
|
|
||||||
DATABASE_URL="postgresql://user:password@host:5432/database?schema=public"
|
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3002
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第二阶段:数据库设计 (Prisma Schema)
|
|
||||||
|
|
||||||
### 2.1 创建 prisma/schema.prisma
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 钱包账户表 (状态表)
|
|
||||||
// ============================================
|
|
||||||
model WalletAccount {
|
|
||||||
id BigInt @id @default(autoincrement()) @map("wallet_id")
|
|
||||||
userId BigInt @unique @map("user_id")
|
|
||||||
|
|
||||||
// USDT 余额
|
|
||||||
usdtAvailable Decimal @default(0) @map("usdt_available") @db.Decimal(20, 8)
|
|
||||||
usdtFrozen Decimal @default(0) @map("usdt_frozen") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// DST 余额
|
|
||||||
dstAvailable Decimal @default(0) @map("dst_available") @db.Decimal(20, 8)
|
|
||||||
dstFrozen Decimal @default(0) @map("dst_frozen") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// BNB 余额
|
|
||||||
bnbAvailable Decimal @default(0) @map("bnb_available") @db.Decimal(20, 8)
|
|
||||||
bnbFrozen Decimal @default(0) @map("bnb_frozen") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// OG 余额
|
|
||||||
ogAvailable Decimal @default(0) @map("og_available") @db.Decimal(20, 8)
|
|
||||||
ogFrozen Decimal @default(0) @map("og_frozen") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// RWAD 余额
|
|
||||||
rwadAvailable Decimal @default(0) @map("rwad_available") @db.Decimal(20, 8)
|
|
||||||
rwadFrozen Decimal @default(0) @map("rwad_frozen") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// 算力
|
|
||||||
hashpower Decimal @default(0) @map("hashpower") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// 待领取收益
|
|
||||||
pendingUsdt Decimal @default(0) @map("pending_usdt") @db.Decimal(20, 8)
|
|
||||||
pendingHashpower Decimal @default(0) @map("pending_hashpower") @db.Decimal(20, 8)
|
|
||||||
pendingExpireAt DateTime? @map("pending_expire_at")
|
|
||||||
|
|
||||||
// 可结算收益
|
|
||||||
settleableUsdt Decimal @default(0) @map("settleable_usdt") @db.Decimal(20, 8)
|
|
||||||
settleableHashpower Decimal @default(0) @map("settleable_hashpower") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// 已结算总额
|
|
||||||
settledTotalUsdt Decimal @default(0) @map("settled_total_usdt") @db.Decimal(20, 8)
|
|
||||||
settledTotalHashpower Decimal @default(0) @map("settled_total_hashpower") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// 已过期总额
|
|
||||||
expiredTotalUsdt Decimal @default(0) @map("expired_total_usdt") @db.Decimal(20, 8)
|
|
||||||
expiredTotalHashpower Decimal @default(0) @map("expired_total_hashpower") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
status String @default("ACTIVE") @map("status") @db.VarChar(20)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
@@map("wallet_accounts")
|
|
||||||
@@index([userId])
|
|
||||||
@@index([usdtAvailable(sort: Desc)])
|
|
||||||
@@index([hashpower(sort: Desc)])
|
|
||||||
@@index([status])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 账本流水表 (行为表, append-only)
|
|
||||||
// ============================================
|
|
||||||
model LedgerEntry {
|
|
||||||
id BigInt @id @default(autoincrement()) @map("entry_id")
|
|
||||||
userId BigInt @map("user_id")
|
|
||||||
|
|
||||||
// 流水类型
|
|
||||||
entryType String @map("entry_type") @db.VarChar(50)
|
|
||||||
|
|
||||||
// 金额变动 (正数入账, 负数支出)
|
|
||||||
amount Decimal @map("amount") @db.Decimal(20, 8)
|
|
||||||
assetType String @map("asset_type") @db.VarChar(20)
|
|
||||||
|
|
||||||
// 余额快照 (操作后余额)
|
|
||||||
balanceAfter Decimal? @map("balance_after") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// 关联引用
|
|
||||||
refOrderId String? @map("ref_order_id") @db.VarChar(100)
|
|
||||||
refTxHash String? @map("ref_tx_hash") @db.VarChar(100)
|
|
||||||
|
|
||||||
// 备注
|
|
||||||
memo String? @map("memo") @db.VarChar(500)
|
|
||||||
|
|
||||||
// 扩展数据
|
|
||||||
payloadJson Json? @map("payload_json")
|
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
@@map("wallet_ledger_entries")
|
|
||||||
@@index([userId, createdAt(sort: Desc)])
|
|
||||||
@@index([entryType])
|
|
||||||
@@index([assetType])
|
|
||||||
@@index([refOrderId])
|
|
||||||
@@index([refTxHash])
|
|
||||||
@@index([createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 充值订单表
|
|
||||||
// ============================================
|
|
||||||
model DepositOrder {
|
|
||||||
id BigInt @id @default(autoincrement()) @map("order_id")
|
|
||||||
userId BigInt @map("user_id")
|
|
||||||
|
|
||||||
// 充值信息
|
|
||||||
chainType String @map("chain_type") @db.VarChar(20)
|
|
||||||
amount Decimal @map("amount") @db.Decimal(20, 8)
|
|
||||||
txHash String @unique @map("tx_hash") @db.VarChar(100)
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
status String @default("PENDING") @map("status") @db.VarChar(20)
|
|
||||||
confirmedAt DateTime? @map("confirmed_at")
|
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
@@map("deposit_orders")
|
|
||||||
@@index([userId])
|
|
||||||
@@index([txHash])
|
|
||||||
@@index([status])
|
|
||||||
@@index([chainType])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 结算订单表
|
|
||||||
// ============================================
|
|
||||||
model SettlementOrder {
|
|
||||||
id BigInt @id @default(autoincrement()) @map("order_id")
|
|
||||||
userId BigInt @map("user_id")
|
|
||||||
|
|
||||||
// 结算信息
|
|
||||||
usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8)
|
|
||||||
settleCurrency String @map("settle_currency") @db.VarChar(10)
|
|
||||||
|
|
||||||
// SWAP 信息
|
|
||||||
swapTxHash String? @map("swap_tx_hash") @db.VarChar(100)
|
|
||||||
receivedAmount Decimal? @map("received_amount") @db.Decimal(20, 8)
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
status String @default("PENDING") @map("status") @db.VarChar(20)
|
|
||||||
settledAt DateTime? @map("settled_at")
|
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
@@map("settlement_orders")
|
|
||||||
@@index([userId])
|
|
||||||
@@index([status])
|
|
||||||
@@index([settleCurrency])
|
|
||||||
@@index([createdAt])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 初始化数据库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev --name init
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第三阶段:领域层实现
|
|
||||||
|
|
||||||
### 3.1 值对象 (Value Objects)
|
|
||||||
|
|
||||||
#### 3.1.1 asset-type.enum.ts
|
|
||||||
```typescript
|
|
||||||
export enum AssetType {
|
|
||||||
USDT = 'USDT',
|
|
||||||
DST = 'DST',
|
|
||||||
BNB = 'BNB',
|
|
||||||
OG = 'OG',
|
|
||||||
RWAD = 'RWAD',
|
|
||||||
HASHPOWER = 'HASHPOWER',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.2 chain-type.enum.ts
|
|
||||||
```typescript
|
|
||||||
export enum ChainType {
|
|
||||||
KAVA = 'KAVA',
|
|
||||||
DST = 'DST',
|
|
||||||
BSC = 'BSC',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.3 wallet-status.enum.ts
|
|
||||||
```typescript
|
|
||||||
export enum WalletStatus {
|
|
||||||
ACTIVE = 'ACTIVE',
|
|
||||||
FROZEN = 'FROZEN',
|
|
||||||
CLOSED = 'CLOSED',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.4 ledger-entry-type.enum.ts
|
|
||||||
```typescript
|
|
||||||
export enum LedgerEntryType {
|
|
||||||
DEPOSIT_KAVA = 'DEPOSIT_KAVA',
|
|
||||||
DEPOSIT_BSC = 'DEPOSIT_BSC',
|
|
||||||
PLANT_PAYMENT = 'PLANT_PAYMENT',
|
|
||||||
REWARD_PENDING = 'REWARD_PENDING',
|
|
||||||
REWARD_TO_SETTLEABLE = 'REWARD_TO_SETTLEABLE',
|
|
||||||
REWARD_EXPIRED = 'REWARD_EXPIRED',
|
|
||||||
REWARD_SETTLED = 'REWARD_SETTLED',
|
|
||||||
TRANSFER_TO_POOL = 'TRANSFER_TO_POOL',
|
|
||||||
SWAP_EXECUTED = 'SWAP_EXECUTED',
|
|
||||||
WITHDRAWAL = 'WITHDRAWAL',
|
|
||||||
TRANSFER_IN = 'TRANSFER_IN',
|
|
||||||
TRANSFER_OUT = 'TRANSFER_OUT',
|
|
||||||
FREEZE = 'FREEZE',
|
|
||||||
UNFREEZE = 'UNFREEZE',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.5 deposit-status.enum.ts
|
|
||||||
```typescript
|
|
||||||
export enum DepositStatus {
|
|
||||||
PENDING = 'PENDING',
|
|
||||||
CONFIRMED = 'CONFIRMED',
|
|
||||||
FAILED = 'FAILED',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.6 settlement-status.enum.ts
|
|
||||||
```typescript
|
|
||||||
export enum SettlementStatus {
|
|
||||||
PENDING = 'PENDING',
|
|
||||||
SWAPPING = 'SWAPPING',
|
|
||||||
COMPLETED = 'COMPLETED',
|
|
||||||
FAILED = 'FAILED',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SettleCurrency {
|
|
||||||
BNB = 'BNB',
|
|
||||||
OG = 'OG',
|
|
||||||
USDT = 'USDT',
|
|
||||||
DST = 'DST',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.7 money.vo.ts
|
|
||||||
```typescript
|
|
||||||
export class Money {
|
|
||||||
private constructor(
|
|
||||||
public readonly amount: number,
|
|
||||||
public readonly currency: string,
|
|
||||||
) {
|
|
||||||
if (amount < 0) {
|
|
||||||
throw new Error('Money amount cannot be negative');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static create(amount: number, currency: string): Money {
|
|
||||||
return new Money(amount, currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
static USDT(amount: number): Money {
|
|
||||||
return new Money(amount, 'USDT');
|
|
||||||
}
|
|
||||||
|
|
||||||
static zero(currency: string = 'USDT'): Money {
|
|
||||||
return new Money(0, currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
add(other: Money): Money {
|
|
||||||
this.ensureSameCurrency(other);
|
|
||||||
return new Money(this.amount + other.amount, this.currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
subtract(other: Money): Money {
|
|
||||||
this.ensureSameCurrency(other);
|
|
||||||
if (this.amount < other.amount) {
|
|
||||||
throw new Error('Insufficient balance');
|
|
||||||
}
|
|
||||||
return new Money(this.amount - other.amount, this.currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
lessThan(other: Money): boolean {
|
|
||||||
this.ensureSameCurrency(other);
|
|
||||||
return this.amount < other.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
greaterThan(other: Money): boolean {
|
|
||||||
this.ensureSameCurrency(other);
|
|
||||||
return this.amount > other.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
isZero(): boolean {
|
|
||||||
return this.amount === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureSameCurrency(other: Money): void {
|
|
||||||
if (this.currency !== other.currency) {
|
|
||||||
throw new Error('Currency mismatch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.8 balance.vo.ts
|
|
||||||
```typescript
|
|
||||||
import { Money } from './money.vo';
|
|
||||||
|
|
||||||
export class Balance {
|
|
||||||
constructor(
|
|
||||||
public readonly available: Money,
|
|
||||||
public readonly frozen: Money,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
static zero(): Balance {
|
|
||||||
return new Balance(Money.zero(), Money.zero());
|
|
||||||
}
|
|
||||||
|
|
||||||
add(amount: Money): Balance {
|
|
||||||
return new Balance(this.available.add(amount), this.frozen);
|
|
||||||
}
|
|
||||||
|
|
||||||
deduct(amount: Money): Balance {
|
|
||||||
if (this.available.lessThan(amount)) {
|
|
||||||
throw new Error('Insufficient available balance');
|
|
||||||
}
|
|
||||||
return new Balance(this.available.subtract(amount), this.frozen);
|
|
||||||
}
|
|
||||||
|
|
||||||
freeze(amount: Money): Balance {
|
|
||||||
if (this.available.lessThan(amount)) {
|
|
||||||
throw new Error('Insufficient available balance to freeze');
|
|
||||||
}
|
|
||||||
return new Balance(
|
|
||||||
this.available.subtract(amount),
|
|
||||||
this.frozen.add(amount),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
unfreeze(amount: Money): Balance {
|
|
||||||
if (this.frozen.lessThan(amount)) {
|
|
||||||
throw new Error('Insufficient frozen balance to unfreeze');
|
|
||||||
}
|
|
||||||
return new Balance(
|
|
||||||
this.available.add(amount),
|
|
||||||
this.frozen.subtract(amount),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get total(): Money {
|
|
||||||
return this.available.add(this.frozen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.9 hashpower.vo.ts
|
|
||||||
```typescript
|
|
||||||
export class Hashpower {
|
|
||||||
private constructor(public readonly value: number) {
|
|
||||||
if (value < 0) {
|
|
||||||
throw new Error('Hashpower cannot be negative');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static create(value: number): Hashpower {
|
|
||||||
return new Hashpower(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static zero(): Hashpower {
|
|
||||||
return new Hashpower(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
add(other: Hashpower): Hashpower {
|
|
||||||
return new Hashpower(this.value + other.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
subtract(other: Hashpower): Hashpower {
|
|
||||||
if (this.value < other.value) {
|
|
||||||
throw new Error('Insufficient hashpower');
|
|
||||||
}
|
|
||||||
return new Hashpower(this.value - other.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
isZero(): boolean {
|
|
||||||
return this.value === 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 聚合根 (Aggregates)
|
|
||||||
|
|
||||||
#### 3.2.1 wallet-account.aggregate.ts
|
|
||||||
|
|
||||||
实现 `WalletAccount` 聚合根,包含:
|
|
||||||
- 余额管理 (USDT/DST/BNB/OG/RWAD/算力)
|
|
||||||
- 收益汇总 (待领取/可结算/已结算/过期)
|
|
||||||
- 核心领域行为:deposit, deduct, freeze, unfreeze, addPendingReward, movePendingToSettleable, settleReward, withdraw
|
|
||||||
|
|
||||||
#### 3.2.2 ledger-entry.aggregate.ts
|
|
||||||
|
|
||||||
实现 `LedgerEntry` 聚合根 (append-only),包含:
|
|
||||||
- 流水类型
|
|
||||||
- 金额变动
|
|
||||||
- 余额快照
|
|
||||||
- 关联引用
|
|
||||||
|
|
||||||
### 3.3 仓储接口 (Repository Interfaces)
|
|
||||||
|
|
||||||
#### 3.3.1 wallet-account.repository.interface.ts
|
|
||||||
```typescript
|
|
||||||
export interface IWalletAccountRepository {
|
|
||||||
save(wallet: WalletAccount): Promise<void>;
|
|
||||||
findById(walletId: bigint): Promise<WalletAccount | null>;
|
|
||||||
findByUserId(userId: bigint): Promise<WalletAccount | null>;
|
|
||||||
getOrCreate(userId: bigint): Promise<WalletAccount>;
|
|
||||||
findByUserIds(userIds: bigint[]): Promise<Map<string, WalletAccount>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WALLET_ACCOUNT_REPOSITORY = Symbol('IWalletAccountRepository');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.2 ledger-entry.repository.interface.ts
|
|
||||||
```typescript
|
|
||||||
export interface ILedgerEntryRepository {
|
|
||||||
save(entry: LedgerEntry): Promise<void>;
|
|
||||||
saveAll(entries: LedgerEntry[]): Promise<void>;
|
|
||||||
findByUserId(userId: bigint, filters?: LedgerFilters, pagination?: Pagination): Promise<LedgerEntry[]>;
|
|
||||||
findByRefOrderId(refOrderId: string): Promise<LedgerEntry[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LEDGER_ENTRY_REPOSITORY = Symbol('ILedgerEntryRepository');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第四阶段:基础设施层实现
|
|
||||||
|
|
||||||
### 4.1 实体映射 (Entities)
|
|
||||||
|
|
||||||
创建 Prisma 实体到领域模型的映射类。
|
|
||||||
|
|
||||||
### 4.2 仓储实现 (Repository Implementations)
|
|
||||||
|
|
||||||
实现所有仓储接口,使用 Prisma Client 进行数据库操作。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第五阶段:应用层实现
|
|
||||||
|
|
||||||
### 5.1 命令对象 (Commands)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// handle-deposit.command.ts
|
|
||||||
export class HandleDepositCommand {
|
|
||||||
constructor(
|
|
||||||
public readonly userId: string,
|
|
||||||
public readonly amount: number,
|
|
||||||
public readonly chainType: ChainType,
|
|
||||||
public readonly txHash: string,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deduct-for-planting.command.ts
|
|
||||||
export class DeductForPlantingCommand {
|
|
||||||
constructor(
|
|
||||||
public readonly userId: string,
|
|
||||||
public readonly amount: number,
|
|
||||||
public readonly orderId: string,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 应用服务 (Application Service)
|
|
||||||
|
|
||||||
实现 `WalletApplicationService`,包含所有用例:
|
|
||||||
- handleDeposit - 处理充值
|
|
||||||
- deductForPlanting - 认种扣款
|
|
||||||
- allocateFunds - 资金分配
|
|
||||||
- addRewards - 增加奖励
|
|
||||||
- movePendingToSettleable - 待领取→可结算
|
|
||||||
- settleRewards - 结算收益
|
|
||||||
- getMyWallet - 查询我的钱包
|
|
||||||
- getMyLedger - 查询我的流水
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第六阶段:API层实现
|
|
||||||
|
|
||||||
### 6.1 DTO 定义
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// wallet.dto.ts
|
|
||||||
export class WalletDTO {
|
|
||||||
walletId: string;
|
|
||||||
userId: string;
|
|
||||||
balances: BalancesDTO;
|
|
||||||
rewards: RewardsDTO;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BalancesDTO {
|
|
||||||
usdt: BalanceDTO;
|
|
||||||
dst: BalanceDTO;
|
|
||||||
bnb: BalanceDTO;
|
|
||||||
og: BalanceDTO;
|
|
||||||
rwad: BalanceDTO;
|
|
||||||
hashpower: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BalanceDTO {
|
|
||||||
available: number;
|
|
||||||
frozen: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 控制器实现
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// wallet.controller.ts
|
|
||||||
@Controller('wallet')
|
|
||||||
export class WalletController {
|
|
||||||
constructor(private readonly walletService: WalletApplicationService) {}
|
|
||||||
|
|
||||||
@Get('my-wallet')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async getMyWallet(@CurrentUser() user: User): Promise<WalletDTO> {
|
|
||||||
return this.walletService.getMyWallet({ userId: user.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ledger.controller.ts
|
|
||||||
@Controller('wallet/ledger')
|
|
||||||
export class LedgerController {
|
|
||||||
constructor(private readonly walletService: WalletApplicationService) {}
|
|
||||||
|
|
||||||
@Get('my-ledger')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async getMyLedger(
|
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Query() query: GetMyLedgerQueryDTO,
|
|
||||||
): Promise<LedgerEntryDTO[]> {
|
|
||||||
return this.walletService.getMyLedger({
|
|
||||||
userId: user.id,
|
|
||||||
...query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第七阶段:模块配置
|
|
||||||
|
|
||||||
### 7.1 app.module.ts
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigModule.forRoot({
|
|
||||||
isGlobal: true,
|
|
||||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
|
||||||
}),
|
|
||||||
ApiModule,
|
|
||||||
InfrastructureModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键业务规则 (不变式)
|
|
||||||
|
|
||||||
1. **余额不能为负**: 任何币种的可用余额不能为负数
|
|
||||||
2. **流水表只能追加**: 账本流水表只能 INSERT,不能 UPDATE/DELETE
|
|
||||||
3. **每笔流水必须有余额快照**: 每笔流水必须记录操作后的余额快照
|
|
||||||
4. **结算金额不能超过可结算余额**: 结算金额必须 ≤ 可结算余额
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 端点汇总
|
|
||||||
|
|
||||||
| 方法 | 路径 | 描述 | 认证 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | /wallet/my-wallet | 查询我的钱包 | 需要 |
|
|
||||||
| GET | /wallet/ledger/my-ledger | 查询我的流水 | 需要 |
|
|
||||||
| POST | /wallet/deposit | 充值入账 (内部) | 服务间 |
|
|
||||||
| POST | /wallet/withdraw | 提现申请 | 需要 |
|
|
||||||
| POST | /wallet/settle | 结算收益 | 需要 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开发顺序建议
|
|
||||||
|
|
||||||
1. 项目初始化和 Prisma Schema
|
|
||||||
2. 值对象实现
|
|
||||||
3. 聚合根实现
|
|
||||||
4. 仓储接口定义
|
|
||||||
5. 仓储实现
|
|
||||||
6. 领域服务实现
|
|
||||||
7. 应用服务实现
|
|
||||||
8. DTO 和控制器实现
|
|
||||||
9. 模块配置和测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. 所有金额使用 `Decimal(20, 8)` 存储,避免浮点数精度问题
|
|
||||||
2. 流水表是 append-only,不允许更新或删除
|
|
||||||
3. 每次余额变动都要创建对应的流水记录
|
|
||||||
4. 使用事务确保余额和流水的一致性
|
|
||||||
5. 参考 identity-service 的代码风格和命名规范
|
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
# Wallet Service API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档描述 Wallet Service 的所有 HTTP API 接口。服务基础路径: `/api/v1`
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
|
||||||
|
除标注为 `公开` 的接口外,所有接口都需要 JWT Bearer Token 认证:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
JWT Payload 结构:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "用户ID",
|
||||||
|
"seq": 1001,
|
||||||
|
"exp": 1700000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { ... },
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"message": "错误描述",
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 健康检查
|
||||||
|
|
||||||
|
### GET /api/v1/health
|
||||||
|
|
||||||
|
检查服务健康状态。
|
||||||
|
|
||||||
|
**认证**: 公开
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "wallet-service",
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 钱包接口
|
||||||
|
|
||||||
|
### GET /api/v1/wallet/my-wallet
|
||||||
|
|
||||||
|
查询当前用户的钱包信息。
|
||||||
|
|
||||||
|
**认证**: 需要
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"walletId": "1",
|
||||||
|
"userId": "12345",
|
||||||
|
"balances": {
|
||||||
|
"usdt": { "available": 1000.5, "frozen": 0 },
|
||||||
|
"dst": { "available": 0, "frozen": 0 },
|
||||||
|
"bnb": { "available": 0.1, "frozen": 0 },
|
||||||
|
"og": { "available": 0, "frozen": 0 },
|
||||||
|
"rwad": { "available": 100, "frozen": 0 }
|
||||||
|
},
|
||||||
|
"hashpower": 500,
|
||||||
|
"rewards": {
|
||||||
|
"pendingUsdt": 50,
|
||||||
|
"pendingHashpower": 100,
|
||||||
|
"pendingExpireAt": "2024-01-02T00:00:00.000Z",
|
||||||
|
"settleableUsdt": 200,
|
||||||
|
"settleableHashpower": 400,
|
||||||
|
"settledTotalUsdt": 1000,
|
||||||
|
"settledTotalHashpower": 2000,
|
||||||
|
"expiredTotalUsdt": 50,
|
||||||
|
"expiredTotalHashpower": 100
|
||||||
|
},
|
||||||
|
"status": "ACTIVE"
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 描述 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| walletId | string | 钱包ID |
|
||||||
|
| userId | string | 用户ID |
|
||||||
|
| balances.{coin}.available | number | 可用余额 |
|
||||||
|
| balances.{coin}.frozen | number | 冻结余额 |
|
||||||
|
| hashpower | number | 当前算力 |
|
||||||
|
| rewards.pendingUsdt | number | 待领取USDT奖励 |
|
||||||
|
| rewards.pendingHashpower | number | 待领取算力奖励 |
|
||||||
|
| rewards.pendingExpireAt | string | 待领取奖励过期时间 |
|
||||||
|
| rewards.settleableUsdt | number | 可结算USDT |
|
||||||
|
| rewards.settleableHashpower | number | 可结算算力 |
|
||||||
|
| rewards.settledTotalUsdt | number | 已结算USDT累计 |
|
||||||
|
| rewards.settledTotalHashpower | number | 已结算算力累计 |
|
||||||
|
| rewards.expiredTotalUsdt | number | 已过期USDT累计 |
|
||||||
|
| rewards.expiredTotalHashpower | number | 已过期算力累计 |
|
||||||
|
| status | string | 钱包状态: ACTIVE, FROZEN |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/v1/wallet/claim-rewards
|
||||||
|
|
||||||
|
领取待领取的奖励,将其转为可结算状态。
|
||||||
|
|
||||||
|
**认证**: 需要
|
||||||
|
|
||||||
|
**请求体**: 无
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"message": "Rewards claimed successfully"
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码**:
|
||||||
|
|
||||||
|
| 错误码 | HTTP状态 | 描述 |
|
||||||
|
|-------|---------|------|
|
||||||
|
| NO_PENDING_REWARDS | 400 | 没有待领取的奖励 |
|
||||||
|
| WALLET_FROZEN | 403 | 钱包已冻结 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/v1/wallet/settle
|
||||||
|
|
||||||
|
结算可结算的USDT奖励为指定币种。
|
||||||
|
|
||||||
|
**认证**: 需要
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"usdtAmount": 100,
|
||||||
|
"settleCurrency": "BNB"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
|
|-----|------|-----|------|
|
||||||
|
| usdtAmount | number | 是 | 结算USDT金额 (>0) |
|
||||||
|
| settleCurrency | string | 是 | 结算目标币种: BNB, USDT, DST, OG, RWAD |
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"settlementOrderId": "12345"
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码**:
|
||||||
|
|
||||||
|
| 错误码 | HTTP状态 | 描述 |
|
||||||
|
|-------|---------|------|
|
||||||
|
| INSUFFICIENT_BALANCE | 400 | 可结算余额不足 |
|
||||||
|
| WALLET_FROZEN | 403 | 钱包已冻结 |
|
||||||
|
| VALIDATION_ERROR | 400 | 参数验证失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 流水接口
|
||||||
|
|
||||||
|
### GET /api/v1/wallet/ledger/my-ledger
|
||||||
|
|
||||||
|
查询当前用户的账本流水记录。
|
||||||
|
|
||||||
|
**认证**: 需要
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 描述 |
|
||||||
|
|-----|------|-----|-------|------|
|
||||||
|
| page | number | 否 | 1 | 页码 (>=1) |
|
||||||
|
| pageSize | number | 否 | 20 | 每页数量 (1-100) |
|
||||||
|
| entryType | string | 否 | - | 流水类型过滤 |
|
||||||
|
| assetType | string | 否 | - | 资产类型过滤 |
|
||||||
|
| startDate | string | 否 | - | 开始日期 (ISO8601) |
|
||||||
|
| endDate | string | 否 | - | 结束日期 (ISO8601) |
|
||||||
|
|
||||||
|
**entryType 可选值**:
|
||||||
|
- `DEPOSIT_KAVA` - KAVA链充值
|
||||||
|
- `DEPOSIT_BSC` - BSC链充值
|
||||||
|
- `PLANT_PAYMENT` - 认种支付
|
||||||
|
- `REWARD_PENDING` - 奖励待领取
|
||||||
|
- `REWARD_TO_SETTLEABLE` - 奖励转可结算
|
||||||
|
- `REWARD_SETTLED` - 奖励已结算
|
||||||
|
- `REWARD_EXPIRED` - 奖励已过期
|
||||||
|
- `WITHDRAWAL` - 提现
|
||||||
|
- `ADMIN_ADJUST` - 管理员调整
|
||||||
|
|
||||||
|
**assetType 可选值**:
|
||||||
|
- `USDT`, `DST`, `BNB`, `OG`, `RWAD`, `HASHPOWER`
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "1001",
|
||||||
|
"entryType": "DEPOSIT_KAVA",
|
||||||
|
"amount": 100,
|
||||||
|
"assetType": "USDT",
|
||||||
|
"balanceAfter": 1100,
|
||||||
|
"refOrderId": null,
|
||||||
|
"refTxHash": "0x1234...5678",
|
||||||
|
"memo": "Deposit from KAVA",
|
||||||
|
"createdAt": "2024-01-01T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1002",
|
||||||
|
"entryType": "PLANT_PAYMENT",
|
||||||
|
"amount": -50,
|
||||||
|
"assetType": "USDT",
|
||||||
|
"balanceAfter": 1050,
|
||||||
|
"refOrderId": "order_123",
|
||||||
|
"refTxHash": null,
|
||||||
|
"memo": "Plant payment",
|
||||||
|
"createdAt": "2024-01-01T11:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"totalPages": 5
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 描述 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| id | string | 流水ID |
|
||||||
|
| entryType | string | 流水类型 |
|
||||||
|
| amount | number | 金额 (正入账/负支出) |
|
||||||
|
| assetType | string | 资产类型 |
|
||||||
|
| balanceAfter | number | 操作后余额 |
|
||||||
|
| refOrderId | string | 关联订单号 |
|
||||||
|
| refTxHash | string | 关联交易哈希 |
|
||||||
|
| memo | string | 备注 |
|
||||||
|
| createdAt | string | 创建时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 充值接口 (内部服务)
|
||||||
|
|
||||||
|
### POST /api/v1/wallet/deposit
|
||||||
|
|
||||||
|
处理链上充值确认后的入账。
|
||||||
|
|
||||||
|
**认证**: 公开 (仅限内部服务调用)
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "12345",
|
||||||
|
"amount": 100,
|
||||||
|
"chainType": "KAVA",
|
||||||
|
"txHash": "0x1234567890abcdef..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
|
|-----|------|-----|------|
|
||||||
|
| userId | string | 是 | 用户ID |
|
||||||
|
| amount | number | 是 | 充值金额 (>0) |
|
||||||
|
| chainType | string | 是 | 链类型: KAVA, BSC |
|
||||||
|
| txHash | string | 是 | 交易哈希 (唯一) |
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"message": "Deposit processed successfully"
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码**:
|
||||||
|
|
||||||
|
| 错误码 | HTTP状态 | 描述 |
|
||||||
|
|-------|---------|------|
|
||||||
|
| DUPLICATE_TRANSACTION | 409 | 重复交易 (txHash已存在) |
|
||||||
|
| VALIDATION_ERROR | 400 | 参数验证失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码汇总
|
||||||
|
|
||||||
|
| 错误码 | HTTP状态 | 描述 |
|
||||||
|
|-------|---------|------|
|
||||||
|
| VALIDATION_ERROR | 400 | 参数验证失败 |
|
||||||
|
| UNAUTHORIZED | 401 | 未认证 |
|
||||||
|
| FORBIDDEN | 403 | 无权限 |
|
||||||
|
| NOT_FOUND | 404 | 资源不存在 |
|
||||||
|
| DUPLICATE_TRANSACTION | 409 | 重复交易 |
|
||||||
|
| INSUFFICIENT_BALANCE | 400 | 余额不足 |
|
||||||
|
| WALLET_FROZEN | 403 | 钱包已冻结 |
|
||||||
|
| WALLET_NOT_FOUND | 404 | 钱包不存在 |
|
||||||
|
| NO_PENDING_REWARDS | 400 | 没有待领取的奖励 |
|
||||||
|
| INTERNAL_ERROR | 500 | 内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swagger 文档
|
||||||
|
|
||||||
|
启动服务后访问: `http://localhost:3000/api-docs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调用示例
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取钱包信息
|
||||||
|
curl -X GET "http://localhost:3000/api/v1/wallet/my-wallet" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
|
# 领取奖励
|
||||||
|
curl -X POST "http://localhost:3000/api/v1/wallet/claim-rewards" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
|
# 结算奖励
|
||||||
|
curl -X POST "http://localhost:3000/api/v1/wallet/settle" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"usdtAmount": 100, "settleCurrency": "BNB"}'
|
||||||
|
|
||||||
|
# 查询流水
|
||||||
|
curl -X GET "http://localhost:3000/api/v1/wallet/ledger/my-ledger?page=1&pageSize=10" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
|
# 充值入账 (内部服务)
|
||||||
|
curl -X POST "http://localhost:3000/api/v1/wallet/deposit" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"userId": "12345", "amount": 100, "chainType": "KAVA", "txHash": "0x..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript (Fetch)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 获取钱包信息
|
||||||
|
const response = await fetch('/api/v1/wallet/my-wallet', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 结算奖励
|
||||||
|
const settleResponse = await fetch('/api/v1/wallet/settle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
usdtAmount: 100,
|
||||||
|
settleCurrency: 'BNB'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,383 @@
|
||||||
|
# Wallet Service 架构设计文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Wallet Service 是 RWA (Real World Assets) 榴莲认种平台的核心钱包与账本服务,负责管理用户资产、处理充值/提现、记录交易流水、管理奖励结算等功能。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术选型 | 版本 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 运行时 | Node.js | 20.x |
|
||||||
|
| 框架 | NestJS | 10.x |
|
||||||
|
| 语言 | TypeScript | 5.x |
|
||||||
|
| ORM | Prisma | 5.x |
|
||||||
|
| 数据库 | PostgreSQL | 15.x |
|
||||||
|
| 认证 | JWT (passport-jwt) | - |
|
||||||
|
| 精度计算 | Decimal.js | 10.x |
|
||||||
|
| API文档 | Swagger | 7.x |
|
||||||
|
|
||||||
|
## 架构模式
|
||||||
|
|
||||||
|
本服务采用 **领域驱动设计 (DDD)** + **CQRS** 架构模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ WalletController │ │ LedgerController │ │ DepositController │ │
|
||||||
|
│ │ /wallet/* │ │ /wallet/ledger │ │ /wallet/deposit │ │
|
||||||
|
│ └────────┬─────────┘ └────────┬─────────┘ └──────────┬───────────┘ │
|
||||||
|
└───────────┼─────────────────────┼──────────────────────┼────────────────┘
|
||||||
|
│ │ │
|
||||||
|
┌───────────┼─────────────────────┼──────────────────────┼────────────────┐
|
||||||
|
│ │ Application Layer (CQRS) │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ WalletApplicationService │ │
|
||||||
|
│ │ ┌─────────────────────┐ ┌─────────────────────────────┐ │ │
|
||||||
|
│ │ │ Commands │ │ Queries │ │ │
|
||||||
|
│ │ │ - HandleDeposit │ │ - GetMyWallet │ │ │
|
||||||
|
│ │ │ - DeductForPlanting │ │ - GetMyLedger │ │ │
|
||||||
|
│ │ │ - AddRewards │ │ │ │ │
|
||||||
|
│ │ │ - ClaimRewards │ │ │ │ │
|
||||||
|
│ │ │ - SettleRewards │ │ │ │ │
|
||||||
|
│ │ └─────────────────────┘ └─────────────────────────────┘ │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────┼───────────────────────────────────────┐
|
||||||
|
│ Domain Layer │ │
|
||||||
|
│ ┌──────────────────────────────┼──────────────────────────────────┐ │
|
||||||
|
│ │ Aggregates │ │
|
||||||
|
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
|
||||||
|
│ │ │ WalletAccount │ │ DepositOrder │ │ SettlementOrder │ │ │
|
||||||
|
│ │ │ (聚合根) │ │ │ │ │ │ │
|
||||||
|
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ┌──────┴──────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ LedgerEntry │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Value Objects │ │
|
||||||
|
│ │ Money │ Balance │ Hashpower │ UserId │ WalletId │ Enums │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Domain Events │ │
|
||||||
|
│ │ DepositCompleted │ BalanceDeducted │ RewardAdded │ etc. │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────┼───────────────────────────────────────┐
|
||||||
|
│ Infrastructure Layer │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Repository Implementations │ │
|
||||||
|
│ │ WalletAccountRepository │ LedgerEntryRepository │ etc. │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Prisma Service │ │
|
||||||
|
│ │ (Database Connection Pool) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ Database │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # 表示层 (Presentation Layer)
|
||||||
|
│ ├── controllers/ # HTTP 控制器
|
||||||
|
│ │ ├── wallet.controller.ts # 钱包相关接口
|
||||||
|
│ │ ├── ledger.controller.ts # 流水查询接口
|
||||||
|
│ │ ├── deposit.controller.ts # 充值入账接口 (内部)
|
||||||
|
│ │ └── health.controller.ts # 健康检查
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ ├── request/ # 请求 DTO
|
||||||
|
│ │ └── response/ # 响应 DTO
|
||||||
|
│ └── api.module.ts
|
||||||
|
│
|
||||||
|
├── application/ # 应用层 (Application Layer)
|
||||||
|
│ ├── commands/ # 命令 (写操作)
|
||||||
|
│ │ ├── handle-deposit.command.ts
|
||||||
|
│ │ ├── deduct-for-planting.command.ts
|
||||||
|
│ │ ├── add-rewards.command.ts
|
||||||
|
│ │ ├── claim-rewards.command.ts
|
||||||
|
│ │ └── settle-rewards.command.ts
|
||||||
|
│ ├── queries/ # 查询 (读操作)
|
||||||
|
│ │ ├── get-my-wallet.query.ts
|
||||||
|
│ │ └── get-my-ledger.query.ts
|
||||||
|
│ └── services/
|
||||||
|
│ └── wallet-application.service.ts
|
||||||
|
│
|
||||||
|
├── domain/ # 领域层 (Domain Layer)
|
||||||
|
│ ├── aggregates/ # 聚合
|
||||||
|
│ │ ├── wallet-account.aggregate.ts
|
||||||
|
│ │ ├── ledger-entry.aggregate.ts
|
||||||
|
│ │ ├── deposit-order.aggregate.ts
|
||||||
|
│ │ └── settlement-order.aggregate.ts
|
||||||
|
│ ├── value-objects/ # 值对象
|
||||||
|
│ │ ├── money.vo.ts
|
||||||
|
│ │ ├── balance.vo.ts
|
||||||
|
│ │ ├── hashpower.vo.ts
|
||||||
|
│ │ ├── user-id.vo.ts
|
||||||
|
│ │ ├── wallet-id.vo.ts
|
||||||
|
│ │ └── [各种枚举].enum.ts
|
||||||
|
│ ├── events/ # 领域事件
|
||||||
|
│ │ ├── deposit-completed.event.ts
|
||||||
|
│ │ ├── balance-deducted.event.ts
|
||||||
|
│ │ └── [其他事件].ts
|
||||||
|
│ └── repositories/ # 仓储接口
|
||||||
|
│ ├── wallet-account.repository.interface.ts
|
||||||
|
│ └── [其他接口].ts
|
||||||
|
│
|
||||||
|
├── infrastructure/ # 基础设施层
|
||||||
|
│ ├── persistence/
|
||||||
|
│ │ ├── prisma/
|
||||||
|
│ │ │ └── prisma.service.ts
|
||||||
|
│ │ └── repositories/ # 仓储实现
|
||||||
|
│ │ ├── wallet-account.repository.impl.ts
|
||||||
|
│ │ └── [其他实现].ts
|
||||||
|
│ └── infrastructure.module.ts
|
||||||
|
│
|
||||||
|
├── shared/ # 共享模块
|
||||||
|
│ ├── decorators/ # 装饰器
|
||||||
|
│ ├── filters/ # 异常过滤器
|
||||||
|
│ ├── guards/ # 守卫
|
||||||
|
│ ├── interceptors/ # 拦截器
|
||||||
|
│ ├── strategies/ # 认证策略
|
||||||
|
│ └── exceptions/ # 自定义异常
|
||||||
|
│
|
||||||
|
├── app.module.ts # 根模块
|
||||||
|
└── main.ts # 入口文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心领域模型
|
||||||
|
|
||||||
|
### 1. WalletAccount (钱包账户聚合)
|
||||||
|
|
||||||
|
钱包账户是核心聚合根,管理用户的所有资产状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
WalletAccount {
|
||||||
|
// 标识
|
||||||
|
walletId: WalletId
|
||||||
|
userId: UserId
|
||||||
|
|
||||||
|
// 多币种余额
|
||||||
|
balances: {
|
||||||
|
usdt: Balance { available, frozen }
|
||||||
|
dst: Balance { available, frozen }
|
||||||
|
bnb: Balance { available, frozen }
|
||||||
|
og: Balance { available, frozen }
|
||||||
|
rwad: Balance { available, frozen }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 算力
|
||||||
|
hashpower: Hashpower
|
||||||
|
|
||||||
|
// 奖励状态机
|
||||||
|
rewards: {
|
||||||
|
pending -> 待领取 (24小时内必须领取)
|
||||||
|
settleable -> 可结算 (可兑换为其他币种)
|
||||||
|
settled -> 已结算累计
|
||||||
|
expired -> 已过期累计
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
status: ACTIVE | FROZEN
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 奖励状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
│ addReward() │
|
||||||
|
└────────┬─────────┘
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
┌─────────│ PENDING │─────────┐
|
||||||
|
│ │ (待领取, 有过期) │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
expire() │ claim() │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ │
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ EXPIRED │ │ SETTLEABLE │ │
|
||||||
|
│ (已过期累计) │ │ (可结算) │ │
|
||||||
|
└──────────────────┘ └────────┬─────────┘ │
|
||||||
|
│ │
|
||||||
|
settle() │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ SETTLED │ │
|
||||||
|
│ (已结算累计) │ │
|
||||||
|
└──────────────────┘ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. LedgerEntry (账本流水)
|
||||||
|
|
||||||
|
采用 **Append-Only** 模式,不可修改,确保审计追溯:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
LedgerEntry {
|
||||||
|
entryType: LedgerEntryType // 流水类型
|
||||||
|
amount: Money // 金额 (正入账/负支出)
|
||||||
|
assetType: AssetType // 资产类型
|
||||||
|
balanceAfter: Money? // 操作后余额快照
|
||||||
|
refOrderId?: string // 关联订单号
|
||||||
|
refTxHash?: string // 关联交易哈希
|
||||||
|
memo?: string // 备注
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 流水类型枚举
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum LedgerEntryType {
|
||||||
|
// 充值相关
|
||||||
|
DEPOSIT_KAVA = 'DEPOSIT_KAVA' // KAVA链充值
|
||||||
|
DEPOSIT_BSC = 'DEPOSIT_BSC' // BSC链充值
|
||||||
|
|
||||||
|
// 认种相关
|
||||||
|
PLANT_PAYMENT = 'PLANT_PAYMENT' // 认种支付
|
||||||
|
|
||||||
|
// 奖励相关
|
||||||
|
REWARD_PENDING = 'REWARD_PENDING' // 奖励待领取
|
||||||
|
REWARD_TO_SETTLEABLE = 'REWARD_TO_SETTLEABLE' // 奖励转可结算
|
||||||
|
REWARD_SETTLED = 'REWARD_SETTLED' // 奖励已结算
|
||||||
|
REWARD_EXPIRED = 'REWARD_EXPIRED' // 奖励已过期
|
||||||
|
|
||||||
|
// 其他
|
||||||
|
WITHDRAWAL = 'WITHDRAWAL' // 提现
|
||||||
|
ADMIN_ADJUST = 'ADMIN_ADJUST' // 管理员调整
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库设计
|
||||||
|
|
||||||
|
### ER 图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ wallet_accounts │ │ wallet_ledger_ │
|
||||||
|
│ │ │ entries │
|
||||||
|
├─────────────────────┤ ├─────────────────────┤
|
||||||
|
│ wallet_id (PK) │───┐ │ entry_id (PK) │
|
||||||
|
│ user_id (UK) │ │ │ user_id (FK) │
|
||||||
|
│ usdt_available │ └──▶│ entry_type │
|
||||||
|
│ usdt_frozen │ │ amount │
|
||||||
|
│ dst_available │ │ asset_type │
|
||||||
|
│ dst_frozen │ │ balance_after │
|
||||||
|
│ bnb_available │ │ ref_order_id │
|
||||||
|
│ bnb_frozen │ │ ref_tx_hash │
|
||||||
|
│ og_available │ │ memo │
|
||||||
|
│ og_frozen │ │ payload_json │
|
||||||
|
│ rwad_available │ │ created_at │
|
||||||
|
│ rwad_frozen │ └─────────────────────┘
|
||||||
|
│ hashpower │
|
||||||
|
│ pending_usdt │ ┌─────────────────────┐
|
||||||
|
│ pending_hashpower │ │ deposit_orders │
|
||||||
|
│ pending_expire_at │ ├─────────────────────┤
|
||||||
|
│ settleable_usdt │ │ order_id (PK) │
|
||||||
|
│ settleable_hashpower│ │ user_id (FK) │
|
||||||
|
│ settled_total_usdt │ │ chain_type │
|
||||||
|
│ settled_total_hp │ │ amount │
|
||||||
|
│ expired_total_usdt │ │ tx_hash (UK) │
|
||||||
|
│ expired_total_hp │ │ status │
|
||||||
|
│ status │ │ confirmed_at │
|
||||||
|
│ created_at │ │ created_at │
|
||||||
|
│ updated_at │ └─────────────────────┘
|
||||||
|
└─────────────────────┘
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ settlement_orders │
|
||||||
|
├─────────────────────┤
|
||||||
|
│ order_id (PK) │
|
||||||
|
│ user_id (FK) │
|
||||||
|
│ usdt_amount │
|
||||||
|
│ settle_currency │
|
||||||
|
│ swap_tx_hash │
|
||||||
|
│ received_amount │
|
||||||
|
│ status │
|
||||||
|
│ settled_at │
|
||||||
|
│ created_at │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 精度处理
|
||||||
|
|
||||||
|
所有金额字段使用 `Decimal(20, 8)` 存储:
|
||||||
|
- 20位总精度,8位小数
|
||||||
|
- 支持最大 999,999,999,999.99999999
|
||||||
|
- 使用 `decimal.js` 库进行计算,避免浮点数精度问题
|
||||||
|
|
||||||
|
## 安全设计
|
||||||
|
|
||||||
|
### 认证与授权
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────┐
|
||||||
|
│ JWT Token │
|
||||||
|
│ (Bearer) │
|
||||||
|
└───────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ JwtAuthGuard │
|
||||||
|
│ - 验证 Token 签名 │
|
||||||
|
│ - 检查 Token 过期 │
|
||||||
|
│ - 提取 userId 和 seq │
|
||||||
|
└───────────────────────────┬─────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ @Public() 装饰器 │
|
||||||
|
│ - 标记为公开接口,跳过认证 │
|
||||||
|
│ - 用于内部服务调用 (如 /deposit) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 内部服务调用
|
||||||
|
|
||||||
|
充值入账接口 (`POST /wallet/deposit`) 使用 `@Public()` 装饰器,跳过 JWT 认证,仅供内部链监控服务调用。生产环境应通过网络隔离保护。
|
||||||
|
|
||||||
|
## 扩展点
|
||||||
|
|
||||||
|
### 1. 领域事件
|
||||||
|
|
||||||
|
所有聚合操作都会产生领域事件,可用于:
|
||||||
|
- 异步通知
|
||||||
|
- 事件溯源
|
||||||
|
- 跨服务通信
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
wallet.deposit(amount, chainType, txHash);
|
||||||
|
// 产生 DepositCompletedEvent
|
||||||
|
|
||||||
|
wallet.deduct(amount, reason);
|
||||||
|
// 产生 BalanceDeductedEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 仓储接口
|
||||||
|
|
||||||
|
基础设施层实现可替换:
|
||||||
|
- 当前:Prisma + PostgreSQL
|
||||||
|
- 可扩展:Redis 缓存、MongoDB 等
|
||||||
|
|
||||||
|
### 3. 消息队列集成
|
||||||
|
|
||||||
|
预留事件发布接口,可集成:
|
||||||
|
- RabbitMQ
|
||||||
|
- Kafka
|
||||||
|
- Redis Pub/Sub
|
||||||
|
|
@ -0,0 +1,787 @@
|
||||||
|
# Wallet Service 部署文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档描述 Wallet Service 的部署架构、配置方式、Docker 容器化以及生产环境部署流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Load Balancer │
|
||||||
|
│ (Nginx/ALB) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────┼────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Wallet Service │ │ Wallet Service │ │ Wallet Service │
|
||||||
|
│ Instance 1 │ │ Instance 2 │ │ Instance N │
|
||||||
|
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||||
|
│ │ │
|
||||||
|
└──────────────────────┼──────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ (Primary) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ (Replica) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
| 组件 | 最低要求 | 推荐配置 |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| CPU | 2 核 | 4+ 核 |
|
||||||
|
| 内存 | 2 GB | 4+ GB |
|
||||||
|
| 磁盘 | 20 GB SSD | 50+ GB SSD |
|
||||||
|
| Node.js | 20.x | 20.x LTS |
|
||||||
|
| PostgreSQL | 15.x | 15.x |
|
||||||
|
|
||||||
|
### 依赖服务
|
||||||
|
|
||||||
|
- PostgreSQL 15.x 数据库
|
||||||
|
- Docker (可选,用于容器化部署)
|
||||||
|
- Kubernetes (可选,用于编排)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
### 必需变量
|
||||||
|
|
||||||
|
| 变量 | 描述 | 示例 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| `DATABASE_URL` | PostgreSQL 连接字符串 | `postgresql://user:pass@host:5432/db` |
|
||||||
|
| `JWT_SECRET` | JWT 签名密钥 | `your-secret-key-here` |
|
||||||
|
| `NODE_ENV` | 环境标识 | `production` |
|
||||||
|
| `PORT` | 服务端口 | `3000` |
|
||||||
|
|
||||||
|
### 可选变量
|
||||||
|
|
||||||
|
| 变量 | 描述 | 默认值 |
|
||||||
|
|-----|------|-------|
|
||||||
|
| `JWT_EXPIRES_IN` | JWT 过期时间 | `24h` |
|
||||||
|
| `LOG_LEVEL` | 日志级别 | `info` |
|
||||||
|
| `CORS_ORIGIN` | CORS 允许源 | `*` |
|
||||||
|
|
||||||
|
### 环境配置文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.production
|
||||||
|
DATABASE_URL="postgresql://wallet:strong_password@db.example.com:5432/wallet_prod?schema=public&connection_limit=10"
|
||||||
|
JWT_SECRET="your-production-secret-key-min-32-chars"
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
# 构建阶段
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm ci --only=production=false
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 生成 Prisma Client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 清理开发依赖
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
# 生产阶段
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 dumb-init 用于信号处理
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
|
# 创建非 root 用户
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nestjs -u 1001
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
|
||||||
|
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nestjs:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
|
||||||
|
|
||||||
|
# 切换到非 root 用户
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/v1/health || exit 1
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### .dockerignore
|
||||||
|
|
||||||
|
```
|
||||||
|
# .dockerignore
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
test
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建和运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t wallet-service:latest .
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -d \
|
||||||
|
--name wallet-service \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e DATABASE_URL="postgresql://wallet:password@host:5432/wallet" \
|
||||||
|
-e JWT_SECRET="your-secret-key" \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
wallet-service:latest
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker logs -f wallet-service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose 部署
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
wallet-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@postgres:5432/wallet?schema=public
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-development-secret-change-in-production}
|
||||||
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: wallet
|
||||||
|
POSTGRES_PASSWORD: wallet123
|
||||||
|
POSTGRES_DB: wallet
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U wallet -d wallet"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### docker-compose.prod.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.prod.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
wallet-service:
|
||||||
|
image: wallet-service:${VERSION:-latest}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
NODE_ENV: production
|
||||||
|
LOG_LEVEL: info
|
||||||
|
deploy:
|
||||||
|
replicas: 3
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
delay: 10s
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发环境
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 生产环境
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# 扩展副本
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --scale wallet-service=3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes 部署
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/deployment.yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: wallet-service
|
||||||
|
labels:
|
||||||
|
app: wallet-service
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: wallet-service
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: wallet-service
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: wallet-service
|
||||||
|
image: wallet-service:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wallet-secrets
|
||||||
|
key: database-url
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wallet-secrets
|
||||||
|
key: jwt-secret
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "250m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/v1/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/v1/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/service.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: wallet-service
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3000
|
||||||
|
selector:
|
||||||
|
app: wallet-service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secret
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/secret.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: wallet-secrets
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
database-url: "postgresql://wallet:password@postgres:5432/wallet"
|
||||||
|
jwt-secret: "your-production-secret-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/ingress.yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: wallet-ingress
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: wallet.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: wallet-service
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建 Secret
|
||||||
|
kubectl apply -f k8s/secret.yaml
|
||||||
|
|
||||||
|
# 部署服务
|
||||||
|
kubectl apply -f k8s/deployment.yaml
|
||||||
|
kubectl apply -f k8s/service.yaml
|
||||||
|
kubectl apply -f k8s/ingress.yaml
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
kubectl get pods -l app=wallet-service
|
||||||
|
kubectl get svc wallet-service
|
||||||
|
|
||||||
|
# 滚动更新
|
||||||
|
kubectl set image deployment/wallet-service wallet-service=wallet-service:v2.0.0
|
||||||
|
|
||||||
|
# 回滚
|
||||||
|
kubectl rollout undo deployment/wallet-service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
### Prisma 迁移
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发环境 - 创建迁移
|
||||||
|
npx prisma migrate dev --name add_new_field
|
||||||
|
|
||||||
|
# 生产环境 - 应用迁移
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# 查看迁移状态
|
||||||
|
npx prisma migrate status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# scripts/migrate.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
|
||||||
|
# 等待数据库就绪
|
||||||
|
until pg_isready -h $DB_HOST -p $DB_PORT -U $DB_USER; do
|
||||||
|
echo "Waiting for database..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# 运行迁移
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
echo "Migrations completed successfully!"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD 流水线
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/deploy.yml
|
||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: wallet
|
||||||
|
POSTGRES_PASSWORD: wallet123
|
||||||
|
POSTGRES_DB: wallet_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate Prisma Client
|
||||||
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: npx prisma db push
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test
|
||||||
|
JWT_SECRET: test-secret
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test
|
||||||
|
JWT_SECRET: test-secret
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to production
|
||||||
|
run: |
|
||||||
|
# 触发部署 (例如 ArgoCD, Kubernetes, 或 SSH)
|
||||||
|
echo "Deploying to production..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 监控和日志
|
||||||
|
|
||||||
|
### 健康检查端点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 健康检查
|
||||||
|
curl http://localhost:3000/api/v1/health
|
||||||
|
|
||||||
|
# 响应示例
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "wallet-service",
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/main.ts
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: process.env.NODE_ENV === 'production'
|
||||||
|
? ['error', 'warn', 'log']
|
||||||
|
: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||||
|
});
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志格式 (生产环境)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z",
|
||||||
|
"level": "info",
|
||||||
|
"context": "WalletController",
|
||||||
|
"message": "Deposit processed",
|
||||||
|
"userId": "12345",
|
||||||
|
"amount": 100,
|
||||||
|
"requestId": "abc-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全配置
|
||||||
|
|
||||||
|
### Nginx 反向代理
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# nginx.conf
|
||||||
|
upstream wallet_service {
|
||||||
|
server wallet-service-1:3000;
|
||||||
|
server wallet-service-2:3000;
|
||||||
|
server wallet-service-3:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name wallet.example.com;
|
||||||
|
|
||||||
|
# 重定向到 HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name wallet.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
|
||||||
|
# 速率限制
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://wallet_service;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量安全
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 不要在版本控制中提交敏感信息
|
||||||
|
# 使用环境变量或密钥管理服务
|
||||||
|
|
||||||
|
# AWS Secrets Manager
|
||||||
|
aws secretsmanager get-secret-value --secret-id wallet/production
|
||||||
|
|
||||||
|
# HashiCorp Vault
|
||||||
|
vault kv get secret/wallet/production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
#### 1. 数据库连接失败
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查连接
|
||||||
|
psql $DATABASE_URL -c "SELECT 1"
|
||||||
|
|
||||||
|
# 检查网络
|
||||||
|
nc -zv db.example.com 5432
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 容器无法启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看日志
|
||||||
|
docker logs wallet-service
|
||||||
|
|
||||||
|
# 进入容器调试
|
||||||
|
docker exec -it wallet-service sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 迁移失败
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看迁移状态
|
||||||
|
npx prisma migrate status
|
||||||
|
|
||||||
|
# 重置数据库 (开发环境)
|
||||||
|
npx prisma migrate reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回滚流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker 回滚
|
||||||
|
docker stop wallet-service
|
||||||
|
docker run -d --name wallet-service wallet-service:previous-tag
|
||||||
|
|
||||||
|
# Kubernetes 回滚
|
||||||
|
kubectl rollout undo deployment/wallet-service
|
||||||
|
|
||||||
|
# 数据库回滚 (手动)
|
||||||
|
# 需要提前备份,Prisma 不支持自动回滚
|
||||||
|
pg_restore -d wallet wallet_backup.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署检查清单
|
||||||
|
|
||||||
|
### 部署前
|
||||||
|
|
||||||
|
- [ ] 所有测试通过
|
||||||
|
- [ ] 代码审查完成
|
||||||
|
- [ ] 环境变量配置正确
|
||||||
|
- [ ] 数据库备份完成
|
||||||
|
- [ ] 迁移脚本测试通过
|
||||||
|
|
||||||
|
### 部署中
|
||||||
|
|
||||||
|
- [ ] 监控指标正常
|
||||||
|
- [ ] 健康检查通过
|
||||||
|
- [ ] 日志无错误
|
||||||
|
- [ ] API 响应正常
|
||||||
|
|
||||||
|
### 部署后
|
||||||
|
|
||||||
|
- [ ] 功能验证通过
|
||||||
|
- [ ] 性能测试通过
|
||||||
|
- [ ] 安全扫描通过
|
||||||
|
- [ ] 文档更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 联系方式
|
||||||
|
|
||||||
|
如遇部署问题,请联系:
|
||||||
|
|
||||||
|
- 运维团队: devops@example.com
|
||||||
|
- 开发团队: dev@example.com
|
||||||
|
- 紧急联系: oncall@example.com
|
||||||
|
|
||||||
|
|
@ -0,0 +1,478 @@
|
||||||
|
# Wallet Service 开发指南
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 20.x
|
||||||
|
- npm 10.x
|
||||||
|
- PostgreSQL 15.x
|
||||||
|
- Docker (用于本地数据库)
|
||||||
|
- WSL2 (Windows 开发者)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository_url>
|
||||||
|
cd wallet-service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置环境变量
|
||||||
|
|
||||||
|
创建 `.env.development` 文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库连接
|
||||||
|
DATABASE_URL="postgresql://wallet:wallet123@localhost:5432/wallet_dev?schema=public"
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
JWT_SECRET="your-development-jwt-secret"
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 启动数据库
|
||||||
|
|
||||||
|
使用 Docker 启动 PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name wallet-postgres-dev \
|
||||||
|
-e POSTGRES_USER=wallet \
|
||||||
|
-e POSTGRES_PASSWORD=wallet123 \
|
||||||
|
-e POSTGRES_DB=wallet_dev \
|
||||||
|
-p 5432:5432 \
|
||||||
|
postgres:15-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成 Prisma Client
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# 推送数据库结构
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# (可选) 打开 Prisma Studio 查看数据
|
||||||
|
npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
服务将在 `http://localhost:3000` 启动。
|
||||||
|
|
||||||
|
Swagger 文档: `http://localhost:3000/api-docs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目脚本
|
||||||
|
|
||||||
|
| 命令 | 描述 |
|
||||||
|
|-----|------|
|
||||||
|
| `npm run start` | 启动生产模式 |
|
||||||
|
| `npm run start:dev` | 启动开发模式 (热重载) |
|
||||||
|
| `npm run start:debug` | 启动调试模式 |
|
||||||
|
| `npm run build` | 构建项目 |
|
||||||
|
| `npm test` | 运行单元测试 |
|
||||||
|
| `npm run test:watch` | 监听模式运行测试 |
|
||||||
|
| `npm run test:cov` | 运行测试并生成覆盖率报告 |
|
||||||
|
| `npm run test:e2e` | 运行 E2E 测试 |
|
||||||
|
| `npm run lint` | 代码检查 |
|
||||||
|
| `npm run format` | 代码格式化 |
|
||||||
|
| `npm run prisma:generate` | 生成 Prisma Client |
|
||||||
|
| `npm run prisma:migrate` | 运行数据库迁移 |
|
||||||
|
| `npm run prisma:studio` | 启动 Prisma Studio |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码结构
|
||||||
|
|
||||||
|
### 添加新功能的标准流程
|
||||||
|
|
||||||
|
#### 1. 定义值对象 (如需要)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/value-objects/new-value.vo.ts
|
||||||
|
export class NewValue {
|
||||||
|
private readonly _value: number;
|
||||||
|
|
||||||
|
private constructor(value: number) {
|
||||||
|
this._value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: number): NewValue {
|
||||||
|
// 验证逻辑
|
||||||
|
if (value < 0) {
|
||||||
|
throw new DomainError('Value cannot be negative');
|
||||||
|
}
|
||||||
|
return new NewValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): number {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 定义领域事件 (如需要)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/events/new-action.event.ts
|
||||||
|
export class NewActionEvent extends DomainEvent {
|
||||||
|
constructor(public readonly payload: {
|
||||||
|
userId: string;
|
||||||
|
amount: string;
|
||||||
|
}) {
|
||||||
|
super('NewActionEvent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 在聚合中添加业务方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/aggregates/wallet-account.aggregate.ts
|
||||||
|
newAction(amount: Money): void {
|
||||||
|
this.ensureActive();
|
||||||
|
// 业务逻辑
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(new NewActionEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
amount: amount.value.toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 定义命令/查询
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/application/commands/new-action.command.ts
|
||||||
|
export class NewActionCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly amount: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 在应用服务中实现
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/application/services/wallet-application.service.ts
|
||||||
|
async newAction(command: NewActionCommand): Promise<void> {
|
||||||
|
const wallet = await this.walletRepo.findByUserId(BigInt(command.userId));
|
||||||
|
if (!wallet) {
|
||||||
|
throw new WalletNotFoundError(`userId: ${command.userId}`);
|
||||||
|
}
|
||||||
|
wallet.newAction(Money.USDT(command.amount));
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
// 记录流水等...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. 添加 DTO
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/api/dto/request/new-action.dto.ts
|
||||||
|
export class NewActionDTO {
|
||||||
|
@ApiProperty({ description: '金额' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. 添加控制器端点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/api/controllers/wallet.controller.ts
|
||||||
|
@Post('new-action')
|
||||||
|
@ApiOperation({ summary: '新操作' })
|
||||||
|
async newAction(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@Body() dto: NewActionDTO,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
await this.walletService.newAction(
|
||||||
|
new NewActionCommand(user.userId, dto.amount)
|
||||||
|
);
|
||||||
|
return { message: 'Success' };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 值对象规范
|
||||||
|
|
||||||
|
### Money (金额)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 创建
|
||||||
|
const usdt = Money.USDT(100); // 100 USDT
|
||||||
|
const bnb = Money.BNB(0.5); // 0.5 BNB
|
||||||
|
const custom = Money.create(50, 'DST'); // 50 DST
|
||||||
|
|
||||||
|
// 运算
|
||||||
|
const sum = usdt.add(Money.USDT(50)); // 150 USDT
|
||||||
|
const diff = usdt.subtract(Money.USDT(30)); // 70 USDT
|
||||||
|
|
||||||
|
// 比较
|
||||||
|
usdt.equals(Money.USDT(100)); // true
|
||||||
|
usdt.lessThan(Money.USDT(200)); // true
|
||||||
|
usdt.isZero(); // false
|
||||||
|
|
||||||
|
// 获取值
|
||||||
|
usdt.value; // 100 (number)
|
||||||
|
usdt.currency; // 'USDT'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Balance (余额)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 创建
|
||||||
|
const balance = Balance.create(
|
||||||
|
Money.USDT(1000), // available
|
||||||
|
Money.USDT(100) // frozen
|
||||||
|
);
|
||||||
|
|
||||||
|
// 操作
|
||||||
|
const afterDeposit = balance.add(Money.USDT(200)); // available + 200
|
||||||
|
const afterDeduct = balance.deduct(Money.USDT(50)); // available - 50
|
||||||
|
const afterFreeze = balance.freeze(Money.USDT(100)); // available -> frozen
|
||||||
|
const afterUnfreeze = balance.unfreeze(Money.USDT(50)); // frozen -> available
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hashpower (算力)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const hp = Hashpower.create(500);
|
||||||
|
const sum = hp.add(Hashpower.create(100)); // 600
|
||||||
|
const value = hp.value; // 500
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 仓储模式
|
||||||
|
|
||||||
|
### 接口定义
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/repositories/wallet-account.repository.interface.ts
|
||||||
|
export interface IWalletAccountRepository {
|
||||||
|
findByUserId(userId: bigint): Promise<WalletAccount | null>;
|
||||||
|
getOrCreate(userId: bigint): Promise<WalletAccount>;
|
||||||
|
save(wallet: WalletAccount): Promise<WalletAccount>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 实现
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/infrastructure/persistence/repositories/wallet-account.repository.impl.ts
|
||||||
|
@Injectable()
|
||||||
|
export class WalletAccountRepositoryImpl implements IWalletAccountRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint): Promise<WalletAccount | null> {
|
||||||
|
const record = await this.prisma.walletAccount.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
if (!record) return null;
|
||||||
|
return WalletAccount.reconstruct(/* ... */);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖注入
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/infrastructure/infrastructure.module.ts
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: WALLET_ACCOUNT_REPOSITORY,
|
||||||
|
useClass: WalletAccountRepositoryImpl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [WALLET_ACCOUNT_REPOSITORY],
|
||||||
|
})
|
||||||
|
export class InfrastructureModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 异常处理
|
||||||
|
|
||||||
|
### 领域异常
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/shared/exceptions/domain.exception.ts
|
||||||
|
export class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsufficientBalanceError extends DomainError {
|
||||||
|
public readonly code = 'INSUFFICIENT_BALANCE';
|
||||||
|
constructor(assetType: string, required: string, available: string) {
|
||||||
|
super(`Insufficient ${assetType} balance: required ${required}, available ${available}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 异常过滤器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/shared/filters/domain-exception.filter.ts
|
||||||
|
@Catch(DomainError)
|
||||||
|
export class DomainExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: DomainError, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const status = this.getHttpStatus(exception);
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
success: false,
|
||||||
|
code: (exception as any).code || 'DOMAIN_ERROR',
|
||||||
|
message: exception.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### VS Code 调试配置
|
||||||
|
|
||||||
|
`.vscode/launch.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug NestJS",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "start:debug"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"restart": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志调试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在代码中添加
|
||||||
|
console.log('DEBUG:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// 使用 NestJS Logger
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
const logger = new Logger('WalletService');
|
||||||
|
logger.log('Processing deposit...');
|
||||||
|
logger.debug('Wallet state:', wallet);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看数据库
|
||||||
|
npx prisma studio
|
||||||
|
|
||||||
|
# 查看 SQL 日志 (在 prisma.service.ts 中)
|
||||||
|
this.$on('query', (e) => {
|
||||||
|
console.log('Query:', e.query);
|
||||||
|
console.log('Params:', e.params);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git 工作流
|
||||||
|
|
||||||
|
### 分支命名
|
||||||
|
|
||||||
|
- `feature/xxx` - 新功能
|
||||||
|
- `fix/xxx` - Bug 修复
|
||||||
|
- `refactor/xxx` - 重构
|
||||||
|
- `docs/xxx` - 文档
|
||||||
|
|
||||||
|
### 提交信息格式
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
类型:
|
||||||
|
- `feat` - 新功能
|
||||||
|
- `fix` - Bug 修复
|
||||||
|
- `docs` - 文档
|
||||||
|
- `style` - 格式
|
||||||
|
- `refactor` - 重构
|
||||||
|
- `test` - 测试
|
||||||
|
- `chore` - 构建/工具
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```
|
||||||
|
feat(wallet): add settle rewards feature
|
||||||
|
|
||||||
|
- Add SettleRewardsCommand
|
||||||
|
- Implement settlement logic in WalletAccount
|
||||||
|
- Add POST /settle endpoint
|
||||||
|
|
||||||
|
Closes #123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. Prisma Client 未生成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 数据库连接失败
|
||||||
|
|
||||||
|
检查 `.env` 文件中的 `DATABASE_URL` 是否正确。
|
||||||
|
|
||||||
|
### 3. WSL2 性能问题
|
||||||
|
|
||||||
|
参考 [E2E-TESTING-WSL2.md](./E2E-TESTING-WSL2.md) 将项目放在 WSL2 原生文件系统中。
|
||||||
|
|
||||||
|
### 4. 端口被占用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
netstat -ano | findstr :3000
|
||||||
|
taskkill /PID <pid> /F
|
||||||
|
|
||||||
|
# Linux/Mac
|
||||||
|
lsof -i :3000
|
||||||
|
kill -9 <pid>
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,556 @@
|
||||||
|
# E2E Testing with WSL2 and Docker PostgreSQL
|
||||||
|
|
||||||
|
本文档记录了在 Windows + WSL2 环境下使用真实 PostgreSQL 数据库进行 E2E 测试的经验和最佳实践。
|
||||||
|
|
||||||
|
## 环境架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Windows Host │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐│
|
||||||
|
│ │ WSL2 ││
|
||||||
|
│ │ ┌─────────────────────────────────────────────────┐││
|
||||||
|
│ │ │ Docker Engine │││
|
||||||
|
│ │ │ ┌───────────────────────────────────────────┐ │││
|
||||||
|
│ │ │ │ PostgreSQL Container (172.17.0.x) │ │││
|
||||||
|
│ │ │ │ Port: 5432 │ │││
|
||||||
|
│ │ │ └───────────────────────────────────────────┘ │││
|
||||||
|
│ │ └─────────────────────────────────────────────────┘││
|
||||||
|
│ │ ││
|
||||||
|
│ │ Node.js Application (测试运行环境) ││
|
||||||
|
│ │ 通过 Docker 网络 (172.17.0.x) 连接 PostgreSQL ││
|
||||||
|
│ └─────────────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重要发现
|
||||||
|
|
||||||
|
### 1. 网络连接问题
|
||||||
|
|
||||||
|
**问题**: 在 WSL2 内运行的 Node.js 应用程序无法通过 `localhost:5432` 连接到 Docker 容器内的 PostgreSQL。
|
||||||
|
|
||||||
|
**原因**: WSL2 的 localhost 和 Docker 容器的网络是隔离的。即使 Docker 端口映射到 `0.0.0.0:5432`,WSL2 内的应用仍然无法通过 localhost 访问。
|
||||||
|
|
||||||
|
**解决方案**: 使用 Docker 容器的实际 IP 地址(通常是 172.17.0.x)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取容器 IP
|
||||||
|
docker inspect <container_name> --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
|
||||||
|
|
||||||
|
# 示例输出: 172.17.0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 数据库连接配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 错误配置 (在 WSL2 中无法工作)
|
||||||
|
DATABASE_URL="postgresql://user:pass@localhost:5432/dbname"
|
||||||
|
|
||||||
|
# 正确配置 (使用 Docker 容器 IP)
|
||||||
|
DATABASE_URL="postgresql://user:pass@172.17.0.2:5432/dbname"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Windows 和 WSL2 网络隔离 (重要发现!)
|
||||||
|
|
||||||
|
**关键发现**: Windows 和 WSL2 之间存在网络隔离,这是在 E2E 测试中遇到的核心挑战。
|
||||||
|
|
||||||
|
#### 三种运行环境的网络访问方式
|
||||||
|
|
||||||
|
| 测试运行位置 | 数据库地址 | 是否可用 |
|
||||||
|
|-------------|-----------|---------|
|
||||||
|
| WSL2 内部 | `172.17.0.x` (Docker 容器 IP) | ✅ 可用 |
|
||||||
|
| WSL2 内部 | `localhost:5432` | ❌ 不可用 |
|
||||||
|
| Windows 原生 | `localhost:5432` | ❌ 不可用 (无 Docker Desktop) |
|
||||||
|
| Windows 原生 | WSL2 IP (172.24.x.x) | ❌ 不可用 (网络隔离) |
|
||||||
|
| CI/CD (Linux) | `localhost:5432` | ✅ 可用 |
|
||||||
|
|
||||||
|
#### 详细说明
|
||||||
|
|
||||||
|
**从 Windows 访问 WSL2 中的 Docker 容器**:
|
||||||
|
- ❌ Windows **无法**通过 `localhost:5432` 访问 WSL2 中的 Docker 容器
|
||||||
|
- ❌ Windows **无法**通过 WSL2 IP (如 172.24.157.5:5432) 访问
|
||||||
|
- 原因: WSL2 使用 NAT 网络模式,网络与 Windows 隔离
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 测试 Windows 到 WSL2 的网络连接
|
||||||
|
Test-NetConnection -ComputerName 172.24.157.5 -Port 5432
|
||||||
|
# 输出: TCP connect to (172.24.157.5 : 5432) failed
|
||||||
|
# 输出: Ping to 172.24.157.5 failed with status: DestinationNetworkUnreachable
|
||||||
|
```
|
||||||
|
|
||||||
|
**从 WSL2 访问 Docker 容器**:
|
||||||
|
- ❌ 不能使用 localhost
|
||||||
|
- ✅ 必须使用容器的实际 IP 地址 (172.17.0.x)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取 WSL2 的 IP 地址
|
||||||
|
hostname -I
|
||||||
|
# 输出: 172.24.157.5 172.19.0.1 172.17.0.1 172.18.0.1
|
||||||
|
|
||||||
|
# 获取 Docker 容器的 IP 地址
|
||||||
|
docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
|
||||||
|
# 输出: 172.17.0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 解决方案对比
|
||||||
|
|
||||||
|
| 方案 | 优点 | 缺点 | 推荐 |
|
||||||
|
|-----|------|------|-----|
|
||||||
|
| 在 WSL2 原生文件系统运行测试 | 性能最好,网络直连 | 需要复制代码 | ⭐⭐⭐ |
|
||||||
|
| 使用 Docker Desktop (Windows) | localhost 可用 | 需要额外安装 | ⭐⭐ |
|
||||||
|
| CI/CD 环境 (GitHub Actions) | 环境一致,网络简单 | 本地无法测试 | ⭐⭐⭐ |
|
||||||
|
| Mock 测试 | 无需真实数据库 | 测试不全面 | ⭐ |
|
||||||
|
|
||||||
|
## Docker 设置步骤
|
||||||
|
|
||||||
|
### 1. 创建 PostgreSQL 容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 WSL2 中运行
|
||||||
|
docker run -d \
|
||||||
|
--restart=always \
|
||||||
|
--name wallet-postgres-test \
|
||||||
|
-e POSTGRES_USER=wallet \
|
||||||
|
-e POSTGRES_PASSWORD=wallet123 \
|
||||||
|
-e POSTGRES_DB=wallet_test \
|
||||||
|
-p 5432:5432 \
|
||||||
|
postgres:15-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 验证容器运行状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查容器状态
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# 检查 PostgreSQL 是否就绪
|
||||||
|
docker exec wallet-postgres-test pg_isready -U wallet
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 获取容器 IP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 推送 Prisma Schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 WSL2 中运行
|
||||||
|
cd /mnt/c/Users/<username>/path/to/project
|
||||||
|
export DATABASE_URL='postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public'
|
||||||
|
npx prisma db push --force-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## E2E 测试运行
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
根据不同的运行环境,需要配置不同的 `DATABASE_URL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.test 配置 (根据运行环境选择)
|
||||||
|
|
||||||
|
# 1. 在 WSL2 中运行测试 - 使用 Docker 容器 IP
|
||||||
|
DATABASE_URL="postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public"
|
||||||
|
|
||||||
|
# 2. 在 CI/CD (GitHub Actions, Linux) 中运行 - 使用 localhost
|
||||||
|
DATABASE_URL="postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public"
|
||||||
|
|
||||||
|
# 3. 通用配置
|
||||||
|
JWT_SECRET="test-jwt-secret-key-for-e2e-testing"
|
||||||
|
NODE_ENV=test
|
||||||
|
PORT=3001
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: NestJS ConfigModule 会根据 `NODE_ENV` 加载对应的 `.env.{NODE_ENV}` 文件,所以设置 `NODE_ENV=test` 会自动加载 `.env.test`。
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 WSL2 中运行 (确保 .env.test 使用容器 IP)
|
||||||
|
npm run test:e2e
|
||||||
|
# 或
|
||||||
|
npx jest --config ./test/jest-e2e.json --runInBand --forceExit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
|
||||||
|
### 问题 1: 无法连接数据库
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: P1001: Can't reach database server at `localhost:5432`
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 确认 Docker 容器正在运行: `docker ps`
|
||||||
|
2. 获取容器 IP: `docker inspect <container> --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"`
|
||||||
|
3. 更新 DATABASE_URL 使用容器 IP
|
||||||
|
|
||||||
|
### 问题 2: 容器 IP 地址变化
|
||||||
|
|
||||||
|
每次重启 Docker 容器后,IP 地址可能会改变。
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 使用 Docker network 创建固定网络
|
||||||
|
- 或在测试脚本中动态获取 IP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONTAINER_IP=$(docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}")
|
||||||
|
export DATABASE_URL="postgresql://wallet:wallet123@$CONTAINER_IP:5432/wallet_test?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 3: Jest 测试挂起
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 数据库连接超时
|
||||||
|
- 未正确关闭数据库连接
|
||||||
|
- 异步操作未完成
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 添加 `--forceExit` 参数
|
||||||
|
- 在 `afterAll` 中确保调用 `app.close()`
|
||||||
|
- 增加 Jest 超时时间
|
||||||
|
|
||||||
|
```json
|
||||||
|
// jest-e2e.json
|
||||||
|
{
|
||||||
|
"testTimeout": 30000,
|
||||||
|
"verbose": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 4: 测试断言失败 - 响应结构不匹配
|
||||||
|
|
||||||
|
**错误现象**:
|
||||||
|
```
|
||||||
|
expect(res.body.data).toHaveProperty('walletId');
|
||||||
|
// 失败: Received path: []
|
||||||
|
// 但实际 res.body.data 包含 { walletId: "1", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**: 在 E2E 测试中手动添加了 `TransformInterceptor`,但 `AppModule` 已通过 `APP_INTERCEPTOR` 全局提供,导致响应被双重包装。
|
||||||
|
|
||||||
|
**解决方案**: 不要在测试中重复添加已由 AppModule 全局提供的 Filter 和 Interceptor。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 错误做法 - 重复添加
|
||||||
|
app.useGlobalFilters(new DomainExceptionFilter());
|
||||||
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
|
||||||
|
// ✅ 正确做法 - 只添加 ValidationPipe
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// DomainExceptionFilter 和 TransformInterceptor 已由 AppModule 提供
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 5: WSL2 跨文件系统性能极差
|
||||||
|
|
||||||
|
**错误现象**:
|
||||||
|
```
|
||||||
|
# 在 WSL2 中访问 /mnt/c/ 运行测试
|
||||||
|
npm install # 超时
|
||||||
|
npx jest # 超时 (120秒+)
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**: WSL2 的 `/mnt/c/` 是通过 9P 协议挂载的 Windows 文件系统,I/O 性能比原生 Linux 文件系统慢 10-100 倍。
|
||||||
|
|
||||||
|
**解决方案**: 将项目复制到 WSL2 原生文件系统 (`~/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制到 WSL2 原生文件系统后
|
||||||
|
npm install # ~40秒 (vs 超时)
|
||||||
|
npx jest # ~7秒 (vs 超时)
|
||||||
|
```
|
||||||
|
|
||||||
|
**性能对比**:
|
||||||
|
| 操作 | /mnt/c/ (Windows) | ~/ (WSL2 原生) |
|
||||||
|
|-----|-------------------|----------------|
|
||||||
|
| npm install | 超时 | 40秒 |
|
||||||
|
| Jest E2E 测试 | 超时 | 6.7秒 |
|
||||||
|
| TypeScript 编译 | 极慢 | 正常 |
|
||||||
|
|
||||||
|
## 测试数据清理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function cleanupTestData() {
|
||||||
|
try {
|
||||||
|
await prisma.ledgerEntry.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.depositOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.settlementOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.walletAccount.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Cleanup error (may be expected):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自动化脚本
|
||||||
|
|
||||||
|
创建 `scripts/test-e2e.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 获取容器 IP
|
||||||
|
CONTAINER_IP=$(docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$CONTAINER_IP" ]; then
|
||||||
|
echo "Error: PostgreSQL container not running. Starting..."
|
||||||
|
docker start wallet-postgres-test || docker run -d \
|
||||||
|
--restart=always \
|
||||||
|
--name wallet-postgres-test \
|
||||||
|
-e POSTGRES_USER=wallet \
|
||||||
|
-e POSTGRES_PASSWORD=wallet123 \
|
||||||
|
-e POSTGRES_DB=wallet_test \
|
||||||
|
-p 5432:5432 \
|
||||||
|
postgres:15-alpine
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
CONTAINER_IP=$(docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Using PostgreSQL at: $CONTAINER_IP"
|
||||||
|
|
||||||
|
export DATABASE_URL="postgresql://wallet:wallet123@$CONTAINER_IP:5432/wallet_test?schema=public"
|
||||||
|
export JWT_SECRET="test-jwt-secret-key-for-e2e-testing"
|
||||||
|
export NODE_ENV=test
|
||||||
|
|
||||||
|
# 推送 schema (如果需要)
|
||||||
|
npx prisma db push --skip-generate
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
npx jest --config ./test/jest-e2e.json --runInBand --forceExit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **使用独立的测试数据库**: 不要在开发或生产数据库上运行 E2E 测试
|
||||||
|
2. **每次测试前后清理数据**: 确保测试隔离性
|
||||||
|
3. **使用唯一的测试 ID**: 避免与其他数据冲突
|
||||||
|
4. **正确处理异步操作**: 确保所有 Promise 都被等待
|
||||||
|
5. **关闭数据库连接**: 在 `afterAll` 中关闭应用和数据库连接
|
||||||
|
6. **使用 `--forceExit`**: 防止 Jest 挂起
|
||||||
|
|
||||||
|
## 参考命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有 Docker 容器
|
||||||
|
docker ps -a
|
||||||
|
|
||||||
|
# 查看容器日志
|
||||||
|
docker logs wallet-postgres-test
|
||||||
|
|
||||||
|
# 进入 PostgreSQL 容器
|
||||||
|
docker exec -it wallet-postgres-test psql -U wallet -d wallet_test
|
||||||
|
|
||||||
|
# 重置数据库
|
||||||
|
docker exec wallet-postgres-test psql -U wallet -c "DROP DATABASE IF EXISTS wallet_test; CREATE DATABASE wallet_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能问题
|
||||||
|
|
||||||
|
### WSL2 跨文件系统访问慢
|
||||||
|
|
||||||
|
**问题**: 在 WSL2 中访问 `/mnt/c/` (Windows 文件系统) 比访问 Linux 原生文件系统慢很多。
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- `npm run build` 和 `npx jest` 启动很慢
|
||||||
|
- 编译 TypeScript 需要很长时间
|
||||||
|
- 测试可能因为 I/O 超时
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. **首选**: 将项目放在 WSL2 的原生文件系统中 (`~/projects/` 而不是 `/mnt/c/`)
|
||||||
|
2. **或者**: 从 Windows 直接运行测试,但需要配置正确的数据库连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制项目到 WSL2 原生文件系统
|
||||||
|
cp -r /mnt/c/Users/<user>/project ~/project-wsl
|
||||||
|
cd ~/project-wsl
|
||||||
|
npm install
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### 单元测试 vs E2E 测试
|
||||||
|
|
||||||
|
由于上述性能问题,建议:
|
||||||
|
|
||||||
|
1. **单元测试**: 使用 Mock,不需要真实数据库,可以在 Windows 上快速运行
|
||||||
|
2. **E2E 测试**: 使用真实数据库,适合 CI/CD 环境或原生 Linux 环境
|
||||||
|
|
||||||
|
## 当前状态总结
|
||||||
|
|
||||||
|
### ✅ 全部测试通过!
|
||||||
|
|
||||||
|
| 测试类型 | 数量 | 状态 | 运行时间 |
|
||||||
|
|---------|------|------|---------|
|
||||||
|
| 单元测试 | 69 | ✅ 通过 | 5.2s |
|
||||||
|
| E2E 测试 (真实数据库) | 23 | ✅ 通过 | 6.7s |
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
- ✅ PostgreSQL Docker 容器创建和运行
|
||||||
|
- ✅ 数据库 Schema 推送成功
|
||||||
|
- ✅ 单独脚本可以成功连接数据库 (从 WSL2 内部)
|
||||||
|
- ✅ 单元测试 (69 个) 全部通过
|
||||||
|
- ✅ **E2E 真实数据库测试 (23 个) 全部通过!** 🎉
|
||||||
|
|
||||||
|
### 网络问题分析
|
||||||
|
- ❌ Windows → WSL2 Docker: 网络不可达 (NAT 隔离)
|
||||||
|
- ❌ WSL2 → Docker via localhost: 不可用
|
||||||
|
- ✅ WSL2 → Docker via 容器 IP (172.17.0.x): 可用
|
||||||
|
- ✅ 从 WSL2 原生文件系统运行测试: 性能极佳
|
||||||
|
|
||||||
|
### 解决方案总结
|
||||||
|
|
||||||
|
**关键发现**: 将项目复制到 WSL2 原生文件系统可以完美解决性能和网络问题!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 复制项目到 WSL2 原生文件系统
|
||||||
|
mkdir -p ~/wallet-service-test
|
||||||
|
cp -r /mnt/c/Users/<user>/project/src ~/wallet-service-test/
|
||||||
|
cp -r /mnt/c/Users/<user>/project/test ~/wallet-service-test/
|
||||||
|
cp -r /mnt/c/Users/<user>/project/prisma ~/wallet-service-test/
|
||||||
|
cp /mnt/c/Users/<user>/project/package*.json ~/wallet-service-test/
|
||||||
|
cp /mnt/c/Users/<user>/project/tsconfig*.json ~/wallet-service-test/
|
||||||
|
cp /mnt/c/Users/<user>/project/nest-cli.json ~/wallet-service-test/
|
||||||
|
cp /mnt/c/Users/<user>/project/.env.test ~/wallet-service-test/
|
||||||
|
|
||||||
|
# 2. 安装依赖 (~40秒)
|
||||||
|
cd ~/wallet-service-test
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 3. 生成 Prisma Client 并推送 Schema
|
||||||
|
export DATABASE_URL='postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public'
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma db push --skip-generate
|
||||||
|
|
||||||
|
# 4. 运行 E2E 测试 (~7秒)
|
||||||
|
export JWT_SECRET='test-jwt-secret-key-for-e2e-testing'
|
||||||
|
export NODE_ENV=test
|
||||||
|
npx jest --config ./test/jest-e2e.json --runInBand --forceExit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推荐方案
|
||||||
|
1. **本地开发**: 将项目复制到 WSL2 原生文件系统运行真实数据库 E2E 测试
|
||||||
|
2. **CI/CD**: 在 GitHub Actions 中直接使用 localhost 连接 PostgreSQL 服务
|
||||||
|
3. **日常开发**: 单元测试可在 Windows 上直接运行,无需数据库
|
||||||
|
|
||||||
|
## CI/CD 集成建议
|
||||||
|
|
||||||
|
在 GitHub Actions 中运行真实数据库 E2E 测试:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/e2e-tests.yml
|
||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: wallet
|
||||||
|
POSTGRES_PASSWORD: wallet123
|
||||||
|
POSTGRES_DB: wallet_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Push Prisma schema
|
||||||
|
run: npx prisma db push
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public
|
||||||
|
JWT_SECRET: test-jwt-secret
|
||||||
|
NODE_ENV: test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── jest-e2e.json # E2E 测试配置
|
||||||
|
├── app.e2e-spec.ts # 真实数据库 E2E 测试
|
||||||
|
└── (其他测试文件)
|
||||||
|
|
||||||
|
src/
|
||||||
|
├── domain/
|
||||||
|
│ ├── aggregates/*.spec.ts # 领域聚合单元测试
|
||||||
|
│ └── value-objects/*.spec.ts # 值对象单元测试
|
||||||
|
└── application/
|
||||||
|
└── services/*.spec.ts # 应用服务集成测试 (Mock)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### 验证数据库连接
|
||||||
|
```javascript
|
||||||
|
// test-db.js
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
console.log('Connecting...');
|
||||||
|
await prisma.$connect();
|
||||||
|
console.log('Connected!');
|
||||||
|
const result = await prisma.$queryRaw`SELECT 1 as test`;
|
||||||
|
console.log('Query result:', result);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证 NestJS 应用启动
|
||||||
|
```javascript
|
||||||
|
// test-nest-startup.js
|
||||||
|
const { Test } = require('@nestjs/testing');
|
||||||
|
const { AppModule } = require('./dist/app.module');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const moduleFixture = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
console.log('App initialized successfully!');
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,752 @@
|
||||||
|
# Wallet Service 测试文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档描述 Wallet Service 的测试架构和实现方式,包括单元测试、集成测试和端到端 (E2E) 测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试架构
|
||||||
|
|
||||||
|
```
|
||||||
|
测试金字塔
|
||||||
|
┌─────────────┐
|
||||||
|
│ E2E 测试 │ ← 少量,真实数据库
|
||||||
|
│ (23 个) │
|
||||||
|
├─────────────┤
|
||||||
|
│ 集成测试 │ ← 应用服务层
|
||||||
|
│ (Mock) │
|
||||||
|
├─────────────────┤
|
||||||
|
│ 单元测试 │ ← 领域层,快速
|
||||||
|
│ (46 个) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试类型分布
|
||||||
|
|
||||||
|
| 测试类型 | 测试数量 | 覆盖范围 | 执行时间 |
|
||||||
|
|---------|---------|---------|---------|
|
||||||
|
| 单元测试 | 46 | 领域层 (Value Objects, Aggregates) | ~2s |
|
||||||
|
| 应用服务测试 | 23 | 应用层 (Service, Commands, Queries) | ~3s |
|
||||||
|
| E2E 测试 | 23 | API 层 + 数据库 | ~7s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
wallet-service/
|
||||||
|
├── src/
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── value-objects/
|
||||||
|
│ │ │ ├── money.vo.spec.ts # Money 值对象测试
|
||||||
|
│ │ │ ├── balance.vo.spec.ts # Balance 值对象测试
|
||||||
|
│ │ │ └── hashpower.vo.spec.ts # Hashpower 值对象测试
|
||||||
|
│ │ └── aggregates/
|
||||||
|
│ │ ├── wallet-account.aggregate.spec.ts
|
||||||
|
│ │ ├── ledger-entry.aggregate.spec.ts
|
||||||
|
│ │ ├── deposit-order.aggregate.spec.ts
|
||||||
|
│ │ └── settlement-order.aggregate.spec.ts
|
||||||
|
│ └── application/
|
||||||
|
│ └── services/
|
||||||
|
│ └── wallet-application.service.spec.ts
|
||||||
|
├── test/
|
||||||
|
│ ├── jest-e2e.json # E2E 测试配置
|
||||||
|
│ ├── app.e2e-spec.ts # 主 E2E 测试套件
|
||||||
|
│ └── simple.e2e-spec.ts # 简单连接测试
|
||||||
|
└── jest.config.js # Jest 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 单元测试
|
||||||
|
|
||||||
|
### 值对象测试
|
||||||
|
|
||||||
|
值对象测试确保业务规则在值对象层面正确实现。
|
||||||
|
|
||||||
|
#### Money 值对象测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/value-objects/money.vo.spec.ts
|
||||||
|
describe('Money Value Object', () => {
|
||||||
|
describe('creation', () => {
|
||||||
|
it('should create USDT money', () => {
|
||||||
|
const money = Money.USDT(100);
|
||||||
|
expect(money.value).toBe(100);
|
||||||
|
expect(money.currency).toBe('USDT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on negative amount', () => {
|
||||||
|
expect(() => Money.USDT(-10)).toThrow('negative');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operations', () => {
|
||||||
|
it('should add same currency', () => {
|
||||||
|
const a = Money.USDT(100);
|
||||||
|
const b = Money.USDT(50);
|
||||||
|
const sum = a.add(b);
|
||||||
|
expect(sum.value).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on currency mismatch', () => {
|
||||||
|
const usdt = Money.USDT(100);
|
||||||
|
const bnb = Money.BNB(1);
|
||||||
|
expect(() => usdt.add(bnb)).toThrow('currency');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Balance 值对象测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/value-objects/balance.vo.spec.ts
|
||||||
|
describe('Balance Value Object', () => {
|
||||||
|
it('should freeze available to frozen', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(0));
|
||||||
|
const frozen = balance.freeze(Money.USDT(30));
|
||||||
|
|
||||||
|
expect(frozen.available.value).toBe(70);
|
||||||
|
expect(frozen.frozen.value).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on insufficient balance for freeze', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(50), Money.USDT(0));
|
||||||
|
expect(() => balance.freeze(Money.USDT(100))).toThrow('Insufficient');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 聚合测试
|
||||||
|
|
||||||
|
聚合测试验证复杂的业务逻辑和领域事件。
|
||||||
|
|
||||||
|
#### WalletAccount 聚合测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/aggregates/wallet-account.aggregate.spec.ts
|
||||||
|
describe('WalletAccount Aggregate', () => {
|
||||||
|
let wallet: WalletAccount;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wallet = WalletAccount.createNew(UserId.create(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deposit', () => {
|
||||||
|
it('should increase USDT balance on deposit', () => {
|
||||||
|
const amount = Money.USDT(100);
|
||||||
|
wallet.deposit(amount, 'KAVA', 'tx_hash_123');
|
||||||
|
|
||||||
|
expect(wallet.balances.usdt.available.value).toBe(100);
|
||||||
|
expect(wallet.domainEvents.length).toBe(1);
|
||||||
|
expect(wallet.domainEvents[0].eventType).toBe('DepositCompletedEvent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when wallet is frozen', () => {
|
||||||
|
wallet.freezeWallet();
|
||||||
|
expect(() => wallet.deposit(Money.USDT(100), 'KAVA', 'tx')).toThrow('Wallet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rewards lifecycle', () => {
|
||||||
|
it('should move pending rewards to settleable', () => {
|
||||||
|
const usdt = Money.USDT(10);
|
||||||
|
const hashpower = Hashpower.create(5);
|
||||||
|
const expireAt = new Date(Date.now() + 86400000);
|
||||||
|
|
||||||
|
wallet.addPendingReward(usdt, hashpower, expireAt, 'order_123');
|
||||||
|
wallet.movePendingToSettleable();
|
||||||
|
|
||||||
|
expect(wallet.rewards.pendingUsdt.isZero()).toBe(true);
|
||||||
|
expect(wallet.rewards.settleableUsdt.value).toBe(10);
|
||||||
|
expect(wallet.hashpower.value).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行单元测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有单元测试
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# 监听模式
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# 覆盖率报告
|
||||||
|
npm run test:cov
|
||||||
|
|
||||||
|
# 运行特定文件
|
||||||
|
npm test -- money.vo.spec.ts
|
||||||
|
|
||||||
|
# 运行特定测试
|
||||||
|
npm test -- --testNamePattern="should create USDT"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 应用服务测试
|
||||||
|
|
||||||
|
应用服务测试使用 Mock 仓储来隔离业务逻辑测试。
|
||||||
|
|
||||||
|
### Mock 仓储设置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/application/services/wallet-application.service.spec.ts
|
||||||
|
describe('WalletApplicationService', () => {
|
||||||
|
let service: WalletApplicationService;
|
||||||
|
let mockWalletRepo: any;
|
||||||
|
let mockLedgerRepo: any;
|
||||||
|
let mockDepositRepo: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 创建 Mock 仓储
|
||||||
|
mockWalletRepo = {
|
||||||
|
save: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
getOrCreate: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLedgerRepo = {
|
||||||
|
save: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDepositRepo = {
|
||||||
|
save: jest.fn(),
|
||||||
|
existsByTxHash: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 配置测试模块
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
WalletApplicationService,
|
||||||
|
{ provide: WALLET_ACCOUNT_REPOSITORY, useValue: mockWalletRepo },
|
||||||
|
{ provide: LEDGER_ENTRY_REPOSITORY, useValue: mockLedgerRepo },
|
||||||
|
{ provide: DEPOSIT_ORDER_REPOSITORY, useValue: mockDepositRepo },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<WalletApplicationService>(WalletApplicationService);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命令处理测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('handleDeposit', () => {
|
||||||
|
it('should process deposit successfully', async () => {
|
||||||
|
const command = new HandleDepositCommand('1', 100, ChainType.KAVA, 'tx_123');
|
||||||
|
const mockWallet = createMockWallet(BigInt(1), 0);
|
||||||
|
|
||||||
|
mockDepositRepo.existsByTxHash.mockResolvedValue(false);
|
||||||
|
mockWalletRepo.getOrCreate.mockResolvedValue(mockWallet);
|
||||||
|
mockDepositRepo.save.mockResolvedValue({});
|
||||||
|
mockWalletRepo.save.mockResolvedValue(mockWallet);
|
||||||
|
mockLedgerRepo.save.mockResolvedValue({});
|
||||||
|
|
||||||
|
await service.handleDeposit(command);
|
||||||
|
|
||||||
|
expect(mockDepositRepo.existsByTxHash).toHaveBeenCalledWith('tx_123');
|
||||||
|
expect(mockWalletRepo.getOrCreate).toHaveBeenCalledWith(BigInt(1));
|
||||||
|
expect(mockDepositRepo.save).toHaveBeenCalled();
|
||||||
|
expect(mockWalletRepo.save).toHaveBeenCalled();
|
||||||
|
expect(mockLedgerRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for duplicate transaction', async () => {
|
||||||
|
mockDepositRepo.existsByTxHash.mockResolvedValue(true);
|
||||||
|
const command = new HandleDepositCommand('1', 100, ChainType.KAVA, 'tx_dup');
|
||||||
|
|
||||||
|
await expect(service.handleDeposit(command)).rejects.toThrow('Duplicate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询处理测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('getMyWallet', () => {
|
||||||
|
it('should return wallet DTO', async () => {
|
||||||
|
const query = new GetMyWalletQuery('1');
|
||||||
|
const mockWallet = createMockWallet(BigInt(1), 100);
|
||||||
|
|
||||||
|
mockWalletRepo.getOrCreate.mockResolvedValue(mockWallet);
|
||||||
|
|
||||||
|
const result = await service.getMyWallet(query);
|
||||||
|
|
||||||
|
expect(result.userId).toBe('1');
|
||||||
|
expect(result.balances.usdt.available).toBe(100);
|
||||||
|
expect(result.status).toBe('ACTIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E2E 测试
|
||||||
|
|
||||||
|
E2E 测试使用真实数据库验证完整的请求流程。
|
||||||
|
|
||||||
|
### 测试配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
// test/jest-e2e.json
|
||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试环境设置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// test/app.e2e-spec.ts
|
||||||
|
describe('Wallet Service (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let authToken: string;
|
||||||
|
const testUserId = '99999';
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || 'test-secret';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// 只需要 ValidationPipe
|
||||||
|
// DomainExceptionFilter 和 TransformInterceptor 由 AppModule 提供
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
// 生成测试 JWT Token
|
||||||
|
authToken = jwt.sign(
|
||||||
|
{ sub: testUserId, seq: 1001 },
|
||||||
|
jwtSecret,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
await prisma.ledgerEntry.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.depositOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.walletAccount.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 端点测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('Health Check', () => {
|
||||||
|
it('/api/v1/health (GET) - should return health status', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/health')
|
||||||
|
.expect(200)
|
||||||
|
.expect(res => {
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Wallet Operations', () => {
|
||||||
|
it('/api/v1/wallet/my-wallet (GET) - should return wallet info', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data).toHaveProperty('walletId');
|
||||||
|
expect(res.body.data).toHaveProperty('balances');
|
||||||
|
expect(res.body.data.status).toBe('ACTIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Deposit Operations', () => {
|
||||||
|
it('/api/v1/wallet/deposit (POST) - should process deposit', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/deposit')
|
||||||
|
.send({
|
||||||
|
userId: testUserId,
|
||||||
|
amount: 100,
|
||||||
|
chainType: 'KAVA',
|
||||||
|
txHash: `test_tx_${Date.now()}`,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// 验证余额更新
|
||||||
|
const walletRes = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
|
||||||
|
expect(walletRes.body.data.balances.usdt.available).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库完整性测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('Database Integrity', () => {
|
||||||
|
it('should persist wallet data correctly', async () => {
|
||||||
|
const wallet = await prisma.walletAccount.findFirst({
|
||||||
|
where: { userId: BigInt(testUserId) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wallet).not.toBeNull();
|
||||||
|
expect(wallet?.status).toBe('ACTIVE');
|
||||||
|
expect(Number(wallet?.usdtAvailable)).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist ledger entries correctly', async () => {
|
||||||
|
const entries = await prisma.ledgerEntry.findMany({
|
||||||
|
where: { userId: BigInt(testUserId) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WSL2 环境配置
|
||||||
|
|
||||||
|
### PostgreSQL Docker 容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 WSL2 中启动 PostgreSQL
|
||||||
|
docker run -d \
|
||||||
|
--name wallet-postgres-test \
|
||||||
|
-e POSTGRES_USER=wallet \
|
||||||
|
-e POSTGRES_PASSWORD=wallet123 \
|
||||||
|
-e POSTGRES_DB=wallet_test \
|
||||||
|
-p 5432:5432 \
|
||||||
|
postgres:15-alpine
|
||||||
|
|
||||||
|
# 获取容器 IP
|
||||||
|
docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' wallet-postgres-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量设置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用容器 IP (推荐)
|
||||||
|
export DATABASE_URL="postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public"
|
||||||
|
|
||||||
|
# 或使用 localhost (确保端口映射正常)
|
||||||
|
export DATABASE_URL="postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public"
|
||||||
|
|
||||||
|
export JWT_SECRET="test-jwt-secret-key-for-e2e-testing"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行 E2E 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 初始化数据库
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# 运行 E2E 测试
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# 使用 dotenv 加载环境变量
|
||||||
|
npx dotenv -e .env.test -- npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 问题 1: 测试超时
|
||||||
|
|
||||||
|
**症状**: E2E 测试超时或长时间无响应
|
||||||
|
|
||||||
|
**原因**: WSL2 跨文件系统性能问题
|
||||||
|
|
||||||
|
**解决方案**: 将项目复制到 WSL2 原生文件系统
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 不要使用 /mnt/c/ 路径
|
||||||
|
cp -r /mnt/c/project ~/project
|
||||||
|
cd ~/project
|
||||||
|
npm install
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 2: 数据库连接失败
|
||||||
|
|
||||||
|
**症状**: `ECONNREFUSED` 或 `Connection timeout`
|
||||||
|
|
||||||
|
**原因**: 网络配置问题
|
||||||
|
|
||||||
|
**解决方案**: 使用 Docker 容器 IP 而非 localhost
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取容器 IP
|
||||||
|
docker inspect wallet-postgres-test | grep IPAddress
|
||||||
|
|
||||||
|
# 更新 DATABASE_URL
|
||||||
|
export DATABASE_URL="postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 3: 响应结构断言失败
|
||||||
|
|
||||||
|
**症状**: `expect(res.body.data).toHaveProperty('walletId')` 失败
|
||||||
|
|
||||||
|
**原因**: TransformInterceptor 被重复应用
|
||||||
|
|
||||||
|
**错误代码**:
|
||||||
|
```typescript
|
||||||
|
// 错误 - 导致双重包装
|
||||||
|
app.useGlobalFilters(new DomainExceptionFilter());
|
||||||
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
```
|
||||||
|
|
||||||
|
**正确代码**:
|
||||||
|
```typescript
|
||||||
|
// 正确 - 只添加 ValidationPipe
|
||||||
|
// Filter 和 Interceptor 由 AppModule 通过 APP_INTERCEPTOR 提供
|
||||||
|
app.useGlobalPipes(new ValidationPipe({...}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 4: Prisma Client 未生成
|
||||||
|
|
||||||
|
**症状**: `Cannot find module '@prisma/client'`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试覆盖率
|
||||||
|
|
||||||
|
### 生成覆盖率报告
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
### 覆盖率目标
|
||||||
|
|
||||||
|
| 层级 | 语句覆盖 | 分支覆盖 | 函数覆盖 |
|
||||||
|
|-----|---------|---------|---------|
|
||||||
|
| Domain Layer | 90%+ | 85%+ | 90%+ |
|
||||||
|
| Application Layer | 80%+ | 75%+ | 85%+ |
|
||||||
|
| API Layer | 70%+ | 65%+ | 80%+ |
|
||||||
|
|
||||||
|
### 查看报告
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HTML 报告
|
||||||
|
open coverage/lcov-report/index.html
|
||||||
|
|
||||||
|
# 终端摘要
|
||||||
|
npm run test:cov -- --coverageReporters="text-summary"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD 集成
|
||||||
|
|
||||||
|
### GitHub Actions 配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/test.yml
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: wallet
|
||||||
|
POSTGRES_PASSWORD: wallet123
|
||||||
|
POSTGRES_DB: wallet_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate Prisma Client
|
||||||
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Push database schema
|
||||||
|
run: npx prisma db push
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test
|
||||||
|
JWT_SECRET: test-jwt-secret
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试最佳实践
|
||||||
|
|
||||||
|
### 1. 测试命名
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 好的命名
|
||||||
|
it('should increase USDT balance when deposit is processed')
|
||||||
|
it('should throw InsufficientBalanceError when balance is insufficient')
|
||||||
|
|
||||||
|
// 避免的命名
|
||||||
|
it('test deposit')
|
||||||
|
it('works')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 测试隔离
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 每个测试独立
|
||||||
|
beforeEach(() => {
|
||||||
|
wallet = WalletAccount.createNew(UserId.create(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用工厂函数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 创建测试数据的工厂函数
|
||||||
|
const createMockWallet = (userId: bigint, balance = 0) => {
|
||||||
|
return WalletAccount.reconstruct({
|
||||||
|
walletId: BigInt(1),
|
||||||
|
userId,
|
||||||
|
usdtAvailable: new Decimal(balance),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 断言明确
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 好的断言
|
||||||
|
expect(wallet.balances.usdt.available.value).toBe(100);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data).toHaveProperty('walletId');
|
||||||
|
|
||||||
|
// 避免的断言
|
||||||
|
expect(wallet).toBeTruthy();
|
||||||
|
expect(res.body).toBeDefined();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试测试
|
||||||
|
|
||||||
|
### VS Code 调试配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
// .vscode/launch.json
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug Jest Tests",
|
||||||
|
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||||
|
"args": ["--runInBand", "--watchAll=false"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug E2E Tests",
|
||||||
|
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||||
|
"args": ["--config", "test/jest-e2e.json", "--runInBand"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL": "postgresql://wallet:wallet123@localhost:5432/wallet_test",
|
||||||
|
"JWT_SECRET": "test-secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 单个测试调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 只运行特定测试
|
||||||
|
npm test -- --testNamePattern="should process deposit"
|
||||||
|
|
||||||
|
# 运行特定文件
|
||||||
|
npm test -- wallet-account.aggregate.spec.ts
|
||||||
|
|
||||||
|
# 显示详细输出
|
||||||
|
npm test -- --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
{
|
||||||
|
"name": "wallet-service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "RWA Wallet & Ledger Service",
|
||||||
|
"author": "RWA Team",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"prisma": {
|
||||||
|
"schema": "prisma/schema.prisma",
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:migrate:prod": "prisma migrate deploy",
|
||||||
|
"prisma:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/microservices": "^10.0.0",
|
||||||
|
"@nestjs/passport": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/swagger": "^7.1.17",
|
||||||
|
"@prisma/client": "^5.7.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"decimal.js": "^10.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jest": "^29.5.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^20.3.1",
|
||||||
|
"@types/passport-jwt": "^4.0.0",
|
||||||
|
"@types/supertest": "^6.0.0",
|
||||||
|
"@types/uuid": "^9.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"prisma": "^5.7.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-loader": "^9.4.3",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.1.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 钱包账户表 (状态表)
|
||||||
|
// ============================================
|
||||||
|
model WalletAccount {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("wallet_id")
|
||||||
|
userId BigInt @unique @map("user_id")
|
||||||
|
|
||||||
|
// USDT 余额
|
||||||
|
usdtAvailable Decimal @default(0) @map("usdt_available") @db.Decimal(20, 8)
|
||||||
|
usdtFrozen Decimal @default(0) @map("usdt_frozen") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// DST 余额
|
||||||
|
dstAvailable Decimal @default(0) @map("dst_available") @db.Decimal(20, 8)
|
||||||
|
dstFrozen Decimal @default(0) @map("dst_frozen") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// BNB 余额
|
||||||
|
bnbAvailable Decimal @default(0) @map("bnb_available") @db.Decimal(20, 8)
|
||||||
|
bnbFrozen Decimal @default(0) @map("bnb_frozen") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// OG 余额
|
||||||
|
ogAvailable Decimal @default(0) @map("og_available") @db.Decimal(20, 8)
|
||||||
|
ogFrozen Decimal @default(0) @map("og_frozen") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// RWAD 余额
|
||||||
|
rwadAvailable Decimal @default(0) @map("rwad_available") @db.Decimal(20, 8)
|
||||||
|
rwadFrozen Decimal @default(0) @map("rwad_frozen") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 算力
|
||||||
|
hashpower Decimal @default(0) @map("hashpower") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 待领取收益
|
||||||
|
pendingUsdt Decimal @default(0) @map("pending_usdt") @db.Decimal(20, 8)
|
||||||
|
pendingHashpower Decimal @default(0) @map("pending_hashpower") @db.Decimal(20, 8)
|
||||||
|
pendingExpireAt DateTime? @map("pending_expire_at")
|
||||||
|
|
||||||
|
// 可结算收益
|
||||||
|
settleableUsdt Decimal @default(0) @map("settleable_usdt") @db.Decimal(20, 8)
|
||||||
|
settleableHashpower Decimal @default(0) @map("settleable_hashpower") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 已结算总额
|
||||||
|
settledTotalUsdt Decimal @default(0) @map("settled_total_usdt") @db.Decimal(20, 8)
|
||||||
|
settledTotalHashpower Decimal @default(0) @map("settled_total_hashpower") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 已过期总额
|
||||||
|
expiredTotalUsdt Decimal @default(0) @map("expired_total_usdt") @db.Decimal(20, 8)
|
||||||
|
expiredTotalHashpower Decimal @default(0) @map("expired_total_hashpower") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
status String @default("ACTIVE") @map("status") @db.VarChar(20)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("wallet_accounts")
|
||||||
|
@@index([userId])
|
||||||
|
@@index([usdtAvailable(sort: Desc)])
|
||||||
|
@@index([hashpower(sort: Desc)])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 账本流水表 (行为表, append-only)
|
||||||
|
// ============================================
|
||||||
|
model LedgerEntry {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("entry_id")
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
|
||||||
|
// 流水类型
|
||||||
|
entryType String @map("entry_type") @db.VarChar(50)
|
||||||
|
|
||||||
|
// 金额变动 (正数入账, 负数支出)
|
||||||
|
amount Decimal @map("amount") @db.Decimal(20, 8)
|
||||||
|
assetType String @map("asset_type") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 余额快照 (操作后余额)
|
||||||
|
balanceAfter Decimal? @map("balance_after") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 关联引用
|
||||||
|
refOrderId String? @map("ref_order_id") @db.VarChar(100)
|
||||||
|
refTxHash String? @map("ref_tx_hash") @db.VarChar(100)
|
||||||
|
|
||||||
|
// 备注
|
||||||
|
memo String? @map("memo") @db.VarChar(500)
|
||||||
|
|
||||||
|
// 扩展数据
|
||||||
|
payloadJson Json? @map("payload_json")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("wallet_ledger_entries")
|
||||||
|
@@index([userId, createdAt(sort: Desc)])
|
||||||
|
@@index([entryType])
|
||||||
|
@@index([assetType])
|
||||||
|
@@index([refOrderId])
|
||||||
|
@@index([refTxHash])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 充值订单表
|
||||||
|
// ============================================
|
||||||
|
model DepositOrder {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("order_id")
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
|
||||||
|
// 充值信息
|
||||||
|
chainType String @map("chain_type") @db.VarChar(20)
|
||||||
|
amount Decimal @map("amount") @db.Decimal(20, 8)
|
||||||
|
txHash String @unique @map("tx_hash") @db.VarChar(100)
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
status String @default("PENDING") @map("status") @db.VarChar(20)
|
||||||
|
confirmedAt DateTime? @map("confirmed_at")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("deposit_orders")
|
||||||
|
@@index([userId])
|
||||||
|
@@index([txHash])
|
||||||
|
@@index([status])
|
||||||
|
@@index([chainType])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 结算订单表
|
||||||
|
// ============================================
|
||||||
|
model SettlementOrder {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("order_id")
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
|
||||||
|
// 结算信息
|
||||||
|
usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8)
|
||||||
|
settleCurrency String @map("settle_currency") @db.VarChar(10)
|
||||||
|
|
||||||
|
// SWAP 信息
|
||||||
|
swapTxHash String? @map("swap_tx_hash") @db.VarChar(100)
|
||||||
|
receivedAmount Decimal? @map("received_amount") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
status String @default("PENDING") @map("status") @db.VarChar(20)
|
||||||
|
settledAt DateTime? @map("settled_at")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("settlement_orders")
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([settleCurrency])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
WalletController,
|
||||||
|
LedgerController,
|
||||||
|
DepositController,
|
||||||
|
HealthController,
|
||||||
|
} from './controllers';
|
||||||
|
import { WalletApplicationService } from '@/application/services';
|
||||||
|
import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('JWT_SECRET') || 'default-secret',
|
||||||
|
signOptions: { expiresIn: '7d' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
WalletController,
|
||||||
|
LedgerController,
|
||||||
|
DepositController,
|
||||||
|
HealthController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
WalletApplicationService,
|
||||||
|
JwtStrategy,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ApiModule {}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Controller, Post, Body } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { WalletApplicationService } from '@/application/services';
|
||||||
|
import { HandleDepositCommand } from '@/application/commands';
|
||||||
|
import { HandleDepositDTO } from '@/api/dto/request';
|
||||||
|
import { Public } from '@/shared/decorators';
|
||||||
|
|
||||||
|
@ApiTags('Deposit (Internal)')
|
||||||
|
@Controller('wallet/deposit')
|
||||||
|
export class DepositController {
|
||||||
|
constructor(private readonly walletService: WalletApplicationService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '处理充值入账',
|
||||||
|
description: '内部服务调用,处理链上充值确认后的入账',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '入账成功' })
|
||||||
|
async handleDeposit(@Body() dto: HandleDepositDTO): Promise<{ message: string }> {
|
||||||
|
const command = new HandleDepositCommand(
|
||||||
|
dto.userId,
|
||||||
|
dto.amount,
|
||||||
|
dto.chainType,
|
||||||
|
dto.txHash,
|
||||||
|
);
|
||||||
|
await this.walletService.handleDeposit(command);
|
||||||
|
return { message: 'Deposit processed successfully' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { Public } from '@/shared/decorators';
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '健康检查' })
|
||||||
|
health() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'wallet-service',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './wallet.controller';
|
||||||
|
export * from './ledger.controller';
|
||||||
|
export * from './deposit.controller';
|
||||||
|
export * from './health.controller';
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { WalletApplicationService } from '@/application/services';
|
||||||
|
import { GetMyLedgerQuery } from '@/application/queries';
|
||||||
|
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||||
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { GetMyLedgerQueryDTO } from '@/api/dto/request';
|
||||||
|
import { PaginatedLedgerResponseDTO } from '@/api/dto/response';
|
||||||
|
|
||||||
|
@ApiTags('Ledger')
|
||||||
|
@Controller('wallet/ledger')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class LedgerController {
|
||||||
|
constructor(private readonly walletService: WalletApplicationService) {}
|
||||||
|
|
||||||
|
@Get('my-ledger')
|
||||||
|
@ApiOperation({ summary: '查询我的流水', description: '获取当前用户的账本流水记录' })
|
||||||
|
@ApiResponse({ status: 200, type: PaginatedLedgerResponseDTO })
|
||||||
|
async getMyLedger(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@Query() queryDto: GetMyLedgerQueryDTO,
|
||||||
|
): Promise<PaginatedLedgerResponseDTO> {
|
||||||
|
const query = new GetMyLedgerQuery(
|
||||||
|
user.userId,
|
||||||
|
queryDto.page,
|
||||||
|
queryDto.pageSize,
|
||||||
|
queryDto.entryType,
|
||||||
|
queryDto.assetType,
|
||||||
|
queryDto.startDate ? new Date(queryDto.startDate) : undefined,
|
||||||
|
queryDto.endDate ? new Date(queryDto.endDate) : undefined,
|
||||||
|
);
|
||||||
|
return this.walletService.getMyLedger(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { WalletApplicationService } from '@/application/services';
|
||||||
|
import { GetMyWalletQuery } from '@/application/queries';
|
||||||
|
import { ClaimRewardsCommand, SettleRewardsCommand } from '@/application/commands';
|
||||||
|
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||||
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { SettleRewardsDTO } from '@/api/dto/request';
|
||||||
|
import { WalletResponseDTO } from '@/api/dto/response';
|
||||||
|
|
||||||
|
@ApiTags('Wallet')
|
||||||
|
@Controller('wallet')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class WalletController {
|
||||||
|
constructor(private readonly walletService: WalletApplicationService) {}
|
||||||
|
|
||||||
|
@Get('my-wallet')
|
||||||
|
@ApiOperation({ summary: '查询我的钱包', description: '获取当前用户的钱包余额、算力和奖励信息' })
|
||||||
|
@ApiResponse({ status: 200, type: WalletResponseDTO })
|
||||||
|
async getMyWallet(@CurrentUser() user: CurrentUserPayload): Promise<WalletResponseDTO> {
|
||||||
|
const query = new GetMyWalletQuery(user.userId);
|
||||||
|
return this.walletService.getMyWallet(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('claim-rewards')
|
||||||
|
@ApiOperation({ summary: '领取奖励', description: '将待领取的奖励转为可结算状态' })
|
||||||
|
@ApiResponse({ status: 200, description: '领取成功' })
|
||||||
|
async claimRewards(@CurrentUser() user: CurrentUserPayload): Promise<{ message: string }> {
|
||||||
|
const command = new ClaimRewardsCommand(user.userId);
|
||||||
|
await this.walletService.claimRewards(command);
|
||||||
|
return { message: 'Rewards claimed successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('settle')
|
||||||
|
@ApiOperation({ summary: '结算收益', description: '将可结算的USDT兑换为指定币种' })
|
||||||
|
@ApiResponse({ status: 200, description: '结算成功' })
|
||||||
|
async settleRewards(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@Body() dto: SettleRewardsDTO,
|
||||||
|
): Promise<{ settlementOrderId: string }> {
|
||||||
|
const command = new SettleRewardsCommand(
|
||||||
|
user.userId,
|
||||||
|
dto.usdtAmount,
|
||||||
|
dto.settleCurrency,
|
||||||
|
);
|
||||||
|
const orderId = await this.walletService.settleRewards(command);
|
||||||
|
return { settlementOrderId: orderId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { IsNotEmpty, IsNumber, IsString, IsEnum, Min } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ChainType } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export class HandleDepositDTO {
|
||||||
|
@ApiProperty({ description: '用户ID' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '充值金额', minimum: 0 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '链类型', enum: ChainType })
|
||||||
|
@IsEnum(ChainType)
|
||||||
|
chainType: ChainType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '交易哈希' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
txHash: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './deposit.dto';
|
||||||
|
export * from './ledger-query.dto';
|
||||||
|
export * from './settlement.dto';
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { IsOptional, IsNumber, IsEnum, IsDateString, Min, Max } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { LedgerEntryType, AssetType } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export class GetMyLedgerQueryDTO {
|
||||||
|
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '流水类型', enum: LedgerEntryType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LedgerEntryType)
|
||||||
|
entryType?: LedgerEntryType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '资产类型', enum: AssetType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetType)
|
||||||
|
assetType?: AssetType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '开始日期' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '结束日期' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { IsNumber, IsEnum, Min } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { SettleCurrency } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export class SettleRewardsDTO {
|
||||||
|
@ApiProperty({ description: '结算USDT金额', minimum: 0 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
usdtAmount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '结算目标币种', enum: SettleCurrency })
|
||||||
|
@IsEnum(SettleCurrency)
|
||||||
|
settleCurrency: SettleCurrency;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './wallet.dto';
|
||||||
|
export * from './ledger.dto';
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LedgerEntryResponseDTO {
|
||||||
|
@ApiProperty({ description: '流水ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '流水类型' })
|
||||||
|
entryType: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '金额 (正数入账, 负数支出)' })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '资产类型' })
|
||||||
|
assetType: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '操作后余额', nullable: true })
|
||||||
|
balanceAfter: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '关联订单ID', nullable: true })
|
||||||
|
refOrderId: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '关联交易哈希', nullable: true })
|
||||||
|
refTxHash: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '备注', nullable: true })
|
||||||
|
memo: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '创建时间' })
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaginatedLedgerResponseDTO {
|
||||||
|
@ApiProperty({ type: [LedgerEntryResponseDTO], description: '流水列表' })
|
||||||
|
data: LedgerEntryResponseDTO[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总记录数' })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前页码' })
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '每页数量' })
|
||||||
|
pageSize: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总页数' })
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class BalanceDTO {
|
||||||
|
@ApiProperty({ description: '可用余额' })
|
||||||
|
available: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '冻结余额' })
|
||||||
|
frozen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BalancesDTO {
|
||||||
|
@ApiProperty({ type: BalanceDTO })
|
||||||
|
usdt: BalanceDTO;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BalanceDTO })
|
||||||
|
dst: BalanceDTO;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BalanceDTO })
|
||||||
|
bnb: BalanceDTO;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BalanceDTO })
|
||||||
|
og: BalanceDTO;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BalanceDTO })
|
||||||
|
rwad: BalanceDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RewardsDTO {
|
||||||
|
@ApiProperty({ description: '待领取USDT' })
|
||||||
|
pendingUsdt: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '待领取算力' })
|
||||||
|
pendingHashpower: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '待领取过期时间', nullable: true })
|
||||||
|
pendingExpireAt: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '可结算USDT' })
|
||||||
|
settleableUsdt: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '可结算算力' })
|
||||||
|
settleableHashpower: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已结算USDT总额' })
|
||||||
|
settledTotalUsdt: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已结算算力总额' })
|
||||||
|
settledTotalHashpower: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已过期USDT总额' })
|
||||||
|
expiredTotalUsdt: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已过期算力总额' })
|
||||||
|
expiredTotalHashpower: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletResponseDTO {
|
||||||
|
@ApiProperty({ description: '钱包ID' })
|
||||||
|
walletId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '用户ID' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BalancesDTO, description: '各币种余额' })
|
||||||
|
balances: BalancesDTO;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '算力' })
|
||||||
|
hashpower: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: RewardsDTO, description: '奖励信息' })
|
||||||
|
rewards: RewardsDTO;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '钱包状态' })
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||||
|
import { ApiModule } from '@/api/api.module';
|
||||||
|
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
|
import { DomainExceptionFilter } from '@/shared/filters/domain-exception.filter';
|
||||||
|
import { TransformInterceptor } from '@/shared/interceptors/transform.interceptor';
|
||||||
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: [
|
||||||
|
`.env.${process.env.NODE_ENV || 'development'}`,
|
||||||
|
'.env',
|
||||||
|
],
|
||||||
|
// Also load from process.env (system environment variables)
|
||||||
|
ignoreEnvFile: false,
|
||||||
|
}),
|
||||||
|
InfrastructureModule,
|
||||||
|
ApiModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: DomainExceptionFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: TransformInterceptor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export class AddRewardsCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly usdtAmount: number,
|
||||||
|
public readonly hashpowerAmount: number,
|
||||||
|
public readonly expireAt: Date,
|
||||||
|
public readonly refOrderId?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export class ClaimRewardsCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export class DeductForPlantingCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly amount: number,
|
||||||
|
public readonly orderId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { ChainType } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export class HandleDepositCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly amount: number,
|
||||||
|
public readonly chainType: ChainType,
|
||||||
|
public readonly txHash: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './handle-deposit.command';
|
||||||
|
export * from './deduct-for-planting.command';
|
||||||
|
export * from './add-rewards.command';
|
||||||
|
export * from './claim-rewards.command';
|
||||||
|
export * from './settle-rewards.command';
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { SettleCurrency } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export class SettleRewardsCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly usdtAmount: number,
|
||||||
|
public readonly settleCurrency: SettleCurrency,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { LedgerEntryType, AssetType } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export class GetMyLedgerQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly page?: number,
|
||||||
|
public readonly pageSize?: number,
|
||||||
|
public readonly entryType?: LedgerEntryType,
|
||||||
|
public readonly assetType?: AssetType,
|
||||||
|
public readonly startDate?: Date,
|
||||||
|
public readonly endDate?: Date,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export class GetMyWalletQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './get-my-wallet.query';
|
||||||
|
export * from './get-my-ledger.query';
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './wallet-application.service';
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { WalletApplicationService } from './wallet-application.service';
|
||||||
|
import {
|
||||||
|
WALLET_ACCOUNT_REPOSITORY,
|
||||||
|
LEDGER_ENTRY_REPOSITORY,
|
||||||
|
DEPOSIT_ORDER_REPOSITORY,
|
||||||
|
SETTLEMENT_ORDER_REPOSITORY,
|
||||||
|
} from '@/domain/repositories';
|
||||||
|
import { WalletAccount, DepositOrder, SettlementOrder } from '@/domain/aggregates';
|
||||||
|
import { UserId, ChainType, SettleCurrency, Money, Hashpower, WalletStatus } from '@/domain/value-objects';
|
||||||
|
import {
|
||||||
|
HandleDepositCommand,
|
||||||
|
DeductForPlantingCommand,
|
||||||
|
AddRewardsCommand,
|
||||||
|
ClaimRewardsCommand,
|
||||||
|
SettleRewardsCommand,
|
||||||
|
} from '@/application/commands';
|
||||||
|
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
describe('WalletApplicationService', () => {
|
||||||
|
let service: WalletApplicationService;
|
||||||
|
let mockWalletRepo: any;
|
||||||
|
let mockLedgerRepo: any;
|
||||||
|
let mockDepositRepo: any;
|
||||||
|
let mockSettlementRepo: any;
|
||||||
|
|
||||||
|
const createMockWallet = (userId: bigint, usdtBalance = 0) => {
|
||||||
|
return WalletAccount.reconstruct({
|
||||||
|
walletId: BigInt(1),
|
||||||
|
userId,
|
||||||
|
usdtAvailable: new Decimal(usdtBalance),
|
||||||
|
usdtFrozen: new Decimal(0),
|
||||||
|
dstAvailable: new Decimal(0),
|
||||||
|
dstFrozen: new Decimal(0),
|
||||||
|
bnbAvailable: new Decimal(0),
|
||||||
|
bnbFrozen: new Decimal(0),
|
||||||
|
ogAvailable: new Decimal(0),
|
||||||
|
ogFrozen: new Decimal(0),
|
||||||
|
rwadAvailable: new Decimal(0),
|
||||||
|
rwadFrozen: new Decimal(0),
|
||||||
|
hashpower: new Decimal(0),
|
||||||
|
pendingUsdt: new Decimal(0),
|
||||||
|
pendingHashpower: new Decimal(0),
|
||||||
|
pendingExpireAt: null,
|
||||||
|
settleableUsdt: new Decimal(0),
|
||||||
|
settleableHashpower: new Decimal(0),
|
||||||
|
settledTotalUsdt: new Decimal(0),
|
||||||
|
settledTotalHashpower: new Decimal(0),
|
||||||
|
expiredTotalUsdt: new Decimal(0),
|
||||||
|
expiredTotalHashpower: new Decimal(0),
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockWalletRepo = {
|
||||||
|
save: jest.fn(),
|
||||||
|
findById: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
getOrCreate: jest.fn(),
|
||||||
|
findByUserIds: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLedgerRepo = {
|
||||||
|
save: jest.fn(),
|
||||||
|
saveAll: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
findByRefOrderId: jest.fn(),
|
||||||
|
findByRefTxHash: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDepositRepo = {
|
||||||
|
save: jest.fn(),
|
||||||
|
findById: jest.fn(),
|
||||||
|
findByTxHash: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
existsByTxHash: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockSettlementRepo = {
|
||||||
|
save: jest.fn(),
|
||||||
|
findById: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
findPendingOrders: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
WalletApplicationService,
|
||||||
|
{ provide: WALLET_ACCOUNT_REPOSITORY, useValue: mockWalletRepo },
|
||||||
|
{ provide: LEDGER_ENTRY_REPOSITORY, useValue: mockLedgerRepo },
|
||||||
|
{ provide: DEPOSIT_ORDER_REPOSITORY, useValue: mockDepositRepo },
|
||||||
|
{ provide: SETTLEMENT_ORDER_REPOSITORY, useValue: mockSettlementRepo },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<WalletApplicationService>(WalletApplicationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleDeposit', () => {
|
||||||
|
it('should process deposit successfully', async () => {
|
||||||
|
const command = new HandleDepositCommand('1', 100, ChainType.KAVA, 'tx_123');
|
||||||
|
const mockWallet = createMockWallet(BigInt(1), 0);
|
||||||
|
|
||||||
|
mockDepositRepo.existsByTxHash.mockResolvedValue(false);
|
||||||
|
mockWalletRepo.getOrCreate.mockResolvedValue(mockWallet);
|
||||||
|
mockDepositRepo.save.mockImplementation((order: DepositOrder) => Promise.resolve(order));
|
||||||
|
mockWalletRepo.save.mockImplementation((wallet: WalletAccount) => Promise.resolve(wallet));
|
||||||
|
mockLedgerRepo.save.mockResolvedValue({});
|
||||||
|
|
||||||
|
await service.handleDeposit(command);
|
||||||
|
|
||||||
|
expect(mockDepositRepo.existsByTxHash).toHaveBeenCalledWith('tx_123');
|
||||||
|
expect(mockWalletRepo.getOrCreate).toHaveBeenCalledWith(BigInt(1));
|
||||||
|
expect(mockDepositRepo.save).toHaveBeenCalled();
|
||||||
|
expect(mockWalletRepo.save).toHaveBeenCalled();
|
||||||
|
expect(mockLedgerRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for duplicate transaction', async () => {
|
||||||
|
const command = new HandleDepositCommand('1', 100, ChainType.KAVA, 'tx_duplicate');
|
||||||
|
mockDepositRepo.existsByTxHash.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(service.handleDeposit(command)).rejects.toThrow('Duplicate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deductForPlanting', () => {
|
||||||
|
it('should deduct USDT for planting', async () => {
|
||||||
|
const command = new DeductForPlantingCommand('1', 50, 'order_123');
|
||||||
|
const mockWallet = createMockWallet(BigInt(1), 100);
|
||||||
|
|
||||||
|
mockWalletRepo.findByUserId.mockResolvedValue(mockWallet);
|
||||||
|
mockWalletRepo.save.mockImplementation((wallet: WalletAccount) => Promise.resolve(wallet));
|
||||||
|
mockLedgerRepo.save.mockResolvedValue({});
|
||||||
|
|
||||||
|
await service.deductForPlanting(command);
|
||||||
|
|
||||||
|
expect(mockWalletRepo.findByUserId).toHaveBeenCalledWith(BigInt(1));
|
||||||
|
expect(mockWalletRepo.save).toHaveBeenCalled();
|
||||||
|
expect(mockLedgerRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when wallet not found', async () => {
|
||||||
|
const command = new DeductForPlantingCommand('999', 50, 'order_123');
|
||||||
|
mockWalletRepo.findByUserId.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.deductForPlanting(command)).rejects.toThrow('not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addRewards', () => {
|
||||||
|
it('should add pending rewards', async () => {
|
||||||
|
const expireAt = new Date(Date.now() + 86400000);
|
||||||
|
const command = new AddRewardsCommand('1', 10, 5, expireAt, 'order_123');
|
||||||
|
const mockWallet = createMockWallet(BigInt(1), 100);
|
||||||
|
|
||||||
|
mockWalletRepo.getOrCreate.mockResolvedValue(mockWallet);
|
||||||
|
mockWalletRepo.save.mockImplementation((wallet: WalletAccount) => Promise.resolve(wallet));
|
||||||
|
mockLedgerRepo.save.mockResolvedValue({});
|
||||||
|
|
||||||
|
await service.addRewards(command);
|
||||||
|
|
||||||
|
expect(mockWalletRepo.getOrCreate).toHaveBeenCalledWith(BigInt(1));
|
||||||
|
expect(mockWalletRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMyWallet', () => {
|
||||||
|
it('should return wallet DTO', async () => {
|
||||||
|
const query = new GetMyWalletQuery('1');
|
||||||
|
const mockWallet = createMockWallet(BigInt(1), 100);
|
||||||
|
|
||||||
|
mockWalletRepo.getOrCreate.mockResolvedValue(mockWallet);
|
||||||
|
|
||||||
|
const result = await service.getMyWallet(query);
|
||||||
|
|
||||||
|
expect(result.userId).toBe('1');
|
||||||
|
expect(result.balances.usdt.available).toBe(100);
|
||||||
|
expect(result.status).toBe('ACTIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMyLedger', () => {
|
||||||
|
it('should return paginated ledger entries', async () => {
|
||||||
|
const query = new GetMyLedgerQuery('1', 1, 10);
|
||||||
|
|
||||||
|
mockLedgerRepo.findByUserId.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
totalPages: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getMyLedger(query);
|
||||||
|
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
expect(result.pageSize).toBe(10);
|
||||||
|
expect(result.data).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,354 @@
|
||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IWalletAccountRepository, WALLET_ACCOUNT_REPOSITORY,
|
||||||
|
ILedgerEntryRepository, LEDGER_ENTRY_REPOSITORY,
|
||||||
|
IDepositOrderRepository, DEPOSIT_ORDER_REPOSITORY,
|
||||||
|
ISettlementOrderRepository, SETTLEMENT_ORDER_REPOSITORY,
|
||||||
|
} from '@/domain/repositories';
|
||||||
|
import { LedgerEntry, DepositOrder, SettlementOrder } from '@/domain/aggregates';
|
||||||
|
import {
|
||||||
|
UserId, Money, Hashpower, LedgerEntryType, AssetType, ChainType, SettleCurrency,
|
||||||
|
} from '@/domain/value-objects';
|
||||||
|
import {
|
||||||
|
HandleDepositCommand, DeductForPlantingCommand, AddRewardsCommand,
|
||||||
|
ClaimRewardsCommand, SettleRewardsCommand,
|
||||||
|
} from '@/application/commands';
|
||||||
|
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
|
||||||
|
import { DuplicateTransactionError, WalletNotFoundError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export interface WalletDTO {
|
||||||
|
walletId: string;
|
||||||
|
userId: string;
|
||||||
|
balances: {
|
||||||
|
usdt: { available: number; frozen: number };
|
||||||
|
dst: { available: number; frozen: number };
|
||||||
|
bnb: { available: number; frozen: number };
|
||||||
|
og: { available: number; frozen: number };
|
||||||
|
rwad: { available: number; frozen: number };
|
||||||
|
};
|
||||||
|
hashpower: number;
|
||||||
|
rewards: {
|
||||||
|
pendingUsdt: number;
|
||||||
|
pendingHashpower: number;
|
||||||
|
pendingExpireAt: string | null;
|
||||||
|
settleableUsdt: number;
|
||||||
|
settleableHashpower: number;
|
||||||
|
settledTotalUsdt: number;
|
||||||
|
settledTotalHashpower: number;
|
||||||
|
expiredTotalUsdt: number;
|
||||||
|
expiredTotalHashpower: number;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LedgerEntryDTO {
|
||||||
|
id: string;
|
||||||
|
entryType: string;
|
||||||
|
amount: number;
|
||||||
|
assetType: string;
|
||||||
|
balanceAfter: number | null;
|
||||||
|
refOrderId: string | null;
|
||||||
|
refTxHash: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedLedgerDTO {
|
||||||
|
data: LedgerEntryDTO[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WalletApplicationService {
|
||||||
|
constructor(
|
||||||
|
@Inject(WALLET_ACCOUNT_REPOSITORY)
|
||||||
|
private readonly walletRepo: IWalletAccountRepository,
|
||||||
|
@Inject(LEDGER_ENTRY_REPOSITORY)
|
||||||
|
private readonly ledgerRepo: ILedgerEntryRepository,
|
||||||
|
@Inject(DEPOSIT_ORDER_REPOSITORY)
|
||||||
|
private readonly depositRepo: IDepositOrderRepository,
|
||||||
|
@Inject(SETTLEMENT_ORDER_REPOSITORY)
|
||||||
|
private readonly settlementRepo: ISettlementOrderRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// =============== Commands ===============
|
||||||
|
|
||||||
|
async handleDeposit(command: HandleDepositCommand): Promise<void> {
|
||||||
|
// Check for duplicate transaction
|
||||||
|
const exists = await this.depositRepo.existsByTxHash(command.txHash);
|
||||||
|
if (exists) {
|
||||||
|
throw new DuplicateTransactionError(command.txHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = BigInt(command.userId);
|
||||||
|
const amount = Money.USDT(command.amount);
|
||||||
|
|
||||||
|
// Get or create wallet
|
||||||
|
const wallet = await this.walletRepo.getOrCreate(userId);
|
||||||
|
|
||||||
|
// Create deposit order
|
||||||
|
const depositOrder = DepositOrder.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
chainType: command.chainType,
|
||||||
|
amount,
|
||||||
|
txHash: command.txHash,
|
||||||
|
});
|
||||||
|
depositOrder.confirm();
|
||||||
|
await this.depositRepo.save(depositOrder);
|
||||||
|
|
||||||
|
// Credit wallet
|
||||||
|
wallet.deposit(amount, command.chainType, command.txHash);
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
|
||||||
|
// Record ledger entry
|
||||||
|
const entryType = command.chainType === ChainType.KAVA
|
||||||
|
? LedgerEntryType.DEPOSIT_KAVA
|
||||||
|
: LedgerEntryType.DEPOSIT_BSC;
|
||||||
|
|
||||||
|
const ledgerEntry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
entryType,
|
||||||
|
amount,
|
||||||
|
balanceAfter: wallet.balances.usdt.available,
|
||||||
|
refTxHash: command.txHash,
|
||||||
|
memo: `Deposit from ${command.chainType}`,
|
||||||
|
});
|
||||||
|
await this.ledgerRepo.save(ledgerEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deductForPlanting(command: DeductForPlantingCommand): Promise<void> {
|
||||||
|
const userId = BigInt(command.userId);
|
||||||
|
const amount = Money.USDT(command.amount);
|
||||||
|
|
||||||
|
const wallet = await this.walletRepo.findByUserId(userId);
|
||||||
|
if (!wallet) {
|
||||||
|
throw new WalletNotFoundError(`userId: ${command.userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct from wallet
|
||||||
|
wallet.deduct(amount, 'Plant payment', command.orderId);
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
|
||||||
|
// Record ledger entry
|
||||||
|
const ledgerEntry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
entryType: LedgerEntryType.PLANT_PAYMENT,
|
||||||
|
amount: Money.signed(-command.amount, 'USDT'), // Negative for deduction
|
||||||
|
balanceAfter: wallet.balances.usdt.available,
|
||||||
|
refOrderId: command.orderId,
|
||||||
|
memo: 'Plant payment',
|
||||||
|
});
|
||||||
|
await this.ledgerRepo.save(ledgerEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRewards(command: AddRewardsCommand): Promise<void> {
|
||||||
|
const userId = BigInt(command.userId);
|
||||||
|
|
||||||
|
const wallet = await this.walletRepo.getOrCreate(userId);
|
||||||
|
|
||||||
|
const usdtAmount = Money.USDT(command.usdtAmount);
|
||||||
|
const hashpowerAmount = Hashpower.create(command.hashpowerAmount);
|
||||||
|
|
||||||
|
wallet.addPendingReward(usdtAmount, hashpowerAmount, command.expireAt, command.refOrderId);
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
|
||||||
|
// Record ledger entry for USDT reward
|
||||||
|
if (command.usdtAmount > 0) {
|
||||||
|
const usdtEntry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
entryType: LedgerEntryType.REWARD_PENDING,
|
||||||
|
amount: usdtAmount,
|
||||||
|
refOrderId: command.refOrderId,
|
||||||
|
memo: 'Pending reward added',
|
||||||
|
payloadJson: { expireAt: command.expireAt.toISOString() },
|
||||||
|
});
|
||||||
|
await this.ledgerRepo.save(usdtEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record ledger entry for hashpower reward
|
||||||
|
if (command.hashpowerAmount > 0) {
|
||||||
|
const hpEntry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
entryType: LedgerEntryType.REWARD_PENDING,
|
||||||
|
amount: Money.create(command.hashpowerAmount, 'HASHPOWER'),
|
||||||
|
refOrderId: command.refOrderId,
|
||||||
|
memo: 'Pending hashpower reward added',
|
||||||
|
payloadJson: { expireAt: command.expireAt.toISOString() },
|
||||||
|
});
|
||||||
|
await this.ledgerRepo.save(hpEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimRewards(command: ClaimRewardsCommand): Promise<void> {
|
||||||
|
const userId = BigInt(command.userId);
|
||||||
|
|
||||||
|
const wallet = await this.walletRepo.findByUserId(userId);
|
||||||
|
if (!wallet) {
|
||||||
|
throw new WalletNotFoundError(`userId: ${command.userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingUsdt = wallet.rewards.pendingUsdt.value;
|
||||||
|
const pendingHashpower = wallet.rewards.pendingHashpower.value;
|
||||||
|
|
||||||
|
wallet.movePendingToSettleable();
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
|
||||||
|
// Record ledger entries
|
||||||
|
if (pendingUsdt > 0) {
|
||||||
|
const usdtEntry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
entryType: LedgerEntryType.REWARD_TO_SETTLEABLE,
|
||||||
|
amount: Money.USDT(pendingUsdt),
|
||||||
|
memo: 'Reward claimed to settleable',
|
||||||
|
});
|
||||||
|
await this.ledgerRepo.save(usdtEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingHashpower > 0) {
|
||||||
|
const hpEntry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
entryType: LedgerEntryType.REWARD_TO_SETTLEABLE,
|
||||||
|
amount: Money.create(pendingHashpower, 'HASHPOWER'),
|
||||||
|
balanceAfter: Money.create(wallet.hashpower.value, 'HASHPOWER'),
|
||||||
|
memo: 'Hashpower reward claimed',
|
||||||
|
});
|
||||||
|
await this.ledgerRepo.save(hpEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async settleRewards(command: SettleRewardsCommand): Promise<string> {
|
||||||
|
const userId = BigInt(command.userId);
|
||||||
|
const usdtAmount = Money.USDT(command.usdtAmount);
|
||||||
|
|
||||||
|
const wallet = await this.walletRepo.findByUserId(userId);
|
||||||
|
if (!wallet) {
|
||||||
|
throw new WalletNotFoundError(`userId: ${command.userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create settlement order
|
||||||
|
const settlementOrder = SettlementOrder.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
usdtAmount,
|
||||||
|
settleCurrency: command.settleCurrency,
|
||||||
|
});
|
||||||
|
const savedOrder = await this.settlementRepo.save(settlementOrder);
|
||||||
|
|
||||||
|
// For now, simulate immediate settlement (in production, this would be async)
|
||||||
|
// Assume 1:1 exchange rate for simplicity
|
||||||
|
const receivedAmount = Money.create(command.usdtAmount, command.settleCurrency);
|
||||||
|
const swapTxHash = `swap_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
wallet.settleRewards(
|
||||||
|
usdtAmount,
|
||||||
|
command.settleCurrency,
|
||||||
|
receivedAmount,
|
||||||
|
savedOrder.id.toString(),
|
||||||
|
swapTxHash,
|
||||||
|
);
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
|
||||||
|
// Update settlement order
|
||||||
|
settlementOrder.complete(swapTxHash, receivedAmount);
|
||||||
|
await this.settlementRepo.save(settlementOrder);
|
||||||
|
|
||||||
|
// Record ledger entry
|
||||||
|
const ledgerEntry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(userId),
|
||||||
|
entryType: LedgerEntryType.REWARD_SETTLED,
|
||||||
|
amount: receivedAmount,
|
||||||
|
balanceAfter: wallet.balances[command.settleCurrency.toLowerCase() as keyof typeof wallet.balances]?.available,
|
||||||
|
refOrderId: savedOrder.id.toString(),
|
||||||
|
refTxHash: swapTxHash,
|
||||||
|
memo: `Settled ${usdtAmount.value} USDT to ${command.settleCurrency}`,
|
||||||
|
});
|
||||||
|
await this.ledgerRepo.save(ledgerEntry);
|
||||||
|
|
||||||
|
return savedOrder.id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Queries ===============
|
||||||
|
|
||||||
|
async getMyWallet(query: GetMyWalletQuery): Promise<WalletDTO> {
|
||||||
|
const userId = BigInt(query.userId);
|
||||||
|
const wallet = await this.walletRepo.getOrCreate(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
walletId: wallet.walletId.toString(),
|
||||||
|
userId: wallet.userId.toString(),
|
||||||
|
balances: {
|
||||||
|
usdt: {
|
||||||
|
available: wallet.balances.usdt.available.value,
|
||||||
|
frozen: wallet.balances.usdt.frozen.value,
|
||||||
|
},
|
||||||
|
dst: {
|
||||||
|
available: wallet.balances.dst.available.value,
|
||||||
|
frozen: wallet.balances.dst.frozen.value,
|
||||||
|
},
|
||||||
|
bnb: {
|
||||||
|
available: wallet.balances.bnb.available.value,
|
||||||
|
frozen: wallet.balances.bnb.frozen.value,
|
||||||
|
},
|
||||||
|
og: {
|
||||||
|
available: wallet.balances.og.available.value,
|
||||||
|
frozen: wallet.balances.og.frozen.value,
|
||||||
|
},
|
||||||
|
rwad: {
|
||||||
|
available: wallet.balances.rwad.available.value,
|
||||||
|
frozen: wallet.balances.rwad.frozen.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hashpower: wallet.hashpower.value,
|
||||||
|
rewards: {
|
||||||
|
pendingUsdt: wallet.rewards.pendingUsdt.value,
|
||||||
|
pendingHashpower: wallet.rewards.pendingHashpower.value,
|
||||||
|
pendingExpireAt: wallet.rewards.pendingExpireAt?.toISOString() ?? null,
|
||||||
|
settleableUsdt: wallet.rewards.settleableUsdt.value,
|
||||||
|
settleableHashpower: wallet.rewards.settleableHashpower.value,
|
||||||
|
settledTotalUsdt: wallet.rewards.settledTotalUsdt.value,
|
||||||
|
settledTotalHashpower: wallet.rewards.settledTotalHashpower.value,
|
||||||
|
expiredTotalUsdt: wallet.rewards.expiredTotalUsdt.value,
|
||||||
|
expiredTotalHashpower: wallet.rewards.expiredTotalHashpower.value,
|
||||||
|
},
|
||||||
|
status: wallet.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyLedger(query: GetMyLedgerQuery): Promise<PaginatedLedgerDTO> {
|
||||||
|
const userId = BigInt(query.userId);
|
||||||
|
|
||||||
|
const result = await this.ledgerRepo.findByUserId(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
entryType: query.entryType,
|
||||||
|
assetType: query.assetType,
|
||||||
|
startDate: query.startDate,
|
||||||
|
endDate: query.endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page: query.page ?? 1,
|
||||||
|
pageSize: query.pageSize ?? 20,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data.map(entry => ({
|
||||||
|
id: entry.id.toString(),
|
||||||
|
entryType: entry.entryType,
|
||||||
|
amount: entry.amount.value,
|
||||||
|
assetType: entry.assetType,
|
||||||
|
balanceAfter: entry.balanceAfter?.value ?? null,
|
||||||
|
refOrderId: entry.refOrderId,
|
||||||
|
refTxHash: entry.refTxHash,
|
||||||
|
memo: entry.memo,
|
||||||
|
createdAt: entry.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { DepositOrder } from './deposit-order.aggregate';
|
||||||
|
import { UserId, ChainType, DepositStatus, Money } from '@/domain/value-objects';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
jest.mock('@/shared/exceptions/domain.exception', () => ({
|
||||||
|
DomainError: class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('DepositOrder Aggregate', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a new deposit order', () => {
|
||||||
|
const order = DepositOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
chainType: ChainType.KAVA,
|
||||||
|
amount: Money.USDT(100),
|
||||||
|
txHash: 'tx_hash_123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order.userId.value).toBe(BigInt(1));
|
||||||
|
expect(order.chainType).toBe(ChainType.KAVA);
|
||||||
|
expect(order.amount.value).toBe(100);
|
||||||
|
expect(order.txHash).toBe('tx_hash_123');
|
||||||
|
expect(order.status).toBe(DepositStatus.PENDING);
|
||||||
|
expect(order.isPending).toBe(true);
|
||||||
|
expect(order.isConfirmed).toBe(false);
|
||||||
|
expect(order.confirmedAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('confirm', () => {
|
||||||
|
it('should confirm a pending deposit', () => {
|
||||||
|
const order = DepositOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
chainType: ChainType.BSC,
|
||||||
|
amount: Money.USDT(50),
|
||||||
|
txHash: 'tx_hash_456',
|
||||||
|
});
|
||||||
|
|
||||||
|
order.confirm();
|
||||||
|
|
||||||
|
expect(order.status).toBe(DepositStatus.CONFIRMED);
|
||||||
|
expect(order.isConfirmed).toBe(true);
|
||||||
|
expect(order.isPending).toBe(false);
|
||||||
|
expect(order.confirmedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when confirming non-pending deposit', () => {
|
||||||
|
const order = DepositOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
chainType: ChainType.KAVA,
|
||||||
|
amount: Money.USDT(100),
|
||||||
|
txHash: 'tx_hash_789',
|
||||||
|
});
|
||||||
|
|
||||||
|
order.confirm();
|
||||||
|
expect(() => order.confirm()).toThrow('Only pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fail', () => {
|
||||||
|
it('should mark pending deposit as failed', () => {
|
||||||
|
const order = DepositOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
chainType: ChainType.KAVA,
|
||||||
|
amount: Money.USDT(100),
|
||||||
|
txHash: 'tx_hash_fail',
|
||||||
|
});
|
||||||
|
|
||||||
|
order.fail();
|
||||||
|
|
||||||
|
expect(order.status).toBe(DepositStatus.FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when failing non-pending deposit', () => {
|
||||||
|
const order = DepositOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
chainType: ChainType.KAVA,
|
||||||
|
amount: Money.USDT(100),
|
||||||
|
txHash: 'tx_hash_test',
|
||||||
|
});
|
||||||
|
|
||||||
|
order.confirm();
|
||||||
|
expect(() => order.fail()).toThrow('Only pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconstruct', () => {
|
||||||
|
it('should reconstruct from database record', () => {
|
||||||
|
const order = DepositOrder.reconstruct({
|
||||||
|
id: BigInt(1),
|
||||||
|
userId: BigInt(100),
|
||||||
|
chainType: 'KAVA',
|
||||||
|
amount: new Decimal(200),
|
||||||
|
txHash: 'tx_reconstructed',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order.id).toBe(BigInt(1));
|
||||||
|
expect(order.status).toBe(DepositStatus.CONFIRMED);
|
||||||
|
expect(order.isConfirmed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { UserId, ChainType, DepositStatus, Money } from '@/domain/value-objects';
|
||||||
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export class DepositOrder {
|
||||||
|
private readonly _id: bigint;
|
||||||
|
private readonly _userId: UserId;
|
||||||
|
private readonly _chainType: ChainType;
|
||||||
|
private readonly _amount: Money;
|
||||||
|
private readonly _txHash: string;
|
||||||
|
private _status: DepositStatus;
|
||||||
|
private _confirmedAt: Date | null;
|
||||||
|
private readonly _createdAt: Date;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
id: bigint,
|
||||||
|
userId: UserId,
|
||||||
|
chainType: ChainType,
|
||||||
|
amount: Money,
|
||||||
|
txHash: string,
|
||||||
|
status: DepositStatus,
|
||||||
|
confirmedAt: Date | null,
|
||||||
|
createdAt: Date,
|
||||||
|
) {
|
||||||
|
this._id = id;
|
||||||
|
this._userId = userId;
|
||||||
|
this._chainType = chainType;
|
||||||
|
this._amount = amount;
|
||||||
|
this._txHash = txHash;
|
||||||
|
this._status = status;
|
||||||
|
this._confirmedAt = confirmedAt;
|
||||||
|
this._createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): bigint { return this._id; }
|
||||||
|
get userId(): UserId { return this._userId; }
|
||||||
|
get chainType(): ChainType { return this._chainType; }
|
||||||
|
get amount(): Money { return this._amount; }
|
||||||
|
get txHash(): string { return this._txHash; }
|
||||||
|
get status(): DepositStatus { return this._status; }
|
||||||
|
get confirmedAt(): Date | null { return this._confirmedAt; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
get isPending(): boolean { return this._status === DepositStatus.PENDING; }
|
||||||
|
get isConfirmed(): boolean { return this._status === DepositStatus.CONFIRMED; }
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
userId: UserId;
|
||||||
|
chainType: ChainType;
|
||||||
|
amount: Money;
|
||||||
|
txHash: string;
|
||||||
|
}): DepositOrder {
|
||||||
|
return new DepositOrder(
|
||||||
|
BigInt(0), // Will be set by database
|
||||||
|
params.userId,
|
||||||
|
params.chainType,
|
||||||
|
params.amount,
|
||||||
|
params.txHash,
|
||||||
|
DepositStatus.PENDING,
|
||||||
|
null,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static reconstruct(params: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
chainType: string;
|
||||||
|
amount: Decimal;
|
||||||
|
txHash: string;
|
||||||
|
status: string;
|
||||||
|
confirmedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): DepositOrder {
|
||||||
|
return new DepositOrder(
|
||||||
|
params.id,
|
||||||
|
UserId.create(params.userId),
|
||||||
|
params.chainType as ChainType,
|
||||||
|
Money.USDT(params.amount),
|
||||||
|
params.txHash,
|
||||||
|
params.status as DepositStatus,
|
||||||
|
params.confirmedAt,
|
||||||
|
params.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(): void {
|
||||||
|
if (this._status !== DepositStatus.PENDING) {
|
||||||
|
throw new DomainError('Only pending deposits can be confirmed');
|
||||||
|
}
|
||||||
|
this._status = DepositStatus.CONFIRMED;
|
||||||
|
this._confirmedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(): void {
|
||||||
|
if (this._status !== DepositStatus.PENDING) {
|
||||||
|
throw new DomainError('Only pending deposits can be marked as failed');
|
||||||
|
}
|
||||||
|
this._status = DepositStatus.FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './wallet-account.aggregate';
|
||||||
|
export * from './ledger-entry.aggregate';
|
||||||
|
export * from './deposit-order.aggregate';
|
||||||
|
export * from './settlement-order.aggregate';
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { LedgerEntry } from './ledger-entry.aggregate';
|
||||||
|
import { UserId, LedgerEntryType, Money } from '@/domain/value-objects';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
jest.mock('@/shared/exceptions/domain.exception', () => ({
|
||||||
|
DomainError: class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LedgerEntry Aggregate', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a new ledger entry', () => {
|
||||||
|
const entry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
entryType: LedgerEntryType.DEPOSIT_KAVA,
|
||||||
|
amount: Money.USDT(100),
|
||||||
|
balanceAfter: Money.USDT(100),
|
||||||
|
refTxHash: 'tx_hash_123',
|
||||||
|
memo: 'Test deposit',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entry.entryType).toBe(LedgerEntryType.DEPOSIT_KAVA);
|
||||||
|
expect(entry.amount.value).toBe(100);
|
||||||
|
expect(entry.assetType).toBe('USDT');
|
||||||
|
expect(entry.balanceAfter?.value).toBe(100);
|
||||||
|
expect(entry.refTxHash).toBe('tx_hash_123');
|
||||||
|
expect(entry.memo).toBe('Test deposit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create entry with optional fields as null', () => {
|
||||||
|
const entry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
entryType: LedgerEntryType.PLANT_PAYMENT,
|
||||||
|
amount: Money.signed(-50, 'USDT'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entry.balanceAfter).toBeNull();
|
||||||
|
expect(entry.refOrderId).toBeNull();
|
||||||
|
expect(entry.refTxHash).toBeNull();
|
||||||
|
expect(entry.memo).toBeNull();
|
||||||
|
expect(entry.payloadJson).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create entry with payload json', () => {
|
||||||
|
const payload = { key: 'value', number: 123 };
|
||||||
|
const entry = LedgerEntry.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
entryType: LedgerEntryType.REWARD_PENDING,
|
||||||
|
amount: Money.USDT(10),
|
||||||
|
payloadJson: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entry.payloadJson).toEqual(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconstruct', () => {
|
||||||
|
it('should reconstruct ledger entry from database record', () => {
|
||||||
|
const entry = LedgerEntry.reconstruct({
|
||||||
|
id: BigInt(1),
|
||||||
|
userId: BigInt(100),
|
||||||
|
entryType: 'DEPOSIT_KAVA',
|
||||||
|
amount: new Decimal(50),
|
||||||
|
assetType: 'USDT',
|
||||||
|
balanceAfter: new Decimal(150),
|
||||||
|
refOrderId: 'order_123',
|
||||||
|
refTxHash: 'tx_123',
|
||||||
|
memo: 'Reconstructed entry',
|
||||||
|
payloadJson: { test: true },
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entry.id).toBe(BigInt(1));
|
||||||
|
expect(entry.userId.value).toBe(BigInt(100));
|
||||||
|
expect(entry.entryType).toBe('DEPOSIT_KAVA');
|
||||||
|
expect(entry.amount.value).toBe(50);
|
||||||
|
expect(entry.balanceAfter?.value).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { UserId, AssetType, LedgerEntryType, Money } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export class LedgerEntry {
|
||||||
|
private readonly _id: bigint;
|
||||||
|
private readonly _userId: UserId;
|
||||||
|
private readonly _entryType: LedgerEntryType;
|
||||||
|
private readonly _amount: Money;
|
||||||
|
private readonly _balanceAfter: Money | null;
|
||||||
|
private readonly _refOrderId: string | null;
|
||||||
|
private readonly _refTxHash: string | null;
|
||||||
|
private readonly _memo: string | null;
|
||||||
|
private readonly _payloadJson: Record<string, unknown> | null;
|
||||||
|
private readonly _createdAt: Date;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
id: bigint,
|
||||||
|
userId: UserId,
|
||||||
|
entryType: LedgerEntryType,
|
||||||
|
amount: Money,
|
||||||
|
balanceAfter: Money | null,
|
||||||
|
refOrderId: string | null,
|
||||||
|
refTxHash: string | null,
|
||||||
|
memo: string | null,
|
||||||
|
payloadJson: Record<string, unknown> | null,
|
||||||
|
createdAt: Date,
|
||||||
|
) {
|
||||||
|
this._id = id;
|
||||||
|
this._userId = userId;
|
||||||
|
this._entryType = entryType;
|
||||||
|
this._amount = amount;
|
||||||
|
this._balanceAfter = balanceAfter;
|
||||||
|
this._refOrderId = refOrderId;
|
||||||
|
this._refTxHash = refTxHash;
|
||||||
|
this._memo = memo;
|
||||||
|
this._payloadJson = payloadJson;
|
||||||
|
this._createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): bigint { return this._id; }
|
||||||
|
get userId(): UserId { return this._userId; }
|
||||||
|
get entryType(): LedgerEntryType { return this._entryType; }
|
||||||
|
get amount(): Money { return this._amount; }
|
||||||
|
get assetType(): string { return this._amount.currency; }
|
||||||
|
get balanceAfter(): Money | null { return this._balanceAfter; }
|
||||||
|
get refOrderId(): string | null { return this._refOrderId; }
|
||||||
|
get refTxHash(): string | null { return this._refTxHash; }
|
||||||
|
get memo(): string | null { return this._memo; }
|
||||||
|
get payloadJson(): Record<string, unknown> | null { return this._payloadJson; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
userId: UserId;
|
||||||
|
entryType: LedgerEntryType;
|
||||||
|
amount: Money;
|
||||||
|
balanceAfter?: Money;
|
||||||
|
refOrderId?: string;
|
||||||
|
refTxHash?: string;
|
||||||
|
memo?: string;
|
||||||
|
payloadJson?: Record<string, unknown>;
|
||||||
|
}): LedgerEntry {
|
||||||
|
return new LedgerEntry(
|
||||||
|
BigInt(0), // Will be set by database
|
||||||
|
params.userId,
|
||||||
|
params.entryType,
|
||||||
|
params.amount,
|
||||||
|
params.balanceAfter ?? null,
|
||||||
|
params.refOrderId ?? null,
|
||||||
|
params.refTxHash ?? null,
|
||||||
|
params.memo ?? null,
|
||||||
|
params.payloadJson ?? null,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static reconstruct(params: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
entryType: string;
|
||||||
|
amount: Decimal;
|
||||||
|
assetType: string;
|
||||||
|
balanceAfter: Decimal | null;
|
||||||
|
refOrderId: string | null;
|
||||||
|
refTxHash: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
payloadJson: Record<string, unknown> | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): LedgerEntry {
|
||||||
|
return new LedgerEntry(
|
||||||
|
params.id,
|
||||||
|
UserId.create(params.userId),
|
||||||
|
params.entryType as LedgerEntryType,
|
||||||
|
Money.create(params.amount, params.assetType),
|
||||||
|
params.balanceAfter ? Money.create(params.balanceAfter, params.assetType) : null,
|
||||||
|
params.refOrderId,
|
||||||
|
params.refTxHash,
|
||||||
|
params.memo,
|
||||||
|
params.payloadJson,
|
||||||
|
params.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { SettlementOrder } from './settlement-order.aggregate';
|
||||||
|
import { UserId, SettlementStatus, SettleCurrency, Money } from '@/domain/value-objects';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
jest.mock('@/shared/exceptions/domain.exception', () => ({
|
||||||
|
DomainError: class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SettlementOrder Aggregate', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a new settlement order', () => {
|
||||||
|
const order = SettlementOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
usdtAmount: Money.USDT(100),
|
||||||
|
settleCurrency: SettleCurrency.BNB,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order.userId.value).toBe(BigInt(1));
|
||||||
|
expect(order.usdtAmount.value).toBe(100);
|
||||||
|
expect(order.settleCurrency).toBe(SettleCurrency.BNB);
|
||||||
|
expect(order.status).toBe(SettlementStatus.PENDING);
|
||||||
|
expect(order.isPending).toBe(true);
|
||||||
|
expect(order.isCompleted).toBe(false);
|
||||||
|
expect(order.swapTxHash).toBeNull();
|
||||||
|
expect(order.receivedAmount).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startSwapping', () => {
|
||||||
|
it('should start swapping process', () => {
|
||||||
|
const order = SettlementOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
usdtAmount: Money.USDT(100),
|
||||||
|
settleCurrency: SettleCurrency.OG,
|
||||||
|
});
|
||||||
|
|
||||||
|
order.startSwapping();
|
||||||
|
|
||||||
|
expect(order.status).toBe(SettlementStatus.SWAPPING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when not pending', () => {
|
||||||
|
const order = SettlementOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
usdtAmount: Money.USDT(100),
|
||||||
|
settleCurrency: SettleCurrency.BNB,
|
||||||
|
});
|
||||||
|
|
||||||
|
order.startSwapping();
|
||||||
|
expect(() => order.startSwapping()).toThrow('Only pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complete', () => {
|
||||||
|
it('should complete settlement from pending status', () => {
|
||||||
|
const order = SettlementOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
usdtAmount: Money.USDT(100),
|
||||||
|
settleCurrency: SettleCurrency.BNB,
|
||||||
|
});
|
||||||
|
|
||||||
|
order.complete('swap_tx_123', Money.BNB(0.05));
|
||||||
|
|
||||||
|
expect(order.status).toBe(SettlementStatus.COMPLETED);
|
||||||
|
expect(order.isCompleted).toBe(true);
|
||||||
|
expect(order.swapTxHash).toBe('swap_tx_123');
|
||||||
|
expect(order.receivedAmount?.value).toBe(0.05);
|
||||||
|
expect(order.settledAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete settlement from swapping status', () => {
|
||||||
|
const order = SettlementOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
usdtAmount: Money.USDT(50),
|
||||||
|
settleCurrency: SettleCurrency.OG,
|
||||||
|
});
|
||||||
|
|
||||||
|
order.startSwapping();
|
||||||
|
order.complete('swap_tx_456', Money.OG(10));
|
||||||
|
|
||||||
|
expect(order.status).toBe(SettlementStatus.COMPLETED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fail', () => {
|
||||||
|
it('should mark settlement as failed', () => {
|
||||||
|
const order = SettlementOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
usdtAmount: Money.USDT(100),
|
||||||
|
settleCurrency: SettleCurrency.DST,
|
||||||
|
});
|
||||||
|
|
||||||
|
order.fail();
|
||||||
|
|
||||||
|
expect(order.status).toBe(SettlementStatus.FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when completing already completed settlement', () => {
|
||||||
|
const order = SettlementOrder.create({
|
||||||
|
userId: UserId.create(1),
|
||||||
|
usdtAmount: Money.USDT(100),
|
||||||
|
settleCurrency: SettleCurrency.BNB,
|
||||||
|
});
|
||||||
|
|
||||||
|
order.complete('tx', Money.BNB(0.1));
|
||||||
|
expect(() => order.fail()).toThrow('Completed settlements');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconstruct', () => {
|
||||||
|
it('should reconstruct from database record', () => {
|
||||||
|
const order = SettlementOrder.reconstruct({
|
||||||
|
id: BigInt(1),
|
||||||
|
userId: BigInt(100),
|
||||||
|
usdtAmount: new Decimal(200),
|
||||||
|
settleCurrency: 'BNB',
|
||||||
|
swapTxHash: 'tx_swap',
|
||||||
|
receivedAmount: new Decimal(0.1),
|
||||||
|
status: 'COMPLETED',
|
||||||
|
settledAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order.id).toBe(BigInt(1));
|
||||||
|
expect(order.status).toBe(SettlementStatus.COMPLETED);
|
||||||
|
expect(order.isCompleted).toBe(true);
|
||||||
|
expect(order.receivedAmount?.value).toBe(0.1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { UserId, SettlementStatus, SettleCurrency, Money } from '@/domain/value-objects';
|
||||||
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export class SettlementOrder {
|
||||||
|
private readonly _id: bigint;
|
||||||
|
private readonly _userId: UserId;
|
||||||
|
private readonly _usdtAmount: Money;
|
||||||
|
private readonly _settleCurrency: SettleCurrency;
|
||||||
|
private _swapTxHash: string | null;
|
||||||
|
private _receivedAmount: Money | null;
|
||||||
|
private _status: SettlementStatus;
|
||||||
|
private _settledAt: Date | null;
|
||||||
|
private readonly _createdAt: Date;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
id: bigint,
|
||||||
|
userId: UserId,
|
||||||
|
usdtAmount: Money,
|
||||||
|
settleCurrency: SettleCurrency,
|
||||||
|
swapTxHash: string | null,
|
||||||
|
receivedAmount: Money | null,
|
||||||
|
status: SettlementStatus,
|
||||||
|
settledAt: Date | null,
|
||||||
|
createdAt: Date,
|
||||||
|
) {
|
||||||
|
this._id = id;
|
||||||
|
this._userId = userId;
|
||||||
|
this._usdtAmount = usdtAmount;
|
||||||
|
this._settleCurrency = settleCurrency;
|
||||||
|
this._swapTxHash = swapTxHash;
|
||||||
|
this._receivedAmount = receivedAmount;
|
||||||
|
this._status = status;
|
||||||
|
this._settledAt = settledAt;
|
||||||
|
this._createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): bigint { return this._id; }
|
||||||
|
get userId(): UserId { return this._userId; }
|
||||||
|
get usdtAmount(): Money { return this._usdtAmount; }
|
||||||
|
get settleCurrency(): SettleCurrency { return this._settleCurrency; }
|
||||||
|
get swapTxHash(): string | null { return this._swapTxHash; }
|
||||||
|
get receivedAmount(): Money | null { return this._receivedAmount; }
|
||||||
|
get status(): SettlementStatus { return this._status; }
|
||||||
|
get settledAt(): Date | null { return this._settledAt; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
get isPending(): boolean { return this._status === SettlementStatus.PENDING; }
|
||||||
|
get isCompleted(): boolean { return this._status === SettlementStatus.COMPLETED; }
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
userId: UserId;
|
||||||
|
usdtAmount: Money;
|
||||||
|
settleCurrency: SettleCurrency;
|
||||||
|
}): SettlementOrder {
|
||||||
|
return new SettlementOrder(
|
||||||
|
BigInt(0), // Will be set by database
|
||||||
|
params.userId,
|
||||||
|
params.usdtAmount,
|
||||||
|
params.settleCurrency,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
SettlementStatus.PENDING,
|
||||||
|
null,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static reconstruct(params: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
usdtAmount: Decimal;
|
||||||
|
settleCurrency: string;
|
||||||
|
swapTxHash: string | null;
|
||||||
|
receivedAmount: Decimal | null;
|
||||||
|
status: string;
|
||||||
|
settledAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): SettlementOrder {
|
||||||
|
return new SettlementOrder(
|
||||||
|
params.id,
|
||||||
|
UserId.create(params.userId),
|
||||||
|
Money.USDT(params.usdtAmount),
|
||||||
|
params.settleCurrency as SettleCurrency,
|
||||||
|
params.swapTxHash,
|
||||||
|
params.receivedAmount ? Money.create(params.receivedAmount, params.settleCurrency) : null,
|
||||||
|
params.status as SettlementStatus,
|
||||||
|
params.settledAt,
|
||||||
|
params.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
startSwapping(): void {
|
||||||
|
if (this._status !== SettlementStatus.PENDING) {
|
||||||
|
throw new DomainError('Only pending settlements can start swapping');
|
||||||
|
}
|
||||||
|
this._status = SettlementStatus.SWAPPING;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(swapTxHash: string, receivedAmount: Money): void {
|
||||||
|
if (this._status !== SettlementStatus.SWAPPING && this._status !== SettlementStatus.PENDING) {
|
||||||
|
throw new DomainError('Settlement cannot be completed in current status');
|
||||||
|
}
|
||||||
|
this._status = SettlementStatus.COMPLETED;
|
||||||
|
this._swapTxHash = swapTxHash;
|
||||||
|
this._receivedAmount = receivedAmount;
|
||||||
|
this._settledAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(): void {
|
||||||
|
if (this._status === SettlementStatus.COMPLETED) {
|
||||||
|
throw new DomainError('Completed settlements cannot be marked as failed');
|
||||||
|
}
|
||||||
|
this._status = SettlementStatus.FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { WalletAccount } from './wallet-account.aggregate';
|
||||||
|
import { UserId, Money, Hashpower, WalletStatus } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
// Mock exceptions
|
||||||
|
jest.mock('@/shared/exceptions/domain.exception', () => ({
|
||||||
|
DomainError: class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
InsufficientBalanceError: class InsufficientBalanceError extends Error {
|
||||||
|
constructor(assetType: string, required: string, available: string) {
|
||||||
|
super(`Insufficient ${assetType} balance: required ${required}, available ${available}`);
|
||||||
|
this.name = 'InsufficientBalanceError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
WalletFrozenError: class WalletFrozenError extends Error {
|
||||||
|
constructor(walletId: string) {
|
||||||
|
super(`Wallet ${walletId} is frozen`);
|
||||||
|
this.name = 'WalletFrozenError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('WalletAccount Aggregate', () => {
|
||||||
|
let wallet: WalletAccount;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wallet = WalletAccount.createNew(UserId.create(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNew', () => {
|
||||||
|
it('should create a new wallet with zero balances', () => {
|
||||||
|
expect(wallet.status).toBe(WalletStatus.ACTIVE);
|
||||||
|
expect(wallet.balances.usdt.available.isZero()).toBe(true);
|
||||||
|
expect(wallet.hashpower.isZero()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deposit', () => {
|
||||||
|
it('should increase USDT balance on deposit', () => {
|
||||||
|
const amount = Money.USDT(100);
|
||||||
|
wallet.deposit(amount, 'KAVA', 'tx_hash_123');
|
||||||
|
|
||||||
|
expect(wallet.balances.usdt.available.value).toBe(100);
|
||||||
|
expect(wallet.domainEvents.length).toBe(1);
|
||||||
|
expect(wallet.domainEvents[0].eventType).toBe('DepositCompletedEvent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when wallet is frozen', () => {
|
||||||
|
wallet.freezeWallet();
|
||||||
|
const amount = Money.USDT(100);
|
||||||
|
|
||||||
|
expect(() => wallet.deposit(amount, 'KAVA', 'tx_hash_123')).toThrow('Wallet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deduct', () => {
|
||||||
|
it('should decrease USDT balance on deduct', () => {
|
||||||
|
wallet.deposit(Money.USDT(100), 'KAVA', 'tx_hash_123');
|
||||||
|
wallet.clearDomainEvents();
|
||||||
|
|
||||||
|
wallet.deduct(Money.USDT(30), 'Plant payment', 'order_123');
|
||||||
|
|
||||||
|
expect(wallet.balances.usdt.available.value).toBe(70);
|
||||||
|
expect(wallet.domainEvents.length).toBe(1);
|
||||||
|
expect(wallet.domainEvents[0].eventType).toBe('BalanceDeductedEvent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when insufficient balance', () => {
|
||||||
|
wallet.deposit(Money.USDT(50), 'KAVA', 'tx_hash_123');
|
||||||
|
|
||||||
|
expect(() => wallet.deduct(Money.USDT(100), 'Payment', 'order_123')).toThrow('Insufficient');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rewards', () => {
|
||||||
|
it('should add pending rewards', () => {
|
||||||
|
const usdt = Money.USDT(10);
|
||||||
|
const hashpower = Hashpower.create(5);
|
||||||
|
const expireAt = new Date(Date.now() + 86400000);
|
||||||
|
|
||||||
|
wallet.addPendingReward(usdt, hashpower, expireAt, 'order_123');
|
||||||
|
|
||||||
|
expect(wallet.rewards.pendingUsdt.value).toBe(10);
|
||||||
|
expect(wallet.rewards.pendingHashpower.value).toBe(5);
|
||||||
|
expect(wallet.rewards.pendingExpireAt).toEqual(expireAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move pending rewards to settleable', () => {
|
||||||
|
const usdt = Money.USDT(10);
|
||||||
|
const hashpower = Hashpower.create(5);
|
||||||
|
const expireAt = new Date(Date.now() + 86400000);
|
||||||
|
|
||||||
|
wallet.addPendingReward(usdt, hashpower, expireAt, 'order_123');
|
||||||
|
wallet.clearDomainEvents();
|
||||||
|
wallet.movePendingToSettleable();
|
||||||
|
|
||||||
|
expect(wallet.rewards.pendingUsdt.isZero()).toBe(true);
|
||||||
|
expect(wallet.rewards.pendingHashpower.isZero()).toBe(true);
|
||||||
|
expect(wallet.rewards.settleableUsdt.value).toBe(10);
|
||||||
|
expect(wallet.rewards.settleableHashpower.value).toBe(5);
|
||||||
|
expect(wallet.hashpower.value).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when no pending rewards to move', () => {
|
||||||
|
expect(() => wallet.movePendingToSettleable()).toThrow('No pending rewards');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freeze/unfreeze wallet', () => {
|
||||||
|
it('should freeze wallet', () => {
|
||||||
|
wallet.freezeWallet();
|
||||||
|
expect(wallet.status).toBe(WalletStatus.FROZEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unfreeze wallet', () => {
|
||||||
|
wallet.freezeWallet();
|
||||||
|
wallet.unfreezeWallet();
|
||||||
|
expect(wallet.status).toBe(WalletStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when freezing already frozen wallet', () => {
|
||||||
|
wallet.freezeWallet();
|
||||||
|
expect(() => wallet.freezeWallet()).toThrow('already frozen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import {
|
||||||
|
UserId, WalletId, AssetType, WalletStatus, Money, Balance, Hashpower,
|
||||||
|
} from '@/domain/value-objects';
|
||||||
|
import {
|
||||||
|
DomainEvent, DepositCompletedEvent, BalanceDeductedEvent,
|
||||||
|
RewardAddedEvent, RewardMovedToSettleableEvent, RewardExpiredEvent,
|
||||||
|
SettlementCompletedEvent,
|
||||||
|
} from '@/domain/events';
|
||||||
|
import {
|
||||||
|
DomainError, InsufficientBalanceError, WalletFrozenError,
|
||||||
|
} from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export interface WalletBalances {
|
||||||
|
usdt: Balance;
|
||||||
|
dst: Balance;
|
||||||
|
bnb: Balance;
|
||||||
|
og: Balance;
|
||||||
|
rwad: Balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletRewards {
|
||||||
|
pendingUsdt: Money;
|
||||||
|
pendingHashpower: Hashpower;
|
||||||
|
pendingExpireAt: Date | null;
|
||||||
|
settleableUsdt: Money;
|
||||||
|
settleableHashpower: Hashpower;
|
||||||
|
settledTotalUsdt: Money;
|
||||||
|
settledTotalHashpower: Hashpower;
|
||||||
|
expiredTotalUsdt: Money;
|
||||||
|
expiredTotalHashpower: Hashpower;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletAccount {
|
||||||
|
private readonly _walletId: WalletId;
|
||||||
|
private readonly _userId: UserId;
|
||||||
|
private _balances: WalletBalances;
|
||||||
|
private _hashpower: Hashpower;
|
||||||
|
private _rewards: WalletRewards;
|
||||||
|
private _status: WalletStatus;
|
||||||
|
private readonly _createdAt: Date;
|
||||||
|
private _updatedAt: Date;
|
||||||
|
private _domainEvents: DomainEvent[] = [];
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
walletId: WalletId,
|
||||||
|
userId: UserId,
|
||||||
|
balances: WalletBalances,
|
||||||
|
hashpower: Hashpower,
|
||||||
|
rewards: WalletRewards,
|
||||||
|
status: WalletStatus,
|
||||||
|
createdAt: Date,
|
||||||
|
updatedAt: Date,
|
||||||
|
) {
|
||||||
|
this._walletId = walletId;
|
||||||
|
this._userId = userId;
|
||||||
|
this._balances = balances;
|
||||||
|
this._hashpower = hashpower;
|
||||||
|
this._rewards = rewards;
|
||||||
|
this._status = status;
|
||||||
|
this._createdAt = createdAt;
|
||||||
|
this._updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get walletId(): WalletId { return this._walletId; }
|
||||||
|
get userId(): UserId { return this._userId; }
|
||||||
|
get balances(): WalletBalances { return this._balances; }
|
||||||
|
get hashpower(): Hashpower { return this._hashpower; }
|
||||||
|
get rewards(): WalletRewards { return this._rewards; }
|
||||||
|
get status(): WalletStatus { return this._status; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
get updatedAt(): Date { return this._updatedAt; }
|
||||||
|
get isActive(): boolean { return this._status === WalletStatus.ACTIVE; }
|
||||||
|
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
||||||
|
|
||||||
|
static createNew(userId: UserId): WalletAccount {
|
||||||
|
const now = new Date();
|
||||||
|
return new WalletAccount(
|
||||||
|
WalletId.create(0), // Will be set by database
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
usdt: Balance.zero('USDT'),
|
||||||
|
dst: Balance.zero('DST'),
|
||||||
|
bnb: Balance.zero('BNB'),
|
||||||
|
og: Balance.zero('OG'),
|
||||||
|
rwad: Balance.zero('RWAD'),
|
||||||
|
},
|
||||||
|
Hashpower.zero(),
|
||||||
|
{
|
||||||
|
pendingUsdt: Money.zero('USDT'),
|
||||||
|
pendingHashpower: Hashpower.zero(),
|
||||||
|
pendingExpireAt: null,
|
||||||
|
settleableUsdt: Money.zero('USDT'),
|
||||||
|
settleableHashpower: Hashpower.zero(),
|
||||||
|
settledTotalUsdt: Money.zero('USDT'),
|
||||||
|
settledTotalHashpower: Hashpower.zero(),
|
||||||
|
expiredTotalUsdt: Money.zero('USDT'),
|
||||||
|
expiredTotalHashpower: Hashpower.zero(),
|
||||||
|
},
|
||||||
|
WalletStatus.ACTIVE,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static reconstruct(params: {
|
||||||
|
walletId: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
usdtAvailable: Decimal;
|
||||||
|
usdtFrozen: Decimal;
|
||||||
|
dstAvailable: Decimal;
|
||||||
|
dstFrozen: Decimal;
|
||||||
|
bnbAvailable: Decimal;
|
||||||
|
bnbFrozen: Decimal;
|
||||||
|
ogAvailable: Decimal;
|
||||||
|
ogFrozen: Decimal;
|
||||||
|
rwadAvailable: Decimal;
|
||||||
|
rwadFrozen: Decimal;
|
||||||
|
hashpower: Decimal;
|
||||||
|
pendingUsdt: Decimal;
|
||||||
|
pendingHashpower: Decimal;
|
||||||
|
pendingExpireAt: Date | null;
|
||||||
|
settleableUsdt: Decimal;
|
||||||
|
settleableHashpower: Decimal;
|
||||||
|
settledTotalUsdt: Decimal;
|
||||||
|
settledTotalHashpower: Decimal;
|
||||||
|
expiredTotalUsdt: Decimal;
|
||||||
|
expiredTotalHashpower: Decimal;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): WalletAccount {
|
||||||
|
return new WalletAccount(
|
||||||
|
WalletId.create(params.walletId),
|
||||||
|
UserId.create(params.userId),
|
||||||
|
{
|
||||||
|
usdt: Balance.create(Money.USDT(params.usdtAvailable), Money.USDT(params.usdtFrozen)),
|
||||||
|
dst: Balance.create(Money.DST(params.dstAvailable), Money.DST(params.dstFrozen)),
|
||||||
|
bnb: Balance.create(Money.BNB(params.bnbAvailable), Money.BNB(params.bnbFrozen)),
|
||||||
|
og: Balance.create(Money.OG(params.ogAvailable), Money.OG(params.ogFrozen)),
|
||||||
|
rwad: Balance.create(Money.RWAD(params.rwadAvailable), Money.RWAD(params.rwadFrozen)),
|
||||||
|
},
|
||||||
|
Hashpower.create(params.hashpower),
|
||||||
|
{
|
||||||
|
pendingUsdt: Money.USDT(params.pendingUsdt),
|
||||||
|
pendingHashpower: Hashpower.create(params.pendingHashpower),
|
||||||
|
pendingExpireAt: params.pendingExpireAt,
|
||||||
|
settleableUsdt: Money.USDT(params.settleableUsdt),
|
||||||
|
settleableHashpower: Hashpower.create(params.settleableHashpower),
|
||||||
|
settledTotalUsdt: Money.USDT(params.settledTotalUsdt),
|
||||||
|
settledTotalHashpower: Hashpower.create(params.settledTotalHashpower),
|
||||||
|
expiredTotalUsdt: Money.USDT(params.expiredTotalUsdt),
|
||||||
|
expiredTotalHashpower: Hashpower.create(params.expiredTotalHashpower),
|
||||||
|
},
|
||||||
|
params.status as WalletStatus,
|
||||||
|
params.createdAt,
|
||||||
|
params.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值入账
|
||||||
|
deposit(amount: Money, chainType: string, txHash: string): void {
|
||||||
|
this.ensureActive();
|
||||||
|
|
||||||
|
const balance = this.getBalance(amount.currency as AssetType);
|
||||||
|
const newBalance = balance.add(amount);
|
||||||
|
this.setBalance(amount.currency as AssetType, newBalance);
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this.addDomainEvent(new DepositCompletedEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
walletId: this._walletId.toString(),
|
||||||
|
amount: amount.value.toString(),
|
||||||
|
assetType: amount.currency,
|
||||||
|
chainType,
|
||||||
|
txHash,
|
||||||
|
balanceAfter: newBalance.available.value.toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣款 (如认种支付)
|
||||||
|
deduct(amount: Money, reason: string, refOrderId?: string): void {
|
||||||
|
this.ensureActive();
|
||||||
|
|
||||||
|
const balance = this.getBalance(amount.currency as AssetType);
|
||||||
|
if (balance.available.lessThan(amount)) {
|
||||||
|
throw new InsufficientBalanceError(
|
||||||
|
amount.currency,
|
||||||
|
amount.value.toString(),
|
||||||
|
balance.available.value.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBalance = balance.deduct(amount);
|
||||||
|
this.setBalance(amount.currency as AssetType, newBalance);
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this.addDomainEvent(new BalanceDeductedEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
walletId: this._walletId.toString(),
|
||||||
|
amount: amount.value.toString(),
|
||||||
|
assetType: amount.currency,
|
||||||
|
reason,
|
||||||
|
refOrderId,
|
||||||
|
balanceAfter: newBalance.available.value.toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冻结资金
|
||||||
|
freeze(amount: Money): void {
|
||||||
|
this.ensureActive();
|
||||||
|
|
||||||
|
const balance = this.getBalance(amount.currency as AssetType);
|
||||||
|
const newBalance = balance.freeze(amount);
|
||||||
|
this.setBalance(amount.currency as AssetType, newBalance);
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解冻资金
|
||||||
|
unfreeze(amount: Money): void {
|
||||||
|
this.ensureActive();
|
||||||
|
|
||||||
|
const balance = this.getBalance(amount.currency as AssetType);
|
||||||
|
const newBalance = balance.unfreeze(amount);
|
||||||
|
this.setBalance(amount.currency as AssetType, newBalance);
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加待领取奖励
|
||||||
|
addPendingReward(usdtAmount: Money, hashpowerAmount: Hashpower, expireAt: Date, refOrderId?: string): void {
|
||||||
|
this.ensureActive();
|
||||||
|
|
||||||
|
this._rewards = {
|
||||||
|
...this._rewards,
|
||||||
|
pendingUsdt: this._rewards.pendingUsdt.add(usdtAmount),
|
||||||
|
pendingHashpower: this._rewards.pendingHashpower.add(hashpowerAmount),
|
||||||
|
pendingExpireAt: expireAt,
|
||||||
|
};
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this.addDomainEvent(new RewardAddedEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
walletId: this._walletId.toString(),
|
||||||
|
usdtAmount: usdtAmount.value.toString(),
|
||||||
|
hashpowerAmount: hashpowerAmount.value.toString(),
|
||||||
|
expireAt: expireAt.toISOString(),
|
||||||
|
refOrderId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 待领取 -> 可结算
|
||||||
|
movePendingToSettleable(): void {
|
||||||
|
this.ensureActive();
|
||||||
|
|
||||||
|
if (this._rewards.pendingUsdt.isZero() && this._rewards.pendingHashpower.isZero()) {
|
||||||
|
throw new DomainError('No pending rewards to move');
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedUsdt = this._rewards.pendingUsdt;
|
||||||
|
const movedHashpower = this._rewards.pendingHashpower;
|
||||||
|
|
||||||
|
this._rewards = {
|
||||||
|
...this._rewards,
|
||||||
|
pendingUsdt: Money.zero('USDT'),
|
||||||
|
pendingHashpower: Hashpower.zero(),
|
||||||
|
pendingExpireAt: null,
|
||||||
|
settleableUsdt: this._rewards.settleableUsdt.add(movedUsdt),
|
||||||
|
settleableHashpower: this._rewards.settleableHashpower.add(movedHashpower),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 增加算力
|
||||||
|
this._hashpower = this._hashpower.add(movedHashpower);
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this.addDomainEvent(new RewardMovedToSettleableEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
walletId: this._walletId.toString(),
|
||||||
|
usdtAmount: movedUsdt.value.toString(),
|
||||||
|
hashpowerAmount: movedHashpower.value.toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 奖励过期
|
||||||
|
expirePendingRewards(): void {
|
||||||
|
if (this._rewards.pendingUsdt.isZero() && this._rewards.pendingHashpower.isZero()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredUsdt = this._rewards.pendingUsdt;
|
||||||
|
const expiredHashpower = this._rewards.pendingHashpower;
|
||||||
|
|
||||||
|
this._rewards = {
|
||||||
|
...this._rewards,
|
||||||
|
pendingUsdt: Money.zero('USDT'),
|
||||||
|
pendingHashpower: Hashpower.zero(),
|
||||||
|
pendingExpireAt: null,
|
||||||
|
expiredTotalUsdt: this._rewards.expiredTotalUsdt.add(expiredUsdt),
|
||||||
|
expiredTotalHashpower: this._rewards.expiredTotalHashpower.add(expiredHashpower),
|
||||||
|
};
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this.addDomainEvent(new RewardExpiredEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
walletId: this._walletId.toString(),
|
||||||
|
usdtAmount: expiredUsdt.value.toString(),
|
||||||
|
hashpowerAmount: expiredHashpower.value.toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结算奖励
|
||||||
|
settleRewards(usdtAmount: Money, settleCurrency: string, receivedAmount: Money, settlementOrderId: string, swapTxHash?: string): void {
|
||||||
|
this.ensureActive();
|
||||||
|
|
||||||
|
if (this._rewards.settleableUsdt.lessThan(usdtAmount)) {
|
||||||
|
throw new InsufficientBalanceError(
|
||||||
|
'USDT (settleable)',
|
||||||
|
usdtAmount.value.toString(),
|
||||||
|
this._rewards.settleableUsdt.value.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣减可结算USDT
|
||||||
|
this._rewards = {
|
||||||
|
...this._rewards,
|
||||||
|
settleableUsdt: this._rewards.settleableUsdt.subtract(usdtAmount),
|
||||||
|
settledTotalUsdt: this._rewards.settledTotalUsdt.add(usdtAmount),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 增加目标币种余额
|
||||||
|
const targetBalance = this.getBalance(settleCurrency as AssetType);
|
||||||
|
this.setBalance(settleCurrency as AssetType, targetBalance.add(receivedAmount));
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this.addDomainEvent(new SettlementCompletedEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
walletId: this._walletId.toString(),
|
||||||
|
settlementOrderId,
|
||||||
|
usdtAmount: usdtAmount.value.toString(),
|
||||||
|
settleCurrency,
|
||||||
|
receivedAmount: receivedAmount.value.toString(),
|
||||||
|
swapTxHash,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冻结钱包
|
||||||
|
freezeWallet(): void {
|
||||||
|
if (this._status === WalletStatus.FROZEN) {
|
||||||
|
throw new DomainError('Wallet is already frozen');
|
||||||
|
}
|
||||||
|
this._status = WalletStatus.FROZEN;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解冻钱包
|
||||||
|
unfreezeWallet(): void {
|
||||||
|
if (this._status !== WalletStatus.FROZEN) {
|
||||||
|
throw new DomainError('Wallet is not frozen');
|
||||||
|
}
|
||||||
|
this._status = WalletStatus.ACTIVE;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBalance(assetType: AssetType): Balance {
|
||||||
|
switch (assetType) {
|
||||||
|
case AssetType.USDT: return this._balances.usdt;
|
||||||
|
case AssetType.DST: return this._balances.dst;
|
||||||
|
case AssetType.BNB: return this._balances.bnb;
|
||||||
|
case AssetType.OG: return this._balances.og;
|
||||||
|
case AssetType.RWAD: return this._balances.rwad;
|
||||||
|
default: throw new DomainError(`Unknown asset type: ${assetType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBalance(assetType: AssetType, balance: Balance): void {
|
||||||
|
switch (assetType) {
|
||||||
|
case AssetType.USDT: this._balances.usdt = balance; break;
|
||||||
|
case AssetType.DST: this._balances.dst = balance; break;
|
||||||
|
case AssetType.BNB: this._balances.bnb = balance; break;
|
||||||
|
case AssetType.OG: this._balances.og = balance; break;
|
||||||
|
case AssetType.RWAD: this._balances.rwad = balance; break;
|
||||||
|
default: throw new DomainError(`Unknown asset type: ${assetType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureActive(): void {
|
||||||
|
if (this._status !== WalletStatus.ACTIVE) {
|
||||||
|
throw new WalletFrozenError(this._walletId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addDomainEvent(event: DomainEvent): void {
|
||||||
|
this._domainEvents.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDomainEvents(): void {
|
||||||
|
this._domainEvents = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
export interface BalanceDeductedPayload {
|
||||||
|
userId: string;
|
||||||
|
walletId: string;
|
||||||
|
amount: string;
|
||||||
|
assetType: string;
|
||||||
|
reason: string;
|
||||||
|
refOrderId?: string;
|
||||||
|
balanceAfter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BalanceDeductedEvent extends DomainEvent {
|
||||||
|
constructor(private readonly payload: BalanceDeductedPayload) {
|
||||||
|
super({
|
||||||
|
aggregateId: payload.walletId,
|
||||||
|
aggregateType: 'WalletAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPayload(): BalanceDeductedPayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
export interface DepositCompletedPayload {
|
||||||
|
userId: string;
|
||||||
|
walletId: string;
|
||||||
|
amount: string;
|
||||||
|
assetType: string;
|
||||||
|
chainType: string;
|
||||||
|
txHash: string;
|
||||||
|
balanceAfter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DepositCompletedEvent extends DomainEvent {
|
||||||
|
constructor(private readonly payload: DepositCompletedPayload) {
|
||||||
|
super({
|
||||||
|
aggregateId: payload.walletId,
|
||||||
|
aggregateType: 'WalletAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPayload(): DepositCompletedPayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export abstract class DomainEvent {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly eventType: string;
|
||||||
|
readonly aggregateId: string;
|
||||||
|
readonly aggregateType: string;
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
readonly version: number;
|
||||||
|
|
||||||
|
constructor(params: {
|
||||||
|
aggregateId: string;
|
||||||
|
aggregateType: string;
|
||||||
|
version?: number;
|
||||||
|
}) {
|
||||||
|
this.eventId = uuidv4();
|
||||||
|
this.eventType = this.constructor.name;
|
||||||
|
this.aggregateId = params.aggregateId;
|
||||||
|
this.aggregateType = params.aggregateType;
|
||||||
|
this.occurredAt = new Date();
|
||||||
|
this.version = params.version ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getPayload(): unknown;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export * from './domain-event.base';
|
||||||
|
export * from './deposit-completed.event';
|
||||||
|
export * from './withdrawal-requested.event';
|
||||||
|
export * from './reward-added.event';
|
||||||
|
export * from './reward-moved-to-settleable.event';
|
||||||
|
export * from './reward-expired.event';
|
||||||
|
export * from './settlement-completed.event';
|
||||||
|
export * from './balance-deducted.event';
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
export interface RewardAddedPayload {
|
||||||
|
userId: string;
|
||||||
|
walletId: string;
|
||||||
|
usdtAmount: string;
|
||||||
|
hashpowerAmount: string;
|
||||||
|
expireAt: string;
|
||||||
|
refOrderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RewardAddedEvent extends DomainEvent {
|
||||||
|
constructor(private readonly payload: RewardAddedPayload) {
|
||||||
|
super({
|
||||||
|
aggregateId: payload.walletId,
|
||||||
|
aggregateType: 'WalletAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPayload(): RewardAddedPayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
export interface RewardExpiredPayload {
|
||||||
|
userId: string;
|
||||||
|
walletId: string;
|
||||||
|
usdtAmount: string;
|
||||||
|
hashpowerAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RewardExpiredEvent extends DomainEvent {
|
||||||
|
constructor(private readonly payload: RewardExpiredPayload) {
|
||||||
|
super({
|
||||||
|
aggregateId: payload.walletId,
|
||||||
|
aggregateType: 'WalletAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPayload(): RewardExpiredPayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
export interface RewardMovedToSettleablePayload {
|
||||||
|
userId: string;
|
||||||
|
walletId: string;
|
||||||
|
usdtAmount: string;
|
||||||
|
hashpowerAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RewardMovedToSettleableEvent extends DomainEvent {
|
||||||
|
constructor(private readonly payload: RewardMovedToSettleablePayload) {
|
||||||
|
super({
|
||||||
|
aggregateId: payload.walletId,
|
||||||
|
aggregateType: 'WalletAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPayload(): RewardMovedToSettleablePayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
export interface SettlementCompletedPayload {
|
||||||
|
userId: string;
|
||||||
|
walletId: string;
|
||||||
|
settlementOrderId: string;
|
||||||
|
usdtAmount: string;
|
||||||
|
settleCurrency: string;
|
||||||
|
receivedAmount: string;
|
||||||
|
swapTxHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SettlementCompletedEvent extends DomainEvent {
|
||||||
|
constructor(private readonly payload: SettlementCompletedPayload) {
|
||||||
|
super({
|
||||||
|
aggregateId: payload.walletId,
|
||||||
|
aggregateType: 'WalletAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPayload(): SettlementCompletedPayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
export interface WithdrawalRequestedPayload {
|
||||||
|
userId: string;
|
||||||
|
walletId: string;
|
||||||
|
amount: string;
|
||||||
|
assetType: string;
|
||||||
|
toAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WithdrawalRequestedEvent extends DomainEvent {
|
||||||
|
constructor(private readonly payload: WithdrawalRequestedPayload) {
|
||||||
|
super({
|
||||||
|
aggregateId: payload.walletId,
|
||||||
|
aggregateType: 'WalletAccount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPayload(): WithdrawalRequestedPayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { DepositOrder } from '@/domain/aggregates';
|
||||||
|
import { DepositStatus } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export interface IDepositOrderRepository {
|
||||||
|
save(order: DepositOrder): Promise<DepositOrder>;
|
||||||
|
findById(orderId: bigint): Promise<DepositOrder | null>;
|
||||||
|
findByTxHash(txHash: string): Promise<DepositOrder | null>;
|
||||||
|
findByUserId(userId: bigint, status?: DepositStatus): Promise<DepositOrder[]>;
|
||||||
|
existsByTxHash(txHash: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEPOSIT_ORDER_REPOSITORY = Symbol('IDepositOrderRepository');
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './wallet-account.repository.interface';
|
||||||
|
export * from './ledger-entry.repository.interface';
|
||||||
|
export * from './deposit-order.repository.interface';
|
||||||
|
export * from './settlement-order.repository.interface';
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { LedgerEntry } from '@/domain/aggregates';
|
||||||
|
import { LedgerEntryType, AssetType } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export interface LedgerFilters {
|
||||||
|
entryType?: LedgerEntryType;
|
||||||
|
assetType?: AssetType;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pagination {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILedgerEntryRepository {
|
||||||
|
save(entry: LedgerEntry): Promise<LedgerEntry>;
|
||||||
|
saveAll(entries: LedgerEntry[]): Promise<void>;
|
||||||
|
findByUserId(userId: bigint, filters?: LedgerFilters, pagination?: Pagination): Promise<PaginatedResult<LedgerEntry>>;
|
||||||
|
findByRefOrderId(refOrderId: string): Promise<LedgerEntry[]>;
|
||||||
|
findByRefTxHash(refTxHash: string): Promise<LedgerEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEDGER_ENTRY_REPOSITORY = Symbol('ILedgerEntryRepository');
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { SettlementOrder } from '@/domain/aggregates';
|
||||||
|
import { SettlementStatus } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export interface ISettlementOrderRepository {
|
||||||
|
save(order: SettlementOrder): Promise<SettlementOrder>;
|
||||||
|
findById(orderId: bigint): Promise<SettlementOrder | null>;
|
||||||
|
findByUserId(userId: bigint, status?: SettlementStatus): Promise<SettlementOrder[]>;
|
||||||
|
findPendingOrders(): Promise<SettlementOrder[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTLEMENT_ORDER_REPOSITORY = Symbol('ISettlementOrderRepository');
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { WalletAccount } from '@/domain/aggregates';
|
||||||
|
|
||||||
|
export interface IWalletAccountRepository {
|
||||||
|
save(wallet: WalletAccount): Promise<WalletAccount>;
|
||||||
|
findById(walletId: bigint): Promise<WalletAccount | null>;
|
||||||
|
findByUserId(userId: bigint): Promise<WalletAccount | null>;
|
||||||
|
getOrCreate(userId: bigint): Promise<WalletAccount>;
|
||||||
|
findByUserIds(userIds: bigint[]): Promise<Map<string, WalletAccount>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WALLET_ACCOUNT_REPOSITORY = Symbol('IWalletAccountRepository');
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export enum AssetType {
|
||||||
|
USDT = 'USDT',
|
||||||
|
DST = 'DST',
|
||||||
|
BNB = 'BNB',
|
||||||
|
OG = 'OG',
|
||||||
|
RWAD = 'RWAD',
|
||||||
|
HASHPOWER = 'HASHPOWER',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Balance } from './balance.vo';
|
||||||
|
import { Money } from './money.vo';
|
||||||
|
|
||||||
|
jest.mock('@/shared/exceptions/domain.exception', () => ({
|
||||||
|
DomainError: class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Balance Value Object', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create balance with available and frozen amounts', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(20));
|
||||||
|
expect(balance.available.value).toBe(100);
|
||||||
|
expect(balance.frozen.value).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create zero balance', () => {
|
||||||
|
const balance = Balance.zero('USDT');
|
||||||
|
expect(balance.available.isZero()).toBe(true);
|
||||||
|
expect(balance.frozen.isZero()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when currencies mismatch', () => {
|
||||||
|
expect(() => Balance.create(Money.USDT(100), Money.BNB(20))).toThrow('same currency');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('total', () => {
|
||||||
|
it('should calculate total correctly', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(50));
|
||||||
|
expect(balance.total.value).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('add', () => {
|
||||||
|
it('should add to available balance', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(20));
|
||||||
|
const newBalance = balance.add(Money.USDT(50));
|
||||||
|
expect(newBalance.available.value).toBe(150);
|
||||||
|
expect(newBalance.frozen.value).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deduct', () => {
|
||||||
|
it('should deduct from available balance', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(20));
|
||||||
|
const newBalance = balance.deduct(Money.USDT(30));
|
||||||
|
expect(newBalance.available.value).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when insufficient balance', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(50), Money.USDT(0));
|
||||||
|
expect(() => balance.deduct(Money.USDT(100))).toThrow('Insufficient');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freeze', () => {
|
||||||
|
it('should move from available to frozen', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(20));
|
||||||
|
const newBalance = balance.freeze(Money.USDT(30));
|
||||||
|
expect(newBalance.available.value).toBe(70);
|
||||||
|
expect(newBalance.frozen.value).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when insufficient available balance to freeze', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(20), Money.USDT(0));
|
||||||
|
expect(() => balance.freeze(Money.USDT(50))).toThrow('Insufficient');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unfreeze', () => {
|
||||||
|
it('should move from frozen to available', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(50));
|
||||||
|
const newBalance = balance.unfreeze(Money.USDT(30));
|
||||||
|
expect(newBalance.available.value).toBe(130);
|
||||||
|
expect(newBalance.frozen.value).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when insufficient frozen balance', () => {
|
||||||
|
const balance = Balance.create(Money.USDT(100), Money.USDT(20));
|
||||||
|
expect(() => balance.unfreeze(Money.USDT(50))).toThrow('Insufficient');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { Money } from './money.vo';
|
||||||
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export class Balance {
|
||||||
|
private readonly _available: Money;
|
||||||
|
private readonly _frozen: Money;
|
||||||
|
|
||||||
|
constructor(available: Money, frozen: Money) {
|
||||||
|
if (available.currency !== frozen.currency) {
|
||||||
|
throw new DomainError('Available and frozen must have same currency');
|
||||||
|
}
|
||||||
|
this._available = available;
|
||||||
|
this._frozen = frozen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(available: Money, frozen: Money): Balance {
|
||||||
|
return new Balance(available, frozen);
|
||||||
|
}
|
||||||
|
|
||||||
|
static zero(currency: string = 'USDT'): Balance {
|
||||||
|
return new Balance(Money.zero(currency), Money.zero(currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
get available(): Money {
|
||||||
|
return this._available;
|
||||||
|
}
|
||||||
|
|
||||||
|
get frozen(): Money {
|
||||||
|
return this._frozen;
|
||||||
|
}
|
||||||
|
|
||||||
|
get total(): Money {
|
||||||
|
return this._available.add(this._frozen);
|
||||||
|
}
|
||||||
|
|
||||||
|
get currency(): string {
|
||||||
|
return this._available.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(amount: Money): Balance {
|
||||||
|
return new Balance(this._available.add(amount), this._frozen);
|
||||||
|
}
|
||||||
|
|
||||||
|
deduct(amount: Money): Balance {
|
||||||
|
if (this._available.lessThan(amount)) {
|
||||||
|
throw new DomainError('Insufficient available balance');
|
||||||
|
}
|
||||||
|
return new Balance(this._available.subtract(amount), this._frozen);
|
||||||
|
}
|
||||||
|
|
||||||
|
freeze(amount: Money): Balance {
|
||||||
|
if (this._available.lessThan(amount)) {
|
||||||
|
throw new DomainError('Insufficient available balance to freeze');
|
||||||
|
}
|
||||||
|
return new Balance(
|
||||||
|
this._available.subtract(amount),
|
||||||
|
this._frozen.add(amount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unfreeze(amount: Money): Balance {
|
||||||
|
if (this._frozen.lessThan(amount)) {
|
||||||
|
throw new DomainError('Insufficient frozen balance to unfreeze');
|
||||||
|
}
|
||||||
|
return new Balance(
|
||||||
|
this._available.add(amount),
|
||||||
|
this._frozen.subtract(amount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deductFrozen(amount: Money): Balance {
|
||||||
|
if (this._frozen.lessThan(amount)) {
|
||||||
|
throw new DomainError('Insufficient frozen balance to deduct');
|
||||||
|
}
|
||||||
|
return new Balance(this._available, this._frozen.subtract(amount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum ChainType {
|
||||||
|
KAVA = 'KAVA',
|
||||||
|
DST = 'DST',
|
||||||
|
BSC = 'BSC',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum DepositStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
CONFIRMED = 'CONFIRMED',
|
||||||
|
FAILED = 'FAILED',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Hashpower } from './hashpower.vo';
|
||||||
|
|
||||||
|
jest.mock('@/shared/exceptions/domain.exception', () => ({
|
||||||
|
DomainError: class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Hashpower Value Object', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create hashpower with valid value', () => {
|
||||||
|
const hp = Hashpower.create(100);
|
||||||
|
expect(hp.value).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create zero hashpower', () => {
|
||||||
|
const hp = Hashpower.zero();
|
||||||
|
expect(hp.value).toBe(0);
|
||||||
|
expect(hp.isZero()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for negative value', () => {
|
||||||
|
expect(() => Hashpower.create(-10)).toThrow('cannot be negative');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arithmetic operations', () => {
|
||||||
|
it('should add hashpower', () => {
|
||||||
|
const hp1 = Hashpower.create(100);
|
||||||
|
const hp2 = Hashpower.create(50);
|
||||||
|
const result = hp1.add(hp2);
|
||||||
|
expect(result.value).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subtract hashpower', () => {
|
||||||
|
const hp1 = Hashpower.create(100);
|
||||||
|
const hp2 = Hashpower.create(30);
|
||||||
|
const result = hp1.subtract(hp2);
|
||||||
|
expect(result.value).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when subtracting more than available', () => {
|
||||||
|
const hp1 = Hashpower.create(30);
|
||||||
|
const hp2 = Hashpower.create(50);
|
||||||
|
expect(() => hp1.subtract(hp2)).toThrow('Insufficient');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comparison operations', () => {
|
||||||
|
it('should check equality', () => {
|
||||||
|
const hp1 = Hashpower.create(100);
|
||||||
|
const hp2 = Hashpower.create(100);
|
||||||
|
expect(hp1.equals(hp2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compare less than', () => {
|
||||||
|
const hp1 = Hashpower.create(50);
|
||||||
|
const hp2 = Hashpower.create(100);
|
||||||
|
expect(hp1.lessThan(hp2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compare greater than', () => {
|
||||||
|
const hp1 = Hashpower.create(100);
|
||||||
|
const hp2 = Hashpower.create(50);
|
||||||
|
expect(hp1.greaterThan(hp2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check isPositive', () => {
|
||||||
|
expect(Hashpower.create(100).isPositive()).toBe(true);
|
||||||
|
expect(Hashpower.zero().isPositive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export class Hashpower {
|
||||||
|
private readonly _value: Decimal;
|
||||||
|
|
||||||
|
private constructor(value: Decimal) {
|
||||||
|
if (value.lessThan(0)) {
|
||||||
|
throw new DomainError('Hashpower cannot be negative');
|
||||||
|
}
|
||||||
|
this._value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: number | string | Decimal): Hashpower {
|
||||||
|
return new Hashpower(new Decimal(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static zero(): Hashpower {
|
||||||
|
return new Hashpower(new Decimal(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): number {
|
||||||
|
return this._value.toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
get decimal(): Decimal {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(other: Hashpower): Hashpower {
|
||||||
|
return new Hashpower(this._value.plus(other._value));
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(other: Hashpower): Hashpower {
|
||||||
|
const result = this._value.minus(other._value);
|
||||||
|
if (result.lessThan(0)) {
|
||||||
|
throw new DomainError('Insufficient hashpower');
|
||||||
|
}
|
||||||
|
return new Hashpower(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
isZero(): boolean {
|
||||||
|
return this._value.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
isPositive(): boolean {
|
||||||
|
return this._value.greaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: Hashpower): boolean {
|
||||||
|
return this._value.equals(other._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
lessThan(other: Hashpower): boolean {
|
||||||
|
return this._value.lessThan(other._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
greaterThan(other: Hashpower): boolean {
|
||||||
|
return this._value.greaterThan(other._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `${this._value.toFixed(8)} HP`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export * from './asset-type.enum';
|
||||||
|
export * from './chain-type.enum';
|
||||||
|
export * from './wallet-status.enum';
|
||||||
|
export * from './ledger-entry-type.enum';
|
||||||
|
export * from './deposit-status.enum';
|
||||||
|
export * from './settlement-status.enum';
|
||||||
|
export * from './money.vo';
|
||||||
|
export * from './balance.vo';
|
||||||
|
export * from './hashpower.vo';
|
||||||
|
export * from './wallet-id.vo';
|
||||||
|
export * from './user-id.vo';
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
export enum LedgerEntryType {
|
||||||
|
DEPOSIT_KAVA = 'DEPOSIT_KAVA',
|
||||||
|
DEPOSIT_BSC = 'DEPOSIT_BSC',
|
||||||
|
PLANT_PAYMENT = 'PLANT_PAYMENT',
|
||||||
|
REWARD_PENDING = 'REWARD_PENDING',
|
||||||
|
REWARD_TO_SETTLEABLE = 'REWARD_TO_SETTLEABLE',
|
||||||
|
REWARD_EXPIRED = 'REWARD_EXPIRED',
|
||||||
|
REWARD_SETTLED = 'REWARD_SETTLED',
|
||||||
|
TRANSFER_TO_POOL = 'TRANSFER_TO_POOL',
|
||||||
|
SWAP_EXECUTED = 'SWAP_EXECUTED',
|
||||||
|
WITHDRAWAL = 'WITHDRAWAL',
|
||||||
|
TRANSFER_IN = 'TRANSFER_IN',
|
||||||
|
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||||
|
FREEZE = 'FREEZE',
|
||||||
|
UNFREEZE = 'UNFREEZE',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Money } from './money.vo';
|
||||||
|
|
||||||
|
// Mock DomainError for testing
|
||||||
|
jest.mock('@/shared/exceptions/domain.exception', () => ({
|
||||||
|
DomainError: class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Money Value Object', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create money with valid amount', () => {
|
||||||
|
const money = Money.create(100, 'USDT');
|
||||||
|
expect(money.value).toBe(100);
|
||||||
|
expect(money.currency).toBe('USDT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create zero money', () => {
|
||||||
|
const money = Money.zero('USDT');
|
||||||
|
expect(money.value).toBe(0);
|
||||||
|
expect(money.isZero()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for negative amount', () => {
|
||||||
|
expect(() => Money.create(-100, 'USDT')).toThrow('Money amount cannot be negative');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('static factory methods', () => {
|
||||||
|
it('should create USDT money', () => {
|
||||||
|
const money = Money.USDT(50);
|
||||||
|
expect(money.currency).toBe('USDT');
|
||||||
|
expect(money.value).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create BNB money', () => {
|
||||||
|
const money = Money.BNB(1.5);
|
||||||
|
expect(money.currency).toBe('BNB');
|
||||||
|
expect(money.value).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arithmetic operations', () => {
|
||||||
|
it('should add two money values', () => {
|
||||||
|
const money1 = Money.USDT(100);
|
||||||
|
const money2 = Money.USDT(50);
|
||||||
|
const result = money1.add(money2);
|
||||||
|
expect(result.value).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subtract money values', () => {
|
||||||
|
const money1 = Money.USDT(100);
|
||||||
|
const money2 = Money.USDT(30);
|
||||||
|
const result = money1.subtract(money2);
|
||||||
|
expect(result.value).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when subtracting more than available', () => {
|
||||||
|
const money1 = Money.USDT(50);
|
||||||
|
const money2 = Money.USDT(100);
|
||||||
|
expect(() => money1.subtract(money2)).toThrow('Insufficient balance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when adding different currencies', () => {
|
||||||
|
const money1 = Money.USDT(100);
|
||||||
|
const money2 = Money.BNB(1);
|
||||||
|
expect(() => money1.add(money2)).toThrow('Currency mismatch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comparison operations', () => {
|
||||||
|
it('should compare less than correctly', () => {
|
||||||
|
const money1 = Money.USDT(50);
|
||||||
|
const money2 = Money.USDT(100);
|
||||||
|
expect(money1.lessThan(money2)).toBe(true);
|
||||||
|
expect(money2.lessThan(money1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compare greater than correctly', () => {
|
||||||
|
const money1 = Money.USDT(100);
|
||||||
|
const money2 = Money.USDT(50);
|
||||||
|
expect(money1.greaterThan(money2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check equality correctly', () => {
|
||||||
|
const money1 = Money.USDT(100);
|
||||||
|
const money2 = Money.USDT(100);
|
||||||
|
expect(money1.equals(money2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export class Money {
|
||||||
|
private readonly _amount: Decimal;
|
||||||
|
private readonly _currency: string;
|
||||||
|
private readonly _allowNegative: boolean;
|
||||||
|
|
||||||
|
private constructor(amount: Decimal, currency: string, allowNegative: boolean = false) {
|
||||||
|
if (!allowNegative && amount.lessThan(0)) {
|
||||||
|
throw new DomainError('Money amount cannot be negative');
|
||||||
|
}
|
||||||
|
this._amount = amount;
|
||||||
|
this._currency = currency;
|
||||||
|
this._allowNegative = allowNegative;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(amount: number | string | Decimal, currency: string): Money {
|
||||||
|
return new Money(new Decimal(amount), currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create signed money (allows negative values for ledger entries)
|
||||||
|
static signed(amount: number | string | Decimal, currency: string): Money {
|
||||||
|
return new Money(new Decimal(amount), currency, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static USDT(amount: number | string | Decimal): Money {
|
||||||
|
return new Money(new Decimal(amount), 'USDT');
|
||||||
|
}
|
||||||
|
|
||||||
|
static DST(amount: number | string | Decimal): Money {
|
||||||
|
return new Money(new Decimal(amount), 'DST');
|
||||||
|
}
|
||||||
|
|
||||||
|
static BNB(amount: number | string | Decimal): Money {
|
||||||
|
return new Money(new Decimal(amount), 'BNB');
|
||||||
|
}
|
||||||
|
|
||||||
|
static OG(amount: number | string | Decimal): Money {
|
||||||
|
return new Money(new Decimal(amount), 'OG');
|
||||||
|
}
|
||||||
|
|
||||||
|
static RWAD(amount: number | string | Decimal): Money {
|
||||||
|
return new Money(new Decimal(amount), 'RWAD');
|
||||||
|
}
|
||||||
|
|
||||||
|
static zero(currency: string = 'USDT'): Money {
|
||||||
|
return new Money(new Decimal(0), currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
get amount(): Decimal {
|
||||||
|
return this._amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currency(): string {
|
||||||
|
return this._currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): number {
|
||||||
|
return this._amount.toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(other: Money): Money {
|
||||||
|
this.ensureSameCurrency(other);
|
||||||
|
return new Money(this._amount.plus(other._amount), this._currency, this._allowNegative);
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(other: Money): Money {
|
||||||
|
this.ensureSameCurrency(other);
|
||||||
|
const result = this._amount.minus(other._amount);
|
||||||
|
if (!this._allowNegative && result.lessThan(0)) {
|
||||||
|
throw new DomainError('Insufficient balance');
|
||||||
|
}
|
||||||
|
return new Money(result, this._currency, this._allowNegative);
|
||||||
|
}
|
||||||
|
|
||||||
|
lessThan(other: Money): boolean {
|
||||||
|
this.ensureSameCurrency(other);
|
||||||
|
return this._amount.lessThan(other._amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
lessThanOrEqual(other: Money): boolean {
|
||||||
|
this.ensureSameCurrency(other);
|
||||||
|
return this._amount.lessThanOrEqualTo(other._amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
greaterThan(other: Money): boolean {
|
||||||
|
this.ensureSameCurrency(other);
|
||||||
|
return this._amount.greaterThan(other._amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
greaterThanOrEqual(other: Money): boolean {
|
||||||
|
this.ensureSameCurrency(other);
|
||||||
|
return this._amount.greaterThanOrEqualTo(other._amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: Money): boolean {
|
||||||
|
return this._currency === other._currency && this._amount.equals(other._amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
isZero(): boolean {
|
||||||
|
return this._amount.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
isPositive(): boolean {
|
||||||
|
return this._amount.greaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
isNegative(): boolean {
|
||||||
|
return this._amount.lessThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
abs(): Money {
|
||||||
|
return new Money(this._amount.abs(), this._currency, this._allowNegative);
|
||||||
|
}
|
||||||
|
|
||||||
|
negate(): Money {
|
||||||
|
return new Money(this._amount.negated(), this._currency, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
toDecimal(): Decimal {
|
||||||
|
return this._amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `${this._amount.toFixed(8)} ${this._currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSameCurrency(other: Money): void {
|
||||||
|
if (this._currency !== other._currency) {
|
||||||
|
throw new DomainError(`Currency mismatch: ${this._currency} vs ${other._currency}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export enum SettlementStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
SWAPPING = 'SWAPPING',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
FAILED = 'FAILED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SettleCurrency {
|
||||||
|
BNB = 'BNB',
|
||||||
|
OG = 'OG',
|
||||||
|
USDT = 'USDT',
|
||||||
|
DST = 'DST',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export class UserId {
|
||||||
|
private readonly _value: bigint;
|
||||||
|
|
||||||
|
private constructor(value: bigint) {
|
||||||
|
this._value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: bigint | number | string): UserId {
|
||||||
|
const bigintValue = typeof value === 'bigint' ? value : BigInt(value);
|
||||||
|
if (bigintValue < 0) {
|
||||||
|
throw new DomainError('UserId cannot be negative');
|
||||||
|
}
|
||||||
|
return new UserId(bigintValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): bigint {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: UserId): boolean {
|
||||||
|
return this._value === other._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this._value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
export class WalletId {
|
||||||
|
private readonly _value: bigint;
|
||||||
|
|
||||||
|
private constructor(value: bigint) {
|
||||||
|
this._value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: bigint | number | string): WalletId {
|
||||||
|
const bigintValue = typeof value === 'bigint' ? value : BigInt(value);
|
||||||
|
if (bigintValue < 0) {
|
||||||
|
throw new DomainError('WalletId cannot be negative');
|
||||||
|
}
|
||||||
|
return new WalletId(bigintValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): bigint {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: WalletId): boolean {
|
||||||
|
return this._value === other._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this._value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum WalletStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
FROZEN = 'FROZEN',
|
||||||
|
CLOSED = 'CLOSED',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
WalletAccountRepositoryImpl,
|
||||||
|
LedgerEntryRepositoryImpl,
|
||||||
|
DepositOrderRepositoryImpl,
|
||||||
|
SettlementOrderRepositoryImpl,
|
||||||
|
} from './persistence/repositories';
|
||||||
|
import {
|
||||||
|
WALLET_ACCOUNT_REPOSITORY,
|
||||||
|
LEDGER_ENTRY_REPOSITORY,
|
||||||
|
DEPOSIT_ORDER_REPOSITORY,
|
||||||
|
SETTLEMENT_ORDER_REPOSITORY,
|
||||||
|
} from '@/domain/repositories';
|
||||||
|
|
||||||
|
const repositories = [
|
||||||
|
{
|
||||||
|
provide: WALLET_ACCOUNT_REPOSITORY,
|
||||||
|
useClass: WalletAccountRepositoryImpl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LEDGER_ENTRY_REPOSITORY,
|
||||||
|
useClass: LedgerEntryRepositoryImpl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DEPOSIT_ORDER_REPOSITORY,
|
||||||
|
useClass: DepositOrderRepositoryImpl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SETTLEMENT_ORDER_REPOSITORY,
|
||||||
|
useClass: SettlementOrderRepositoryImpl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService, ...repositories],
|
||||||
|
exports: [PrismaService, ...repositories],
|
||||||
|
})
|
||||||
|
export class InfrastructureModule {}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { IDepositOrderRepository } from '@/domain/repositories';
|
||||||
|
import { DepositOrder } from '@/domain/aggregates';
|
||||||
|
import { DepositStatus } from '@/domain/value-objects';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DepositOrderRepositoryImpl implements IDepositOrderRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(order: DepositOrder): Promise<DepositOrder> {
|
||||||
|
const data = {
|
||||||
|
userId: order.userId.value,
|
||||||
|
chainType: order.chainType,
|
||||||
|
amount: order.amount.toDecimal(),
|
||||||
|
txHash: order.txHash,
|
||||||
|
status: order.status,
|
||||||
|
confirmedAt: order.confirmedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (order.id === BigInt(0)) {
|
||||||
|
const created = await this.prisma.depositOrder.create({ data });
|
||||||
|
return this.toDomain(created);
|
||||||
|
} else {
|
||||||
|
const updated = await this.prisma.depositOrder.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return this.toDomain(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(orderId: bigint): Promise<DepositOrder | null> {
|
||||||
|
const record = await this.prisma.depositOrder.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
});
|
||||||
|
return record ? this.toDomain(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTxHash(txHash: string): Promise<DepositOrder | null> {
|
||||||
|
const record = await this.prisma.depositOrder.findUnique({
|
||||||
|
where: { txHash },
|
||||||
|
});
|
||||||
|
return record ? this.toDomain(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint, status?: DepositStatus): Promise<DepositOrder[]> {
|
||||||
|
const where: Record<string, unknown> = { userId };
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await this.prisma.depositOrder.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map(r => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByTxHash(txHash: string): Promise<boolean> {
|
||||||
|
const count = await this.prisma.depositOrder.count({
|
||||||
|
where: { txHash },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
chainType: string;
|
||||||
|
amount: Decimal;
|
||||||
|
txHash: string;
|
||||||
|
status: string;
|
||||||
|
confirmedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): DepositOrder {
|
||||||
|
return DepositOrder.reconstruct({
|
||||||
|
id: record.id,
|
||||||
|
userId: record.userId,
|
||||||
|
chainType: record.chainType,
|
||||||
|
amount: new Decimal(record.amount.toString()),
|
||||||
|
txHash: record.txHash,
|
||||||
|
status: record.status,
|
||||||
|
confirmedAt: record.confirmedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './wallet-account.repository.impl';
|
||||||
|
export * from './ledger-entry.repository.impl';
|
||||||
|
export * from './deposit-order.repository.impl';
|
||||||
|
export * from './settlement-order.repository.impl';
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
ILedgerEntryRepository, LedgerFilters, Pagination, PaginatedResult,
|
||||||
|
} from '@/domain/repositories';
|
||||||
|
import { LedgerEntry } from '@/domain/aggregates';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LedgerEntryRepositoryImpl implements ILedgerEntryRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(entry: LedgerEntry): Promise<LedgerEntry> {
|
||||||
|
const created = await this.prisma.ledgerEntry.create({
|
||||||
|
data: {
|
||||||
|
userId: entry.userId.value,
|
||||||
|
entryType: entry.entryType,
|
||||||
|
amount: entry.amount.toDecimal(),
|
||||||
|
assetType: entry.assetType,
|
||||||
|
balanceAfter: entry.balanceAfter?.toDecimal() ?? null,
|
||||||
|
refOrderId: entry.refOrderId,
|
||||||
|
refTxHash: entry.refTxHash,
|
||||||
|
memo: entry.memo,
|
||||||
|
payloadJson: entry.payloadJson as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toDomain(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAll(entries: LedgerEntry[]): Promise<void> {
|
||||||
|
await this.prisma.ledgerEntry.createMany({
|
||||||
|
data: entries.map(entry => ({
|
||||||
|
userId: entry.userId.value,
|
||||||
|
entryType: entry.entryType,
|
||||||
|
amount: entry.amount.toDecimal(),
|
||||||
|
assetType: entry.assetType,
|
||||||
|
balanceAfter: entry.balanceAfter?.toDecimal() ?? null,
|
||||||
|
refOrderId: entry.refOrderId,
|
||||||
|
refTxHash: entry.refTxHash,
|
||||||
|
memo: entry.memo,
|
||||||
|
payloadJson: entry.payloadJson as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(
|
||||||
|
userId: bigint,
|
||||||
|
filters?: LedgerFilters,
|
||||||
|
pagination?: Pagination,
|
||||||
|
): Promise<PaginatedResult<LedgerEntry>> {
|
||||||
|
const where: Record<string, unknown> = { userId };
|
||||||
|
|
||||||
|
if (filters?.entryType) {
|
||||||
|
where.entryType = filters.entryType;
|
||||||
|
}
|
||||||
|
if (filters?.assetType) {
|
||||||
|
where.assetType = filters.assetType;
|
||||||
|
}
|
||||||
|
if (filters?.startDate || filters?.endDate) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (filters.startDate) {
|
||||||
|
(where.createdAt as Record<string, unknown>).gte = filters.startDate;
|
||||||
|
}
|
||||||
|
if (filters.endDate) {
|
||||||
|
(where.createdAt as Record<string, unknown>).lte = filters.endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pagination?.page ?? 1;
|
||||||
|
const pageSize = pagination?.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.ledgerEntry.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.ledgerEntry.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records.map(r => this.toDomain(r)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByRefOrderId(refOrderId: string): Promise<LedgerEntry[]> {
|
||||||
|
const records = await this.prisma.ledgerEntry.findMany({
|
||||||
|
where: { refOrderId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map(r => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByRefTxHash(refTxHash: string): Promise<LedgerEntry[]> {
|
||||||
|
const records = await this.prisma.ledgerEntry.findMany({
|
||||||
|
where: { refTxHash },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map(r => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
entryType: string;
|
||||||
|
amount: Decimal;
|
||||||
|
assetType: string;
|
||||||
|
balanceAfter: Decimal | null;
|
||||||
|
refOrderId: string | null;
|
||||||
|
refTxHash: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
payloadJson: unknown;
|
||||||
|
createdAt: Date;
|
||||||
|
}): LedgerEntry {
|
||||||
|
return LedgerEntry.reconstruct({
|
||||||
|
id: record.id,
|
||||||
|
userId: record.userId,
|
||||||
|
entryType: record.entryType,
|
||||||
|
amount: new Decimal(record.amount.toString()),
|
||||||
|
assetType: record.assetType,
|
||||||
|
balanceAfter: record.balanceAfter ? new Decimal(record.balanceAfter.toString()) : null,
|
||||||
|
refOrderId: record.refOrderId,
|
||||||
|
refTxHash: record.refTxHash,
|
||||||
|
memo: record.memo,
|
||||||
|
payloadJson: record.payloadJson as Record<string, unknown> | null,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { ISettlementOrderRepository } from '@/domain/repositories';
|
||||||
|
import { SettlementOrder } from '@/domain/aggregates';
|
||||||
|
import { SettlementStatus } from '@/domain/value-objects';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettlementOrderRepositoryImpl implements ISettlementOrderRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(order: SettlementOrder): Promise<SettlementOrder> {
|
||||||
|
const data = {
|
||||||
|
userId: order.userId.value,
|
||||||
|
usdtAmount: order.usdtAmount.toDecimal(),
|
||||||
|
settleCurrency: order.settleCurrency,
|
||||||
|
swapTxHash: order.swapTxHash,
|
||||||
|
receivedAmount: order.receivedAmount?.toDecimal() ?? null,
|
||||||
|
status: order.status,
|
||||||
|
settledAt: order.settledAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (order.id === BigInt(0)) {
|
||||||
|
const created = await this.prisma.settlementOrder.create({ data });
|
||||||
|
return this.toDomain(created);
|
||||||
|
} else {
|
||||||
|
const updated = await this.prisma.settlementOrder.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return this.toDomain(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(orderId: bigint): Promise<SettlementOrder | null> {
|
||||||
|
const record = await this.prisma.settlementOrder.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
});
|
||||||
|
return record ? this.toDomain(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint, status?: SettlementStatus): Promise<SettlementOrder[]> {
|
||||||
|
const where: Record<string, unknown> = { userId };
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await this.prisma.settlementOrder.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map(r => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPendingOrders(): Promise<SettlementOrder[]> {
|
||||||
|
const records = await this.prisma.settlementOrder.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: [SettlementStatus.PENDING, SettlementStatus.SWAPPING] },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return records.map(r => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
usdtAmount: Decimal;
|
||||||
|
settleCurrency: string;
|
||||||
|
swapTxHash: string | null;
|
||||||
|
receivedAmount: Decimal | null;
|
||||||
|
status: string;
|
||||||
|
settledAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): SettlementOrder {
|
||||||
|
return SettlementOrder.reconstruct({
|
||||||
|
id: record.id,
|
||||||
|
userId: record.userId,
|
||||||
|
usdtAmount: new Decimal(record.usdtAmount.toString()),
|
||||||
|
settleCurrency: record.settleCurrency,
|
||||||
|
swapTxHash: record.swapTxHash,
|
||||||
|
receivedAmount: record.receivedAmount ? new Decimal(record.receivedAmount.toString()) : null,
|
||||||
|
status: record.status,
|
||||||
|
settledAt: record.settledAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { IWalletAccountRepository } from '@/domain/repositories';
|
||||||
|
import { WalletAccount } from '@/domain/aggregates';
|
||||||
|
import { UserId, WalletStatus } from '@/domain/value-objects';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WalletAccountRepositoryImpl implements IWalletAccountRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(wallet: WalletAccount): Promise<WalletAccount> {
|
||||||
|
const data = {
|
||||||
|
userId: wallet.userId.value,
|
||||||
|
usdtAvailable: wallet.balances.usdt.available.toDecimal(),
|
||||||
|
usdtFrozen: wallet.balances.usdt.frozen.toDecimal(),
|
||||||
|
dstAvailable: wallet.balances.dst.available.toDecimal(),
|
||||||
|
dstFrozen: wallet.balances.dst.frozen.toDecimal(),
|
||||||
|
bnbAvailable: wallet.balances.bnb.available.toDecimal(),
|
||||||
|
bnbFrozen: wallet.balances.bnb.frozen.toDecimal(),
|
||||||
|
ogAvailable: wallet.balances.og.available.toDecimal(),
|
||||||
|
ogFrozen: wallet.balances.og.frozen.toDecimal(),
|
||||||
|
rwadAvailable: wallet.balances.rwad.available.toDecimal(),
|
||||||
|
rwadFrozen: wallet.balances.rwad.frozen.toDecimal(),
|
||||||
|
hashpower: wallet.hashpower.decimal,
|
||||||
|
pendingUsdt: wallet.rewards.pendingUsdt.toDecimal(),
|
||||||
|
pendingHashpower: wallet.rewards.pendingHashpower.decimal,
|
||||||
|
pendingExpireAt: wallet.rewards.pendingExpireAt,
|
||||||
|
settleableUsdt: wallet.rewards.settleableUsdt.toDecimal(),
|
||||||
|
settleableHashpower: wallet.rewards.settleableHashpower.decimal,
|
||||||
|
settledTotalUsdt: wallet.rewards.settledTotalUsdt.toDecimal(),
|
||||||
|
settledTotalHashpower: wallet.rewards.settledTotalHashpower.decimal,
|
||||||
|
expiredTotalUsdt: wallet.rewards.expiredTotalUsdt.toDecimal(),
|
||||||
|
expiredTotalHashpower: wallet.rewards.expiredTotalHashpower.decimal,
|
||||||
|
status: wallet.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wallet.walletId.value === BigInt(0)) {
|
||||||
|
// Create new
|
||||||
|
const created = await this.prisma.walletAccount.create({ data });
|
||||||
|
return this.toDomain(created);
|
||||||
|
} else {
|
||||||
|
// Update existing
|
||||||
|
const updated = await this.prisma.walletAccount.update({
|
||||||
|
where: { id: wallet.walletId.value },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return this.toDomain(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(walletId: bigint): Promise<WalletAccount | null> {
|
||||||
|
const record = await this.prisma.walletAccount.findUnique({
|
||||||
|
where: { id: walletId },
|
||||||
|
});
|
||||||
|
return record ? this.toDomain(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint): Promise<WalletAccount | null> {
|
||||||
|
const record = await this.prisma.walletAccount.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
return record ? this.toDomain(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrCreate(userId: bigint): Promise<WalletAccount> {
|
||||||
|
const existing = await this.findByUserId(userId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWallet = WalletAccount.createNew(UserId.create(userId));
|
||||||
|
return this.save(newWallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserIds(userIds: bigint[]): Promise<Map<string, WalletAccount>> {
|
||||||
|
const records = await this.prisma.walletAccount.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = new Map<string, WalletAccount>();
|
||||||
|
for (const record of records) {
|
||||||
|
map.set(record.userId.toString(), this.toDomain(record));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(record: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
usdtAvailable: Decimal;
|
||||||
|
usdtFrozen: Decimal;
|
||||||
|
dstAvailable: Decimal;
|
||||||
|
dstFrozen: Decimal;
|
||||||
|
bnbAvailable: Decimal;
|
||||||
|
bnbFrozen: Decimal;
|
||||||
|
ogAvailable: Decimal;
|
||||||
|
ogFrozen: Decimal;
|
||||||
|
rwadAvailable: Decimal;
|
||||||
|
rwadFrozen: Decimal;
|
||||||
|
hashpower: Decimal;
|
||||||
|
pendingUsdt: Decimal;
|
||||||
|
pendingHashpower: Decimal;
|
||||||
|
pendingExpireAt: Date | null;
|
||||||
|
settleableUsdt: Decimal;
|
||||||
|
settleableHashpower: Decimal;
|
||||||
|
settledTotalUsdt: Decimal;
|
||||||
|
settledTotalHashpower: Decimal;
|
||||||
|
expiredTotalUsdt: Decimal;
|
||||||
|
expiredTotalHashpower: Decimal;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): WalletAccount {
|
||||||
|
return WalletAccount.reconstruct({
|
||||||
|
walletId: record.id,
|
||||||
|
userId: record.userId,
|
||||||
|
usdtAvailable: new Decimal(record.usdtAvailable.toString()),
|
||||||
|
usdtFrozen: new Decimal(record.usdtFrozen.toString()),
|
||||||
|
dstAvailable: new Decimal(record.dstAvailable.toString()),
|
||||||
|
dstFrozen: new Decimal(record.dstFrozen.toString()),
|
||||||
|
bnbAvailable: new Decimal(record.bnbAvailable.toString()),
|
||||||
|
bnbFrozen: new Decimal(record.bnbFrozen.toString()),
|
||||||
|
ogAvailable: new Decimal(record.ogAvailable.toString()),
|
||||||
|
ogFrozen: new Decimal(record.ogFrozen.toString()),
|
||||||
|
rwadAvailable: new Decimal(record.rwadAvailable.toString()),
|
||||||
|
rwadFrozen: new Decimal(record.rwadFrozen.toString()),
|
||||||
|
hashpower: new Decimal(record.hashpower.toString()),
|
||||||
|
pendingUsdt: new Decimal(record.pendingUsdt.toString()),
|
||||||
|
pendingHashpower: new Decimal(record.pendingHashpower.toString()),
|
||||||
|
pendingExpireAt: record.pendingExpireAt,
|
||||||
|
settleableUsdt: new Decimal(record.settleableUsdt.toString()),
|
||||||
|
settleableHashpower: new Decimal(record.settleableHashpower.toString()),
|
||||||
|
settledTotalUsdt: new Decimal(record.settledTotalUsdt.toString()),
|
||||||
|
settledTotalHashpower: new Decimal(record.settledTotalHashpower.toString()),
|
||||||
|
expiredTotalUsdt: new Decimal(record.expiredTotalUsdt.toString()),
|
||||||
|
expiredTotalHashpower: new Decimal(record.expiredTotalHashpower.toString()),
|
||||||
|
status: record.status,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Global prefix
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: '*',
|
||||||
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Wallet Service API')
|
||||||
|
.setDescription('RWA钱包账本服务API - 管理用户余额、充值、提现、流水记账等功能')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.APP_PORT || 3002;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Wallet Service is running on port ${port}`);
|
||||||
|
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface CurrentUserPayload {
|
||||||
|
userId: string;
|
||||||
|
accountSequence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user as CurrentUserPayload;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return user?.[data];
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './current-user.decorator';
|
||||||
|
export * from './public.decorator';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
export class DomainError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DomainError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsufficientBalanceError extends DomainError {
|
||||||
|
constructor(assetType: string, required: string, available: string) {
|
||||||
|
super(`Insufficient ${assetType} balance: required ${required}, available ${available}`);
|
||||||
|
this.name = 'InsufficientBalanceError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletFrozenError extends DomainError {
|
||||||
|
constructor(walletId: string) {
|
||||||
|
super(`Wallet ${walletId} is frozen`);
|
||||||
|
this.name = 'WalletFrozenError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletNotFoundError extends DomainError {
|
||||||
|
constructor(identifier: string) {
|
||||||
|
super(`Wallet not found: ${identifier}`);
|
||||||
|
this.name = 'WalletNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DuplicateTransactionError extends DomainError {
|
||||||
|
constructor(txHash: string) {
|
||||||
|
super(`Duplicate transaction: ${txHash}`);
|
||||||
|
this.name = 'DuplicateTransactionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidOperationError extends DomainError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'InvalidOperationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './domain.exception';
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import {
|
||||||
|
DomainError, InsufficientBalanceError, WalletFrozenError,
|
||||||
|
WalletNotFoundError, DuplicateTransactionError,
|
||||||
|
} from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
@Catch(DomainError)
|
||||||
|
export class DomainExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: DomainError, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
|
||||||
|
let status = HttpStatus.BAD_REQUEST;
|
||||||
|
let code = 'DOMAIN_ERROR';
|
||||||
|
|
||||||
|
if (exception instanceof InsufficientBalanceError) {
|
||||||
|
status = HttpStatus.UNPROCESSABLE_ENTITY;
|
||||||
|
code = 'INSUFFICIENT_BALANCE';
|
||||||
|
} else if (exception instanceof WalletFrozenError) {
|
||||||
|
status = HttpStatus.FORBIDDEN;
|
||||||
|
code = 'WALLET_FROZEN';
|
||||||
|
} else if (exception instanceof WalletNotFoundError) {
|
||||||
|
status = HttpStatus.NOT_FOUND;
|
||||||
|
code = 'WALLET_NOT_FOUND';
|
||||||
|
} else if (exception instanceof DuplicateTransactionError) {
|
||||||
|
status = HttpStatus.CONFLICT;
|
||||||
|
code = 'DUPLICATE_TRANSACTION';
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
success: false,
|
||||||
|
code,
|
||||||
|
message: exception.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { IS_PUBLIC_KEY } from '@/shared/decorators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface Response<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map(data => ({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
seq: number;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('JWT_SECRET') || 'default-secret',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
accountSequence: payload.seq,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { PrismaService } from '../src/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('Wallet Service (e2e) - Real Database', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let authToken: string;
|
||||||
|
const testUserId = '99999'; // Unique test user ID
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || 'test-jwt-secret-key-for-e2e-testing';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
// ValidationPipe is needed for request validation
|
||||||
|
// DomainExceptionFilter and TransformInterceptor are already provided globally by AppModule
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
// Generate test JWT token
|
||||||
|
authToken = jwt.sign(
|
||||||
|
{ sub: testUserId, seq: 1001 },
|
||||||
|
jwtSecret,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up any existing test data
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up test data
|
||||||
|
await cleanupTestData();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
try {
|
||||||
|
await prisma.ledgerEntry.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.depositOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.settlementOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
await prisma.walletAccount.deleteMany({ where: { userId: BigInt(testUserId) } });
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Cleanup error (may be expected):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Health Check', () => {
|
||||||
|
it('/api/v1/health (GET) - should return health status', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/health')
|
||||||
|
.expect(200)
|
||||||
|
.expect(res => {
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data.status).toBe('ok');
|
||||||
|
expect(res.body.data.service).toBe('wallet-service');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication', () => {
|
||||||
|
it('/api/v1/wallet/my-wallet (GET) - should reject without auth', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/my-wallet (GET) - should reject with invalid token', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.set('Authorization', 'Bearer invalid_token')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Wallet Operations', () => {
|
||||||
|
it('/api/v1/wallet/my-wallet (GET) - should return wallet info (creates wallet if not exists)', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data).toHaveProperty('walletId');
|
||||||
|
expect(res.body.data).toHaveProperty('userId', testUserId);
|
||||||
|
expect(res.body.data).toHaveProperty('balances');
|
||||||
|
expect(res.body.data).toHaveProperty('hashpower');
|
||||||
|
expect(res.body.data).toHaveProperty('rewards');
|
||||||
|
expect(res.body.data).toHaveProperty('status');
|
||||||
|
expect(res.body.data.status).toBe('ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/my-wallet (GET) - should have correct balance structure', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const { balances } = res.body.data;
|
||||||
|
expect(balances).toHaveProperty('usdt');
|
||||||
|
expect(balances).toHaveProperty('dst');
|
||||||
|
expect(balances).toHaveProperty('bnb');
|
||||||
|
expect(balances).toHaveProperty('og');
|
||||||
|
expect(balances).toHaveProperty('rwad');
|
||||||
|
|
||||||
|
expect(balances.usdt).toHaveProperty('available');
|
||||||
|
expect(balances.usdt).toHaveProperty('frozen');
|
||||||
|
expect(typeof balances.usdt.available).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ledger Operations', () => {
|
||||||
|
it('/api/v1/wallet/ledger/my-ledger (GET) - should return ledger entries', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/ledger/my-ledger')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data).toHaveProperty('data');
|
||||||
|
expect(res.body.data).toHaveProperty('total');
|
||||||
|
expect(res.body.data).toHaveProperty('page');
|
||||||
|
expect(res.body.data).toHaveProperty('pageSize');
|
||||||
|
expect(res.body.data).toHaveProperty('totalPages');
|
||||||
|
expect(Array.isArray(res.body.data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/ledger/my-ledger (GET) - should support pagination', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/ledger/my-ledger?page=1&pageSize=5')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.data.page).toBe(1);
|
||||||
|
expect(res.body.data.pageSize).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/ledger/my-ledger (GET) - should reject invalid pagination', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/ledger/my-ledger?page=0')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Deposit Operations (Internal API)', () => {
|
||||||
|
const testTxHash = `test_tx_e2e_${Date.now()}`;
|
||||||
|
|
||||||
|
it('/api/v1/wallet/deposit (POST) - should process deposit', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/deposit')
|
||||||
|
.send({
|
||||||
|
userId: testUserId,
|
||||||
|
amount: 100,
|
||||||
|
chainType: 'KAVA',
|
||||||
|
txHash: testTxHash,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data.message).toBe('Deposit processed successfully');
|
||||||
|
|
||||||
|
// Verify balance was updated
|
||||||
|
const walletRes = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(walletRes.body.data.balances.usdt.available).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/deposit (POST) - should reject duplicate transaction', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/deposit')
|
||||||
|
.send({
|
||||||
|
userId: testUserId,
|
||||||
|
amount: 50,
|
||||||
|
chainType: 'KAVA',
|
||||||
|
txHash: testTxHash, // Same txHash as before
|
||||||
|
})
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
expect(res.body.code).toBe('DUPLICATE_TRANSACTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/deposit (POST) - should validate request body', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/deposit')
|
||||||
|
.send({
|
||||||
|
userId: testUserId,
|
||||||
|
amount: -100, // Invalid negative amount
|
||||||
|
chainType: 'KAVA',
|
||||||
|
txHash: 'invalid_tx',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/deposit (POST) - should validate chain type', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/deposit')
|
||||||
|
.send({
|
||||||
|
userId: testUserId,
|
||||||
|
amount: 100,
|
||||||
|
chainType: 'INVALID_CHAIN',
|
||||||
|
txHash: 'tx_invalid_chain',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/deposit (POST) - should process BSC deposit', async () => {
|
||||||
|
const bscTxHash = `test_bsc_tx_${Date.now()}`;
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/deposit')
|
||||||
|
.send({
|
||||||
|
userId: testUserId,
|
||||||
|
amount: 50,
|
||||||
|
chainType: 'BSC',
|
||||||
|
txHash: bscTxHash,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify balance was updated (should be 100 + 50 = 150)
|
||||||
|
const walletRes = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/my-wallet')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(walletRes.body.data.balances.usdt.available).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ledger After Deposits', () => {
|
||||||
|
it('should have ledger entries after deposits', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/wallet/ledger/my-ledger')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.data.total).toBeGreaterThanOrEqual(2); // At least 2 deposits
|
||||||
|
expect(res.body.data.data.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Check ledger entry structure
|
||||||
|
const entry = res.body.data.data[0];
|
||||||
|
expect(entry).toHaveProperty('id');
|
||||||
|
expect(entry).toHaveProperty('entryType');
|
||||||
|
expect(entry).toHaveProperty('amount');
|
||||||
|
expect(entry).toHaveProperty('assetType');
|
||||||
|
expect(entry).toHaveProperty('createdAt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settlement Operations', () => {
|
||||||
|
it('/api/v1/wallet/claim-rewards (POST) - should handle no pending rewards', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/claim-rewards')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/settle (POST) - should validate settle currency', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/settle')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({
|
||||||
|
usdtAmount: 10,
|
||||||
|
settleCurrency: 'INVALID',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/v1/wallet/settle (POST) - should validate amount', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/settle')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({
|
||||||
|
usdtAmount: -10,
|
||||||
|
settleCurrency: 'BNB',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should return 404 for non-existent routes', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/non-existent')
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed JSON', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post('/api/v1/wallet/deposit')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send('{ invalid json }')
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Database Integrity', () => {
|
||||||
|
it('should persist wallet data correctly', async () => {
|
||||||
|
const wallet = await prisma.walletAccount.findFirst({
|
||||||
|
where: { userId: BigInt(testUserId) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wallet).not.toBeNull();
|
||||||
|
expect(wallet?.status).toBe('ACTIVE');
|
||||||
|
expect(Number(wallet?.usdtAvailable)).toBe(150); // 100 + 50 from deposits
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist ledger entries correctly', async () => {
|
||||||
|
const entries = await prisma.ledgerEntry.findMany({
|
||||||
|
where: { userId: BigInt(testUserId) },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Check that deposits are recorded
|
||||||
|
const depositEntries = entries.filter(e =>
|
||||||
|
e.entryType === 'DEPOSIT_KAVA' || e.entryType === 'DEPOSIT_BSC'
|
||||||
|
);
|
||||||
|
expect(depositEntries.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist deposit orders correctly', async () => {
|
||||||
|
const deposits = await prisma.depositOrder.findMany({
|
||||||
|
where: { userId: BigInt(testUserId) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deposits.length).toBe(2);
|
||||||
|
deposits.forEach(deposit => {
|
||||||
|
expect(deposit.status).toBe('CONFIRMED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||||
|
},
|
||||||
|
"testTimeout": 30000,
|
||||||
|
"verbose": true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Simple E2E Test', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
console.log('Starting test setup...');
|
||||||
|
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
console.log('JWT_SECRET:', process.env.JWT_SECRET ? 'SET' : 'NOT SET');
|
||||||
|
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
console.log('Module compiled');
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
console.log('App initialized');
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
console.log('Closing app...');
|
||||||
|
if (app) {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
console.log('App closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('health check should work', async () => {
|
||||||
|
console.log('Running health check test');
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/health')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
console.log('Health check response:', res.body);
|
||||||
|
expect(res.body).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue