feat: 添加市场信息管理和订单ID复制功能

- 新增市场信息表和服务,支持市场名称缓存和自动更新
  - 创建 Market 实体和 MarketRepository
  - 实现 MarketService 提供市场信息查询和缓存
  - 实现 MarketPollingService 每30秒自动检查并更新缺失的市场信息
  - 添加数据库迁移 V19 创建 markets 表

- 订单列表显示市场名称
  - 在 BuyOrderInfo、SellOrderInfo、MatchedOrderInfo 中添加 marketTitle 字段
  - 订单查询时自动查询并填充市场名称
  - 前端订单列表显示市场名称(优先显示名称,ID作为辅助信息)

- 前端订单ID复制功能
  - 添加 isAutoGeneratedOrderId 和 copyToClipboard 工具函数
  - 非自动生成的订单ID支持一键复制
  - 买入、卖出、匹配订单列表均支持复制功能

- 仓位检查延迟检测机制优化
  - 首次检测到仓位不存在时先记录,3分钟后再次检查
  - 避免因API延迟导致的误判
This commit is contained in:
WrBug
2026-01-08 11:56:52 +08:00
parent e75c93ddf4
commit d376a82ccc
19 changed files with 1122 additions and 101 deletions
+137
View File
@@ -0,0 +1,137 @@
# 🎉 PolyHermes v1.1.7 发布公告
**发布日期:2026年1月7日**
---
## ✨ 新功能
### 💰 支持 Maker Rebates Program 费率
我们新增了对 Polymarket Maker Rebates Program 的支持!系统现在会自动获取并应用最新的费率,帮助您享受更优惠的交易成本。
**这意味着什么?**
- 系统会自动查询并应用最新的费率
- 所有订单(买入、卖出)都会使用正确的费率
- 无需手动配置,系统会自动处理
### 🔧 Docker 部署更灵活
现在您可以通过环境变量轻松配置日志级别,无需修改配置文件!
**新增配置项:**
- `LOG_LEVEL_ROOT` - 系统日志级别(默认:INFO
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
只需在 `.env` 文件中添加这些配置,重启服务即可生效。
---
## 🐛 问题修复
### 修复市场价格查询问题
修复了某些市场无法正确查询价格的问题。现在系统会:
- 优先从链上查询市场价格
- 如果链上查询失败,自动降级到其他数据源
- 提高系统的稳定性和容错性
### 修复自动卖出误判问题
修复了在某些情况下系统会误判市场已卖出,导致创建错误记录的问题。现在系统会:
- 更准确地判断市场状态
- 避免误判导致的错误记录
- 提高仓位管理的准确性
---
## 📝 文档更新
### 更新联系方式
- **Telegram 群组**https://t.me/polyhermes
- 欢迎加入我们的 Telegram 群组,获取最新资讯和技术支持!
### 版本信息更清晰
- README 中新增了 Docker 版本徽章
- 可以一目了然地看到最新的 Docker 镜像版本
---
## 📊 本次更新统计
- **5 个提交**
- **16 个文件变更**
- **主要改进**:费率支持、错误修复、部署优化
---
## 🚀 如何升级
### Docker 部署用户(推荐)
```bash
# 1. 停止当前服务
docker-compose -f docker-compose.prod.yml down
# 2. 拉取最新镜像
docker pull wrbug/polyhermes:latest
# 3. 重新启动服务
docker-compose -f docker-compose.prod.yml up -d
# 4. 查看日志确认升级成功
docker-compose -f docker-compose.prod.yml logs -f
```
### 本地构建用户
```bash
# 1. 拉取最新代码
git pull origin dev
# 2. 切换到 v1.1.7 标签
git checkout v1.1.7
# 3. 重新构建并启动
./deploy.sh
```
---
## ⚠️ 重要提示
### 数据库迁移
**本次更新无需数据库迁移**,可以直接升级,不会影响现有数据。
### 配置变更
- 新增的日志级别配置为**可选配置**
- 如果不配置,系统会使用默认值(INFO/DEBUG)
- 不影响现有功能
---
## 🙏 感谢
感谢所有用户的支持和反馈!如果您在使用过程中遇到任何问题,欢迎:
- 📧 提交 Issuehttps://github.com/WrBug/PolyHermes/issues
- 💬 加入 Telegram 群组:https://t.me/polyhermes
- 🐦 关注 Twitter@polyhermes
---
## 📚 相关链接
- **GitHub 仓库**https://github.com/WrBug/PolyHermes
- **完整更新日志**https://github.com/WrBug/PolyHermes/releases/tag/v1.1.7
- **Docker Hub**https://hub.docker.com/r/wrbug/polyhermes
---
**祝您交易顺利!** 🚀
@@ -40,6 +40,7 @@ data class BuyOrderInfo(
val orderId: String,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val side: String,
val quantity: String,
val price: String,
@@ -57,6 +58,7 @@ data class SellOrderInfo(
val orderId: String,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val side: String,
val quantity: String,
val price: String,
@@ -71,6 +73,8 @@ data class SellOrderInfo(
data class MatchedOrderInfo(
val sellOrderId: String,
val buyOrderId: String,
val marketId: String? = null, // 市场ID(从买入订单获取)
val marketTitle: String? = null, // 市场名称
val matchedQuantity: String,
val buyPrice: String,
val sellPrice: String,
@@ -0,0 +1,54 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 市场信息实体
* 用于缓存市场的基本信息(名称、slug等)
*/
@Entity
@Table(name = "markets", indexes = [
Index(name = "idx_market_id", columnList = "market_id", unique = true)
])
data class Market(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "market_id", unique = true, nullable = false, length = 100)
val marketId: String, // 市场IDcondition ID
@Column(name = "title", nullable = false, length = 500)
val title: String, // 市场名称(question
@Column(name = "slug", length = 200)
val slug: String? = null, // 市场slug
@Column(name = "category", length = 50)
val category: String? = null, // 市场分类
@Column(name = "icon", length = 500)
val icon: String? = null, // 市场图标URL
@Column(name = "image", length = 500)
val image: String? = null, // 市场图片URL
@Column(name = "description", columnDefinition = "TEXT")
val description: String? = null, // 市场描述
@Column(name = "active", nullable = false)
val active: Boolean = true, // 是否活跃
@Column(name = "closed", nullable = false)
val closed: Boolean = false, // 是否已关闭
@Column(name = "archived", nullable = false)
val archived: Boolean = false, // 是否已归档
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,12 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.Market
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface MarketRepository : JpaRepository<Market, Long> {
fun findByMarketId(marketId: String): Market?
fun findByMarketIdIn(marketIds: List<String>): List<Market>
}
@@ -62,6 +62,19 @@ class PositionCheckService(
// 记录已发送提示的配置(避免重复推送)
private val notifiedConfigs = ConcurrentHashMap<Long, Long>() // accountId/copyTradingId -> lastNotificationTime
// 待检查的仓位记录(延迟检测机制)
// key: "accountId_marketId_outcomeIndex_copyTradingId"
// value: PendingPositionCheck(包含订单列表和首次检测时间)
private data class PendingPositionCheck(
val accountId: Long,
val marketId: String,
val outcomeIndex: Int,
val copyTradingId: Long,
val orders: List<CopyOrderTracking>,
val firstDetectedTime: Long // 首次检测到仓位不存在的时间
)
private val pendingPositionChecks = ConcurrentHashMap<String, PendingPositionCheck>()
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
@@ -73,6 +86,7 @@ class PositionCheckService(
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
startSubscription()
startCacheCleanup()
startPendingPositionCheckTask()
}
/**
@@ -132,6 +146,121 @@ class PositionCheckService(
}
}
/**
* 启动待检查仓位的定期检查任务
* 每30秒检查一次,如果超过3分钟且确实不存在,则标记为已卖出
*/
private fun startPendingPositionCheckTask() {
scope.launch(Dispatchers.IO) {
while (isActive) {
try {
delay(30000) // 每30秒检查一次
checkPendingPositions()
} catch (e: Exception) {
logger.error("检查待检查仓位异常: ${e.message}", e)
}
}
}
}
/**
* 检查待检查的仓位
* 如果超过3分钟且确实不存在,则标记为已卖出
* 如果存在,则删除记录
*/
private suspend fun checkPendingPositions() {
if (pendingPositionChecks.isEmpty()) {
return
}
try {
// 获取最新的仓位数据
val result = accountService.getAllPositions()
if (result.isFailure) {
logger.warn("获取仓位数据失败,跳过待检查仓位验证: ${result.exceptionOrNull()?.message}")
return
}
val positionListResponse = result.getOrNull() ?: return
val currentPositions = positionListResponse.currentPositions
// 按账户和市场分组当前仓位
val positionsByAccountAndMarket = currentPositions.groupBy {
"${it.accountId}_${it.marketId}_${it.outcomeIndex ?: 0}"
}
val now = System.currentTimeMillis()
val checkDelay = 180000L // 3分钟 = 180000毫秒
val toRemove = mutableListOf<String>()
val toMarkAsSold = mutableListOf<PendingPositionCheck>()
// 遍历所有待检查的仓位
for ((key, pendingCheck) in pendingPositionChecks) {
// 先过滤出仍然有效的订单(remainingQuantity > 0
val validOrders = pendingCheck.orders.filter { order ->
// 重新从数据库查询订单状态,确保数据是最新的
val currentOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
currentOrder != null && currentOrder.remainingQuantity > BigDecimal.ZERO
}
// 如果没有有效订单了,删除记录
if (validOrders.isEmpty()) {
toRemove.add(key)
logger.info("待检查仓位的订单已全部处理,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}")
continue
}
val positionKey = "${pendingCheck.accountId}_${pendingCheck.marketId}_${pendingCheck.outcomeIndex}"
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position != null) {
// 仓位存在,删除记录
toRemove.add(key)
logger.info("待检查仓位已恢复,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${now - pendingCheck.firstDetectedTime}ms")
} else {
// 仓位不存在,检查是否超过3分钟
val elapsedTime = now - pendingCheck.firstDetectedTime
if (elapsedTime >= checkDelay) {
// 超过3分钟且确实不存在,标记为已卖出(使用有效订单)
toMarkAsSold.add(pendingCheck.copy(orders = validOrders))
toRemove.add(key)
logger.info("待检查仓位超过3分钟仍不存在,标记为已卖出: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
} else {
// 未超过3分钟,更新订单列表(移除已处理的订单)
if (validOrders.size < pendingCheck.orders.size) {
pendingPositionChecks[key] = pendingCheck.copy(orders = validOrders)
logger.debug("更新待检查仓位记录,移除已处理的订单: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
}
logger.debug("待检查仓位仍不存在,继续等待: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, remainingTime=${checkDelay - elapsedTime}ms")
}
}
}
// 删除已恢复或已处理的记录
toRemove.forEach { key ->
pendingPositionChecks.remove(key)
}
// 标记为已卖出
for (pendingCheck in toMarkAsSold) {
try {
val currentPrice = getCurrentMarketPrice(pendingCheck.marketId, pendingCheck.outcomeIndex)
updateOrdersAsSold(
pendingCheck.orders,
currentPrice,
pendingCheck.copyTradingId,
pendingCheck.marketId,
pendingCheck.outcomeIndex
)
} catch (e: Exception) {
logger.error("标记待检查仓位为已卖出失败: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查待检查仓位异常: ${e.message}", e)
}
}
/**
* 清理过期的缓存条目(超过2小时的记录)
*/
@@ -163,8 +292,16 @@ class PositionCheckService(
notifiedConfigs.remove(key)
}
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty()) {
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}")
// 清理过期的待检查仓位记录(超过1小时的记录,正常情况下应该在3分钟内处理完)
val expiredPendingChecks = pendingPositionChecks.entries.filter { (_, check) ->
(now - check.firstDetectedTime) > 3600000 // 1小时
}
expiredPendingChecks.forEach { (key, _) ->
pendingPositionChecks.remove(key)
}
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty() || expiredPendingChecks.isNotEmpty()) {
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}, pendingChecks=${expiredPendingChecks.size}")
}
}
@@ -371,31 +508,48 @@ class PositionCheckService(
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position == null) {
// 仓位不存在,检查订单创建时间
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
// 仓位不存在,使用延迟检测机制
// 先检查订单创建时间,只有超过2分钟的订单才进入延迟检测
val now = System.currentTimeMillis()
val ordersToMarkAsSold = orders.filter { order ->
val ordersToCheck = orders.filter { order ->
val orderAge = now - order.createdAt
orderAge > 120000 // 2分钟 = 120000毫秒
}
if (ordersToMarkAsSold.isNotEmpty()) {
// 有订单创建时间超过2分钟,认为仓位已被出售
try {
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
} catch (e: Exception) {
logger.warn("无法获取市场价格,跳过标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
continue
if (ordersToCheck.isNotEmpty()) {
// 有订单创建时间超过2分钟,记录到待检查列表
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
// 如果已经存在记录,更新订单列表(可能订单状态有变化)
val existingCheck = pendingPositionChecks[checkKey]
if (existingCheck == null) {
// 首次检测到,记录
pendingPositionChecks[checkKey] = PendingPositionCheck(
accountId = copyTrading.accountId,
marketId = marketId,
outcomeIndex = outcomeIndex,
copyTradingId = copyTrading.id!!,
orders = ordersToCheck,
firstDetectedTime = now
)
logger.info("首次检测到仓位不存在,记录待检查: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, positionKey=$positionKey")
} else {
// 已存在记录,更新订单列表(可能订单状态有变化)
pendingPositionChecks[checkKey] = existingCheck.copy(orders = ordersToCheck)
logger.debug("更新待检查仓位记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, elapsedTime=${now - existingCheck.firstDetectedTime}ms")
}
} else {
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}, positionKey=$positionKey")
}
} else {
// 有仓位,先检查是否有对应的待检查记录,如果有则删除(仓位已恢复)
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
val pendingCheck = pendingPositionChecks.remove(checkKey)
if (pendingCheck != null) {
logger.info("仓位已恢复,删除待检查记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, elapsedTime=${System.currentTimeMillis() - pendingCheck.firstDetectedTime}ms")
}
// 有仓位,按订单下单顺序(FIFO)更新状态
// 计算逻辑:
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
@@ -0,0 +1,173 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
/**
* 市场信息轮询服务
* 定期检查买入订单的市场信息是否缺失,如果缺失则从API获取并更新
*/
@Service
class MarketPollingService(
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val marketService: MarketService
) {
private val logger = LoggerFactory.getLogger(MarketPollingService::class.java)
@Value("\${market.polling.interval:30000}")
private var pollingInterval: Long = 30000 // 轮询间隔(毫秒),默认30秒
@Value("\${market.polling.batch.size:50}")
private var batchSize: Int = 50 // 批量处理大小,每次最多处理50个市场
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮询)
*/
@PostConstruct
fun init() {
logger.info("MarketPollingService 初始化,启动市场信息轮询任务,轮询间隔: ${pollingInterval}ms (${pollingInterval / 1000 / 60}分钟)")
startPolling()
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
}
scope.cancel()
}
/**
* 启动轮询任务
*/
private fun startPolling() {
synchronized(lock) {
// 如果已经有轮询任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮询任务
pollingJob = scope.launch(Dispatchers.IO) {
// 启动时立即执行一次
try {
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("初始检查市场信息失败: ${e.message}", e)
}
// 然后按间隔定期执行
while (isActive) {
try {
delay(pollingInterval)
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("轮询市场信息失败: ${e.message}", e)
}
}
}
}
}
/**
* 检查并更新缺失的市场信息
*/
private suspend fun checkAndUpdateMissingMarkets() {
try {
logger.debug("开始检查缺失的市场信息...")
// 1. 获取所有买入订单的市场ID(去重)
val allOrders = copyOrderTrackingRepository.findAll()
val marketIds = allOrders.map { it.marketId }.distinct()
if (marketIds.isEmpty()) {
logger.debug("没有找到任何订单,跳过市场信息检查")
return
}
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
// 2. 检查哪些市场信息在数据库中缺失
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
val missingMarketIds = marketIds.filter { it !in existingMarketIds }
// 过滤掉空字符串和无效的市场ID
val validMissingMarketIds = missingMarketIds.filter {
it.isNotBlank() && it.startsWith("0x")
}
if (validMissingMarketIds.isEmpty()) {
logger.debug("所有市场信息都已存在,无需更新")
return
}
logger.info("发现 ${validMissingMarketIds.size} 个缺失的市场信息,开始批量更新...")
// 3. 批量从API获取缺失的市场信息(分批处理,避免一次性请求过多)
val batches = validMissingMarketIds.chunked(batchSize)
var successCount = 0
var failCount = 0
for ((index, batch) in batches.withIndex()) {
try {
logger.debug("处理第 ${index + 1}/${batches.size} 批,包含 ${batch.size} 个市场: ${batch.take(3).joinToString(", ")}${if (batch.size > 3) "..." else ""}")
// 使用 MarketService 的批量获取方法
// 这个方法会尝试从API获取并保存到数据库
val markets = marketService.getMarkets(batch)
val batchSuccessCount = markets.size
val batchFailCount = batch.size - batchSuccessCount
successCount += batchSuccessCount
failCount += batchFailCount
if (batchFailCount > 0) {
logger.warn("${index + 1} 批中有 ${batchFailCount} 个市场信息获取失败")
}
// 避免请求过于频繁,每批之间稍作延迟
if (index < batches.size - 1) {
delay(1000) // 延迟1秒,避免API限流
}
} catch (e: Exception) {
logger.error("批量获取市场信息失败: batch=${batch.take(5).joinToString(", ")}..., error=${e.message}", e)
failCount += batch.size
}
}
logger.info("市场信息更新完成: 成功=${successCount}, 失败=${failCount}, 总计=${validMissingMarketIds.size}")
} catch (e: Exception) {
logger.error("检查并更新市场信息异常: ${e.message}", e)
}
}
/**
* 手动触发检查(用于测试或手动刷新)
*/
fun triggerCheck() {
scope.launch(Dispatchers.IO) {
try {
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("手动触发检查市场信息失败: ${e.message}", e)
}
}
}
}
@@ -0,0 +1,200 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.entity.Market
import com.wrbug.polymarketbot.repository.MarketRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 市场信息服务
* 负责缓存和管理市场信息
*/
@Service
class MarketService(
val marketRepository: MarketRepository, // 改为 public,供 MarketPollingService 使用
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(MarketService::class.java)
// 内存缓存(避免频繁查询数据库)
private val marketCache = ConcurrentHashMap<String, Market>()
/**
* 根据市场ID获取市场信息
* 优先从缓存获取,如果不存在则从数据库查询,如果数据库也没有则从API获取并保存
*/
fun getMarket(marketId: String): Market? {
// 1. 从缓存获取
marketCache[marketId]?.let { return it }
// 2. 从数据库查询
val market = marketRepository.findByMarketId(marketId)
if (market != null) {
marketCache[marketId] = market
return market
}
// 3. 从API获取(异步,不阻塞)
runBlocking {
try {
fetchAndSaveMarket(marketId)
} catch (e: Exception) {
logger.warn("获取市场信息失败: marketId=$marketId, error=${e.message}")
}
}
// 再次从数据库查询(API可能已经保存)
return marketRepository.findByMarketId(marketId)?.also {
marketCache[marketId] = it
}
}
/**
* 批量获取市场信息
*/
fun getMarkets(marketIds: List<String>): Map<String, Market> {
val result = mutableMapOf<String, Market>()
val missingIds = mutableListOf<String>()
// 1. 从缓存和数据库获取
for (marketId in marketIds) {
val market = getMarket(marketId)
if (market != null) {
result[marketId] = market
} else {
missingIds.add(marketId)
}
}
// 2. 批量从API获取缺失的市场信息
if (missingIds.isNotEmpty()) {
runBlocking {
try {
fetchAndSaveMarkets(missingIds)
} catch (e: Exception) {
logger.warn("批量获取市场信息失败: marketIds=$missingIds, error=${e.message}")
}
}
// 再次从数据库查询
val savedMarkets = marketRepository.findByMarketIdIn(missingIds)
for (market in savedMarkets) {
result[market.marketId] = market
marketCache[market.marketId] = market
}
}
return result
}
/**
* 从API获取市场信息并保存到数据库
*/
private suspend fun fetchAndSaveMarket(marketId: String): Market? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
if (markets.isNotEmpty()) {
val marketResponse = markets.first()
saveMarketFromResponse(marketId, marketResponse)
} else {
null
}
} else {
null
}
} catch (e: Exception) {
logger.error("从API获取市场信息失败: marketId=$marketId, error=${e.message}", e)
null
}
}
/**
* 批量从API获取市场信息并保存到数据库
*/
private suspend fun fetchAndSaveMarkets(marketIds: List<String>) {
if (marketIds.isEmpty()) return
try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(conditionIds = marketIds)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
val marketMap = markets.associateBy { it.conditionId ?: "" }
for (marketId in marketIds) {
val marketResponse = marketMap[marketId]
if (marketResponse != null) {
saveMarketFromResponse(marketId, marketResponse)
}
}
}
} catch (e: Exception) {
logger.error("批量从API获取市场信息失败: marketIds=$marketIds, error=${e.message}", e)
}
}
/**
* 从API响应保存市场信息到数据库
*/
private fun saveMarketFromResponse(marketId: String, marketResponse: MarketResponse): Market? {
return try {
val existingMarket = marketRepository.findByMarketId(marketId)
val market = if (existingMarket != null) {
// 更新现有市场信息
existingMarket.copy(
title = marketResponse.question ?: existingMarket.title,
slug = marketResponse.slug ?: existingMarket.slug,
category = marketResponse.category ?: existingMarket.category,
icon = marketResponse.icon ?: existingMarket.icon,
image = marketResponse.image ?: existingMarket.image,
description = marketResponse.description ?: existingMarket.description,
active = marketResponse.active ?: existingMarket.active,
closed = marketResponse.closed ?: existingMarket.closed,
archived = marketResponse.archived ?: existingMarket.archived,
updatedAt = System.currentTimeMillis()
)
} else {
// 创建新市场信息
Market(
marketId = marketId,
title = marketResponse.question ?: marketId,
slug = marketResponse.slug,
category = marketResponse.category,
icon = marketResponse.icon,
image = marketResponse.image,
description = marketResponse.description,
active = marketResponse.active ?: true,
closed = marketResponse.closed ?: false,
archived = marketResponse.archived ?: false
)
}
val savedMarket = marketRepository.save(market)
marketCache[marketId] = savedMarket
savedMarket
} catch (e: Exception) {
logger.error("保存市场信息失败: marketId=$marketId, error=${e.message}", e)
null
}
}
/**
* 清除缓存(用于测试或手动刷新)
*/
fun clearCache() {
marketCache.clear()
}
}
@@ -32,7 +32,8 @@ class CopyTradingStatisticsService(
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val accountService: AccountService,
private val blockchainService: BlockchainService
private val blockchainService: BlockchainService,
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -165,13 +166,19 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, orders.size)
val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList()
// 批量获取市场信息
val marketIds = pagedOrders.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO
val list = pagedOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
val market = markets[order.marketId]
BuyOrderInfo(
orderId = order.buyOrderId,
leaderTradeId = order.leaderBuyTradeId,
marketId = order.marketId,
marketTitle = market?.title,
side = order.side,
quantity = order.quantity.toString(),
price = order.price.toString(),
@@ -212,13 +219,19 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, records.size)
val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList()
// 批量获取市场信息
val marketIds = pagedRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO
val list = pagedRecords.map { record ->
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
val market = markets[record.marketId]
SellOrderInfo(
orderId = record.sellOrderId,
leaderTradeId = record.leaderSellTradeId,
marketId = record.marketId,
marketTitle = market?.title,
side = record.side,
quantity = record.totalMatchedQuantity.toString(),
price = record.sellPrice.toString(),
@@ -263,11 +276,23 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, filtered.size)
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
// 获取匹配记录以获取市场ID
val matchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
val matchRecords = matchRecordIds.mapNotNull { id ->
sellMatchRecordRepository.findById(id).orElse(null)
}
val marketIds = matchRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO
val list = pagedDetails.map { detail ->
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
val market = matchRecord?.let { markets[it.marketId] }
MatchedOrderInfo(
sellOrderId = sellMatchRecordRepository.findById(detail.matchRecordId).orElse(null)?.sellOrderId ?: "",
sellOrderId = matchRecord?.sellOrderId ?: "",
buyOrderId = detail.buyOrderId,
marketId = matchRecord?.marketId,
marketTitle = market?.title,
matchedQuantity = detail.matchedQuantity.toString(),
buyPrice = detail.buyPrice.toString(),
sellPrice = detail.sellPrice.toString(),
@@ -30,8 +30,8 @@ server.port=${SERVER_PORT:8000}
spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}
# 日志配置(支持通过环境变量配置)
logging.level.root=${LOG_LEVEL_ROOT:INFO}
logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:DEBUG}
logging.level.root=${LOG_LEVEL_ROOT:WARN}
logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:INFO}
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
# Polymarket API 配置
@@ -0,0 +1,22 @@
-- ============================================
-- V19: 创建市场信息表
-- ============================================
-- 创建 markets 表
CREATE TABLE IF NOT EXISTS markets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
market_id VARCHAR(100) NOT NULL UNIQUE COMMENT '市场IDcondition ID',
title VARCHAR(500) NOT NULL COMMENT '市场名称(question',
slug VARCHAR(200) COMMENT '市场slug',
category VARCHAR(50) COMMENT '市场分类',
icon VARCHAR(500) COMMENT '市场图标URL',
image VARCHAR(500) COMMENT '市场图片URL',
description TEXT COMMENT '市场描述',
active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否活跃',
closed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已关闭',
archived BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已归档',
created_at BIGINT NOT NULL COMMENT '创建时间(时间戳,毫秒)',
updated_at BIGINT NOT NULL COMMENT '更新时间(时间戳,毫秒)',
INDEX idx_market_id (market_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='市场信息表';
+3 -3
View File
@@ -77,10 +77,10 @@ JWT_SECRET=${JWT_SECRET}
# 管理员密码重置密钥(已自动生成随机值,生产环境建议修改)
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_KEY}
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_ROOT=INFO
# LOG_LEVEL_APP=DEBUG
# LOG_LEVEL_ROOT=WARN
# LOG_LEVEL_APP=INFO
EOF
info ".env 文件已创建,已自动生成随机密码和密钥"
warn "生产环境建议修改以下参数:"
+17
View File
@@ -58,3 +58,20 @@ ADMIN_RESET_PASSWORD_KEY=change-me-in-production
# 生成方式 / Generate: openssl rand -hex 32
CRYPTO_SECRET_KEY=change-me-in-production
# ============================================
# 日志级别配置 / Logging Level Configuration
# ============================================
# 可选配置,如果不设置则使用默认值 / Optional configuration, uses default values if not set
#
# Root 日志级别,控制所有第三方库的日志输出
# Root logging level, controls log output for all third-party libraries
# 默认值 / Default: WARN
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_ROOT=WARN
#
# 应用日志级别,控制应用代码的日志输出
# Application logging level, controls log output for application code
# 默认值 / Default: INFO
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_APP=INFO
+3 -3
View File
@@ -29,10 +29,10 @@ services:
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-INFO}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-DEBUG}
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
depends_on:
mysql:
condition: service_healthy
+3 -3
View File
@@ -27,10 +27,10 @@ services:
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-INFO}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-DEBUG}
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
depends_on:
mysql:
condition: service_healthy
@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin, message } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
const { Option } = Select
import { ReloadOutlined } from '@ant-design/icons'
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons'
interface BuyOrdersTabProps {
copyTradingId: string
@@ -71,20 +71,44 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
return <Tag color={config.color}>{config.text}</Tag>
}
const handleCopyOrderId = async (orderId: string) => {
const success = await copyToClipboard(orderId)
if (success) {
message.success(t('common.copySuccess') || '已复制到剪贴板')
} else {
message.error(t('common.copyFailed') || '复制失败')
}
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId',
key: 'orderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
@@ -104,14 +128,21 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
width: isMobile ? 120 : 200,
render: (text: string, record: BuyOrderInfo) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{record.marketTitle ? (
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
{record.marketTitle}
</span>
) : null}
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
</div>
)
},
{
@@ -272,9 +303,22 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
<span>{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.orderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.orderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
@@ -309,7 +353,12 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
{order.marketTitle}
</div>
) : null}
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react'
import { Table, Input, Button, Card, Divider, Spin } from 'antd'
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
import { ReloadOutlined } from '@ant-design/icons'
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons'
interface MatchedOrdersTabProps {
copyTradingId: string
@@ -64,34 +64,73 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
return num >= 0 ? '#3f8600' : '#cf1322'
}
const handleCopyOrderId = async (orderId: string) => {
const success = await copyToClipboard(orderId)
if (success) {
message.success(t('common.copySuccess') || '已复制到剪贴板')
} else {
message.error(t('common.copyFailed') || '复制失败')
}
}
const columns = [
{
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
dataIndex: 'sellOrderId',
key: 'sellOrderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
dataIndex: 'buyOrderId',
key: 'buyOrderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.matchedQuantity') || '匹配数量',
@@ -206,22 +245,63 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
{(order.marketTitle || order.marketId) && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
{order.marketTitle}
</div>
) : null}
{order.marketId && (
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
)}
</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace',
marginBottom: '8px'
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.sellOrderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.sellOrderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace'
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.buyOrderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.buyOrderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
</div>
@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin, message } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
const { Option } = Select
import { ReloadOutlined } from '@ant-design/icons'
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons'
interface SellOrdersTabProps {
copyTradingId: string
@@ -66,20 +66,44 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
return num >= 0 ? '#3f8600' : '#cf1322'
}
const handleCopyOrderId = async (orderId: string) => {
const success = await copyToClipboard(orderId)
if (success) {
message.success(t('common.copySuccess') || '已复制到剪贴板')
} else {
message.error(t('common.copyFailed') || '复制失败')
}
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId',
key: 'orderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
@@ -99,14 +123,21 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
width: isMobile ? 120 : 200,
render: (text: string, record: SellOrderInfo) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{record.marketTitle ? (
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
{record.marketTitle}
</span>
) : null}
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
</div>
)
},
{
@@ -247,9 +278,22 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
<span>{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.orderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.orderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
@@ -287,7 +331,12 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
{order.marketTitle}
</div>
) : null}
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
+4
View File
@@ -627,6 +627,7 @@ export interface BuyOrderInfo {
orderId: string
leaderTradeId: string
marketId: string
marketTitle?: string // 市场名称
side: string
quantity: string
price: string
@@ -644,6 +645,7 @@ export interface SellOrderInfo {
orderId: string
leaderTradeId: string
marketId: string
marketTitle?: string // 市场名称
side: string
quantity: string
price: string
@@ -658,6 +660,8 @@ export interface SellOrderInfo {
export interface MatchedOrderInfo {
sellOrderId: string
buyOrderId: string
marketId?: string // 市场ID
marketTitle?: string // 市场名称
matchedQuantity: string
buyPrice: string
sellPrice: string
+41
View File
@@ -83,3 +83,44 @@ export {
getGitHubTagUrl
} from './version'
/**
* 检查订单ID是否为自动生成的
* 自动生成的订单ID通常以 "AUTO_" 或 "AUTO_FIFO_" 开头
* @param orderId - 订单ID
* @returns 如果是自动生成的订单ID,返回 true,否则返回 false
*/
export const isAutoGeneratedOrderId = (orderId: string | undefined | null): boolean => {
if (!orderId) return false
return orderId.startsWith('AUTO_') || orderId.startsWith('AUTO_FIFO_')
}
/**
* 复制文本到剪贴板
* @param text - 要复制的文本
* @returns Promise<boolean> - 复制成功返回 true,失败返回 false
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
return true
} else {
// 降级方案:使用传统的 document.execCommand
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
return successful
}
} catch (error) {
console.error('复制到剪贴板失败:', error)
return false
}
}