rwadurian/backend/services/mining-service/DEVELOPMENT_GUIDE.md

23 KiB
Raw Permalink Blame History

Mining Service (挖矿服务) 开发指导

1. 服务概述

1.1 核心职责

Mining Service 负责积分股的挖矿分配和全局状态管理,是代币经济的核心引擎。

主要功能:

  • 管理积分股全局状态(总量、黑洞、流通池、价格)
  • 执行每分钟销毁(维护币价上涨)
  • 计算并分配每日/每小时/每分钟/每秒的积分股
  • 维护挖矿明细账(每个用户获得的积分股来源)
  • 管理分配阶段2年减半规则

1.2 技术栈

  • 框架: NestJS + TypeScript
  • 数据库: PostgreSQL (事务型) + Redis (实时状态)
  • ORM: Prisma
  • 消息队列: Kafka

1.3 端口分配

  • HTTP: 3021
  • 数据库: rwa_mining

2. 架构设计

2.1 目录结构

mining-service/
├── src/
│   ├── api/
│   │   ├── controllers/
│   │   │   ├── share-account.controller.ts       # 用户积分股账户API
│   │   │   ├── global-state.controller.ts        # 全局状态API
│   │   │   ├── mining-record.controller.ts       # 挖矿记录API
│   │   │   └── health.controller.ts
│   │   └── dto/
│   │       ├── request/
│   │       └── response/
│   │           ├── share-account.response.ts
│   │           ├── global-state.response.ts
│   │           ├── mining-record.response.ts
│   │           └── realtime-earning.response.ts
│   │
│   ├── application/
│   │   ├── commands/
│   │   │   ├── execute-minute-burn.command.ts          # 每分钟销毁
│   │   │   ├── distribute-daily-shares.command.ts      # 每日分配
│   │   │   ├── calculate-user-earning.command.ts       # 计算用户收益
│   │   │   ├── advance-distribution-phase.command.ts   # 阶段推进
│   │   │   └── initialize-global-state.command.ts      # 初始化
│   │   ├── queries/
│   │   │   ├── get-user-share-account.query.ts
│   │   │   ├── get-global-state.query.ts
│   │   │   ├── get-user-mining-records.query.ts
│   │   │   └── get-realtime-earning.query.ts
│   │   ├── services/
│   │   │   ├── mining-distribution.service.ts
│   │   │   ├── burn-executor.service.ts
│   │   │   └── price-calculator.service.ts
│   │   ├── event-handlers/
│   │   │   ├── contribution-snapshot-created.handler.ts
│   │   │   └── trade-completed.handler.ts
│   │   └── schedulers/
│   │       ├── minute-burn.scheduler.ts               # 每分钟销毁定时器
│   │       ├── daily-distribution.scheduler.ts        # 每日分配定时器
│   │       └── phase-check.scheduler.ts               # 阶段检查
│   │
│   ├── domain/
│   │   ├── aggregates/
│   │   │   ├── share-global-state.aggregate.ts
│   │   │   ├── share-account.aggregate.ts
│   │   │   └── mining-record.aggregate.ts
│   │   ├── repositories/
│   │   │   ├── share-global-state.repository.interface.ts
│   │   │   ├── share-account.repository.interface.ts
│   │   │   ├── mining-record.repository.interface.ts
│   │   │   └── burn-record.repository.interface.ts
│   │   ├── value-objects/
│   │   │   ├── share-amount.vo.ts
│   │   │   ├── share-price.vo.ts
│   │   │   └── distribution-phase.vo.ts
│   │   ├── events/
│   │   │   ├── shares-distributed.event.ts
│   │   │   ├── shares-burned.event.ts
│   │   │   ├── price-updated.event.ts
│   │   │   └── phase-advanced.event.ts
│   │   └── services/
│   │       ├── burn-calculator.domain-service.ts
│   │       └── distribution-calculator.domain-service.ts
│   │
│   ├── infrastructure/
│   │   ├── persistence/
│   │   │   ├── prisma/
│   │   │   │   └── prisma.service.ts
│   │   │   ├── repositories/
│   │   │   │   ├── share-global-state.repository.impl.ts
│   │   │   │   ├── share-account.repository.impl.ts
│   │   │   │   ├── mining-record.repository.impl.ts
│   │   │   │   └── burn-record.repository.impl.ts
│   │   │   └── unit-of-work/
│   │   │       └── unit-of-work.service.ts
│   │   ├── kafka/
│   │   │   ├── contribution-event-consumer.service.ts
│   │   │   ├── trade-event-consumer.service.ts
│   │   │   ├── event-publisher.service.ts
│   │   │   └── kafka.module.ts
│   │   ├── redis/
│   │   │   ├── global-state-cache.service.ts       # 实时状态缓存
│   │   │   ├── price-cache.service.ts              # 价格缓存
│   │   │   └── earning-cache.service.ts            # 收益缓存
│   │   └── infrastructure.module.ts
│   │
│   ├── shared/
│   ├── config/
│   ├── app.module.ts
│   └── main.ts
│
├── prisma/
│   ├── schema.prisma
│   └── migrations/
├── package.json
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml

