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