3. 数据库设计

3.1 数据库类型选择

表类型 数据库类型 原因
全局状态表 事务型 + Redis 需要强一致性 + 实时读取
用户账户表 事务型 余额变更需要事务
挖矿明细表 事务型 明细账需要完整性
销毁记录表 事务型 审计追踪

3.2 核心表结构

-- ============================================
-- 全局状态表(单例)
-- ============================================

CREATE TABLE share_global_state (
    id UUID PRIMARY KEY DEFAULT '00000000-0000-0000-0000-000000000001',

    -- 积分股总量与池子
    total_supply DECIMAL(30,10) DEFAULT 10002000000,        -- 100.02亿
    original_pool DECIMAL(30,10) DEFAULT 2000000,           -- 200万用于分配
    black_hole_amount DECIMAL(30,10) DEFAULT 0,             -- 黑洞(已销毁)
    circulation_pool DECIMAL(30,10) DEFAULT 0,              -- 流通池(卖出的)

    -- 积分股池(决定价格)
    share_pool_green_points DECIMAL(30,10) DEFAULT 0,       -- 池中绿积分

    -- 价格
    current_price DECIMAL(30,18) DEFAULT 0,

    -- 销毁相关
    minute_burn_rate DECIMAL(30,18),                        -- 每分钟销毁量
    total_minutes_remaining BIGINT,                         -- 剩余分钟数
    last_burn_at TIMESTAMP WITH TIME ZONE,

    -- 分配相关
    distribution_phase INT DEFAULT 1,                       -- 当前阶段 (1,2,3...)
    daily_distribution DECIMAL(30,18),                      -- 每日分配量
    total_distributed DECIMAL(30,10) DEFAULT 0,             -- 累计已分配

    -- 系统控制
    transfer_enabled BOOLEAN DEFAULT FALSE,
    system_start_at TIMESTAMP WITH TIME ZONE,
    is_initialized BOOLEAN DEFAULT FALSE,

    -- 乐观锁
    version INT DEFAULT 1,

    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- ============================================
-- 用户积分股账户
-- ============================================

CREATE TABLE share_accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_sequence VARCHAR(20) NOT NULL UNIQUE,

    -- 余额
    available_balance DECIMAL(30,10) DEFAULT 0,             -- 可用余额
    frozen_balance DECIMAL(30,10) DEFAULT 0,                -- 冻结余额

    -- 统计
    total_mined DECIMAL(30,10) DEFAULT 0,                   -- 累计挖矿获得
    total_sold DECIMAL(30,10) DEFAULT 0,                    -- 累计卖出
    total_bought DECIMAL(30,10) DEFAULT 0,                  -- 累计买入

    -- 实时收益计算参数(缓存)
    last_contribution_ratio DECIMAL(30,18) DEFAULT 0,       -- 最近算力占比
    per_second_earning DECIMAL(30,18) DEFAULT 0,            -- 每秒收益

    -- 乐观锁
    version INT DEFAULT 1,

    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- ============================================
-- 挖矿明细账
-- ============================================

-- 每日挖矿汇总记录
CREATE TABLE daily_mining_records (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_sequence VARCHAR(20) NOT NULL,
    mining_date DATE NOT NULL,

    -- 算力快照
    effective_contribution DECIMAL(30,10) NOT NULL,
    network_total_contribution DECIMAL(30,10) NOT NULL,
    contribution_ratio DECIMAL(30,18) NOT NULL,

    -- 分配结果
    daily_network_distribution DECIMAL(30,18) NOT NULL,     -- 当日全网分配量
    mined_amount DECIMAL(30,18) NOT NULL,                   -- 用户获得量

    -- 分配阶段
    distribution_phase INT NOT NULL,

    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    UNIQUE(account_sequence, mining_date)
);
CREATE INDEX idx_daily_mining_account ON daily_mining_records(account_sequence);
CREATE INDEX idx_daily_mining_date ON daily_mining_records(mining_date);

-- 小时级挖矿记录(更细粒度追踪)
CREATE TABLE hourly_mining_records (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_sequence VARCHAR(20) NOT NULL,
    mining_hour TIMESTAMP WITH TIME ZONE NOT NULL,

    contribution_ratio DECIMAL(30,18) NOT NULL,
    hourly_distribution DECIMAL(30,18) NOT NULL,
    mined_amount DECIMAL(30,18) NOT NULL,

    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    UNIQUE(account_sequence, mining_hour)
);

-- ============================================
-- 销毁记录(明细账)
-- ============================================

CREATE TABLE burn_records (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    burn_type VARCHAR(20) NOT NULL,                         -- SCHEDULED / SELL_TRIGGERED
    burn_amount DECIMAL(30,10) NOT NULL,

    -- 触发信息
    trigger_trade_id UUID,                                  -- 卖出触发时的交易ID
    trigger_account_sequence VARCHAR(20),                   -- 触发用户

    -- 状态快照
    before_black_hole DECIMAL(30,10),
    after_black_hole DECIMAL(30,10),
    before_price DECIMAL(30,18),
    after_price DECIMAL(30,18),
    before_minute_rate DECIMAL(30,18),
    after_minute_rate DECIMAL(30,18),

    -- 计算参数(审计用)
    remaining_minutes_at_burn BIGINT,
    total_to_burn_at_time DECIMAL(30,10),

    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_burn_records_type ON burn_records(burn_type);
CREATE INDEX idx_burn_records_time ON burn_records(created_at);

-- ============================================
-- 分配阶段配置
-- ============================================

CREATE TABLE distribution_phase_configs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    phase_number INT NOT NULL UNIQUE,
    duration_years INT DEFAULT 2,
    total_shares DECIMAL(30,10) NOT NULL,                   -- 该阶段总分配量
    daily_shares DECIMAL(30,18) NOT NULL,                   -- 每日分配量

    start_date DATE,
    end_date DATE,

    is_active BOOLEAN DEFAULT FALSE,

    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- 初始化数据
INSERT INTO distribution_phase_configs (phase_number, total_shares, daily_shares) VALUES
(1, 1000000, 1369.86301369863),      -- 第一个两年100万
(2, 500000, 684.9315068493151),       -- 第二个两年50万
(3, 250000, 342.46575342465753),      -- 第三个两年25万
(4, 125000, 171.23287671232876);      -- 第四个两年12.5万

-- ============================================
-- 价格历史(每分钟记录)
-- ============================================

CREATE TABLE price_ticks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tick_time TIMESTAMP WITH TIME ZONE NOT NULL,

    price DECIMAL(30,18) NOT NULL,

    -- 状态快照
    share_pool_green_points DECIMAL(30,10),
    black_hole_amount DECIMAL(30,10),
    circulation_pool DECIMAL(30,10),
    effective_supply DECIMAL(30,10),                        -- 有效供应量

    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_price_ticks_time ON price_ticks(tick_time);

-- ============================================
-- 同步的算力快照(从 contribution-service
-- ============================================

CREATE TABLE synced_contribution_snapshots (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    snapshot_date DATE NOT NULL,
    account_sequence VARCHAR(20) NOT NULL,

    effective_contribution DECIMAL(30,10) NOT NULL,
    network_total_contribution DECIMAL(30,10) NOT NULL,
    contribution_ratio DECIMAL(30,18) NOT NULL,

    source_sequence_num BIGINT NOT NULL,
    synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    -- 是否已用于分配
    distribution_processed BOOLEAN DEFAULT FALSE,
    distribution_processed_at TIMESTAMP WITH TIME ZONE,

    UNIQUE(snapshot_date, account_sequence)
);

-- ============================================
-- 已处理事件(幂等性)
-- ============================================

CREATE TABLE processed_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id VARCHAR(100) NOT NULL UNIQUE,
    event_type VARCHAR(50) NOT NULL,
    source_service VARCHAR(50),
    processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

4. 核心业务逻辑

4.1 价格计算公式

/**
 * 积分股价格 = 积分股池绿积分 ÷ (100.02亿 - 黑洞量 - 流通池量)
 */
function calculatePrice(state: GlobalState): Decimal {
  const effectiveSupply = state.totalSupply
    .minus(state.blackHoleAmount)
    .minus(state.circulationPool);

  if (effectiveSupply.isZero()) {
    return new Decimal(0);
  }

  return state.sharePoolGreenPoints.dividedBy(effectiveSupply);
}

4.2 每分钟销毁逻辑

/**
 * 定时销毁任务 - 每分钟执行
 * 目的:让代币价格持续上涨
 */
@Cron('* * * * *') // 每分钟
async executeMinuteBurn(): Promise<void> {
  await this.unitOfWork.runInTransaction(async (tx) => {
    const state = await this.globalStateRepo.getForUpdate(tx);

    // 计算当前每分钟销毁量
    // 每分钟销毁量 = (100亿 - 黑洞总量) ÷ 剩余分钟数
    const remainingToBurn = new Decimal('10000000000').minus(state.blackHoleAmount);
    const remainingMinutes = this.calculateRemainingMinutes(state);

    const burnAmount = remainingToBurn.dividedBy(remainingMinutes);

    // 执行销毁
    const newBlackHole = state.blackHoleAmount.plus(burnAmount);

    // 重新计算价格
    const newPrice = this.calculatePrice({
      ...state,
      blackHoleAmount: newBlackHole
    });

    // 更新全局状态
    await this.globalStateRepo.update(tx, {
      blackHoleAmount: newBlackHole,
      currentPrice: newPrice,
      minuteBurnRate: burnAmount,
      lastBurnAt: new Date(),
      version: state.version + 1
    });

    // 记录销毁明细
    await this.burnRecordRepo.create(tx, {
      burnType: 'SCHEDULED',
      burnAmount,
      beforeBlackHole: state.blackHoleAmount,
      afterBlackHole: newBlackHole,
      beforePrice: state.currentPrice,
      afterPrice: newPrice,
      remainingMinutesAtBurn: remainingMinutes,
    });

    // 记录价格
    await this.priceTickRepo.create(tx, {
      tickTime: new Date(),
      price: newPrice,
      sharePoolGreenPoints: state.sharePoolGreenPoints,
      blackHoleAmount: newBlackHole,
      circulationPool: state.circulationPool,
    });

    // 更新 Redis 缓存
    await this.globalStateCache.update({
      price: newPrice,
      blackHoleAmount: newBlackHole,
      minuteBurnRate: burnAmount,
    });
  });
}

/**
 * 计算剩余分钟数
 * 4年 = 365 × 4 × 24 × 60 = 2,102,400 分钟
 */
private calculateRemainingMinutes(state: GlobalState): number {
  const totalMinutes = 365 * 4 * 24 * 60; // 2,102,400
  const elapsedMinutes = this.getElapsedMinutes(state.systemStartAt);
  return Math.max(totalMinutes - elapsedMinutes, 1); // 至少1分钟避免除零
}

4.3 每日积分股分配

/**
 * 每日分配任务 - 凌晨执行
 * 根据用户算力占比分配当日积分股
 */
@Cron('0 0 * * *') // 每天0点
async distributeDailyShares(): Promise<void> {
  const today = new Date();
  const yesterday = subDays(today, 1);

  // 获取昨日算力快照
  const snapshots = await this.syncedSnapshotRepo.findByDate(yesterday);

  if (snapshots.length === 0) {
    this.logger.warn('No contribution snapshots for distribution');
    return;
  }

  const state = await this.globalStateRepo.get();
  const dailyDistribution = state.dailyDistribution;

  // 批量处理用户分配
  for (const snapshot of snapshots) {
    await this.unitOfWork.runInTransaction(async (tx) => {
      // 计算用户应得份额
      const userShare = dailyDistribution.multipliedBy(snapshot.contributionRatio);

      // 更新用户账户
      const account = await this.shareAccountRepo.getByAccountSequence(
        tx,
        snapshot.accountSequence
      );

      await this.shareAccountRepo.update(tx, {
        id: account.id,
        availableBalance: account.availableBalance.plus(userShare),
        totalMined: account.totalMined.plus(userShare),
        lastContributionRatio: snapshot.contributionRatio,
        perSecondEarning: this.calculatePerSecondEarning(snapshot.contributionRatio, state),
        version: account.version + 1,
      });

      // 记录挖矿明细
      await this.dailyMiningRecordRepo.create(tx, {
        accountSequence: snapshot.accountSequence,
        miningDate: yesterday,
        effectiveContribution: snapshot.effectiveContribution,
        networkTotalContribution: snapshot.networkTotalContribution,
        contributionRatio: snapshot.contributionRatio,
        dailyNetworkDistribution: dailyDistribution,
        minedAmount: userShare,
        distributionPhase: state.distributionPhase,
      });

      // 标记快照已处理
      await this.syncedSnapshotRepo.markProcessed(tx, snapshot.id);
    });
  }

  // 更新全局分配统计
  await this.globalStateRepo.addDistributed(dailyDistribution);
}

/**
 * 计算每秒收益(用于前端实时显示)
 * 每秒收益 = 每日分配量 × 用户占比 ÷ 86400
 */
private calculatePerSecondEarning(
  contributionRatio: Decimal,
  state: GlobalState
): Decimal {
  return state.dailyDistribution
    .multipliedBy(contributionRatio)
    .dividedBy(86400);
}

4.4 实时收益查询(前端显示)

/**
 * 获取用户实时收益(用于前端每秒更新显示)
 */
async getRealtimeEarning(accountSequence: string): Promise<RealtimeEarningDto> {
  // 优先从 Redis 获取
  const cached = await this.earningCache.get(accountSequence);
  if (cached) {
    return cached;
  }

  const account = await this.shareAccountRepo.getByAccountSequence(accountSequence);
  const state = await this.globalStateCache.get();

  // 计算显示资产
  // 资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价
  const multiplier = this.calculateMultiplier(state);
  const effectiveShares = account.availableBalance
    .multipliedBy(new Decimal(1).plus(multiplier));
  const displayAssetValue = effectiveShares.multipliedBy(state.currentPrice);

  const result = {
    accountSequence,
    availableBalance: account.availableBalance.toString(),
    perSecondEarning: account.perSecondEarning.toString(),
    displayAssetValue: displayAssetValue.toString(),
    currentPrice: state.currentPrice.toString(),
    multiplier: multiplier.toString(),
  };

  // 缓存 10 秒
  await this.earningCache.set(accountSequence, result, 10);

  return result;
}

/**
 * 计算倍数
 * 倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)
 */
private calculateMultiplier(state: GlobalState): Decimal {
  const numerator = new Decimal('10000000000').minus(state.blackHoleAmount);
  const denominator = new Decimal('2000000').minus(state.circulationPool);

  if (denominator.isZero() || denominator.isNegative()) {
    return new Decimal(0);
  }

  return numerator.dividedBy(denominator);
}

5. 服务间通信

5.1 订阅的事件

Topic 来源服务 数据内容 处理方式
contribution.daily-snapshot-created contribution-service 每日算力快照 用于挖矿分配
trading.trade-completed trading-service 交易完成事件 更新流通池、触发卖出销毁

5.2 发布的事件

Topic 事件类型 订阅者
mining.shares-distributed SharesDistributed trading-service
mining.price-updated PriceUpdated trading-service, mining-admin
mining.shares-burned SharesBurned mining-admin

5.3 Redis 缓存结构

// 全局状态缓存 - 高频读取
interface GlobalStateCache {
  key: 'mining:global-state';
  ttl: 60; // 60秒
  data: {
    currentPrice: string;
    blackHoleAmount: string;
    circulationPool: string;
    minuteBurnRate: string;
    dailyDistribution: string;
  };
}

// 用户收益缓存 - 实时查询
interface UserEarningCache {
  key: `mining:earning:${accountSequence}`;
  ttl: 10; // 10秒
  data: RealtimeEarningDto;
}

6. 关键计算公式汇总

6.1 价格相关

积分股价格 = 积分股池绿积分 ÷ (100.02亿 - 黑洞量 - 流通池量)

每分钟销毁量 = (100亿 - 黑洞总量) ÷ 剩余分钟数

倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)

资产显示 = (账户积分股 + 账户积分股 × 倍数) × 积分股价

6.2 分配相关

第一个两年:每日分配 = 1000000 ÷ 730 = 1369.86301369863
第二个两年:每日分配 = 500000 ÷ 730 = 684.9315068493151
第N个两年每日分配 = 前一阶段 ÷ 2

用户每日获得 = 每日全网分配 × 用户算力占比
用户每秒获得 = 用户每日获得 ÷ 86400

7. 关键注意事项

7.1 精度处理

  • 价格使用 DECIMAL(30,18) - 18位小数
  • 积分股数量使用 DECIMAL(30,10) - 10位小数
  • 使用 Decimal.js 库处理所有数学运算,避免浮点数精度问题

7.2 并发控制

  • 全局状态更新使用乐观锁 (version 字段)
  • 定时任务使用分布式锁Redis避免多实例重复执行

7.3 性能优化

  • 全局状态缓存到 Redis避免频繁查库
  • 用户收益预计算 per_second_earning,前端直接使用
  • 批量处理每日分配,避免单条事务

7.4 数据一致性

  • 销毁操作必须在事务中同时更新:黑洞量、价格、销毁记录
  • 分配操作必须在事务中同时更新:用户余额、挖矿记录

8. 开发检查清单

  • 实现全局状态管理
  • 实现每分钟销毁定时任务
  • 实现每日积分股分配
  • 实现价格计算服务
  • 实现挖矿明细账记录
  • 实现销毁明细账记录
  • 实现实时收益查询 API
  • 实现分配阶段管理
  • 配置 Redis 缓存
  • 编写单元测试
  • 配置 Kafka Consumer

9. 启动命令

# 开发环境
npm run start:dev

# 初始化全局状态
npm run cli -- init-global-state

# 生产环境
npm run build && npm run start:prod