From d376a82ccc0dc4b6960b34e79a880007d3929daa Mon Sep 17 00:00:00 2001 From: WrBug Date: Thu, 8 Jan 2026 11:56:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E7=AE=A1=E7=90=86=E5=92=8C=E8=AE=A2=E5=8D=95?= =?UTF-8?q?ID=E5=A4=8D=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增市场信息表和服务,支持市场名称缓存和自动更新 - 创建 Market 实体和 MarketRepository - 实现 MarketService 提供市场信息查询和缓存 - 实现 MarketPollingService 每30秒自动检查并更新缺失的市场信息 - 添加数据库迁移 V19 创建 markets 表 - 订单列表显示市场名称 - 在 BuyOrderInfo、SellOrderInfo、MatchedOrderInfo 中添加 marketTitle 字段 - 订单查询时自动查询并填充市场名称 - 前端订单列表显示市场名称(优先显示名称,ID作为辅助信息) - 前端订单ID复制功能 - 添加 isAutoGeneratedOrderId 和 copyToClipboard 工具函数 - 非自动生成的订单ID支持一键复制 - 买入、卖出、匹配订单列表均支持复制功能 - 仓位检查延迟检测机制优化 - 首次检测到仓位不存在时先记录,3分钟后再次检查 - 避免因API延迟导致的误判 --- ANNOUNCEMENT_V1.1.7.md | 137 ++++++++++++ .../dto/CopyTradingStatisticsDto.kt | 4 + .../com/wrbug/polymarketbot/entity/Market.kt | 54 +++++ .../repository/MarketRepository.kt | 12 ++ .../service/accounts/PositionCheckService.kt | 188 ++++++++++++++-- .../service/common/MarketPollingService.kt | 173 +++++++++++++++ .../service/common/MarketService.kt | 200 ++++++++++++++++++ .../CopyTradingStatisticsService.kt | 29 ++- .../src/main/resources/application.properties | 4 +- .../migration/V19__create_markets_table.sql | 22 ++ deploy.sh | 6 +- docker-compose.prod.env.example | 17 ++ docker-compose.prod.yml | 6 +- docker-compose.yml | 6 +- .../pages/CopyTradingOrders/BuyOrdersTab.tsx | 95 +++++++-- .../CopyTradingOrders/MatchedOrdersTab.tsx | 130 +++++++++--- .../pages/CopyTradingOrders/SellOrdersTab.tsx | 95 +++++++-- frontend/src/types/index.ts | 4 + frontend/src/utils/index.ts | 41 ++++ 19 files changed, 1122 insertions(+), 101 deletions(-) create mode 100644 ANNOUNCEMENT_V1.1.7.md create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/MarketRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPollingService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt create mode 100644 backend/src/main/resources/db/migration/V19__create_markets_table.sql diff --git a/ANNOUNCEMENT_V1.1.7.md b/ANNOUNCEMENT_V1.1.7.md new file mode 100644 index 0000000..96e67f3 --- /dev/null +++ b/ANNOUNCEMENT_V1.1.7.md @@ -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) +- 不影响现有功能 + +--- + +## 🙏 感谢 + +感谢所有用户的支持和反馈!如果您在使用过程中遇到任何问题,欢迎: + +- 📧 提交 Issue:https://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 + +--- + +**祝您交易顺利!** 🚀 + + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt index 4a2f07d..7ac4d1c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt @@ -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, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt new file mode 100644 index 0000000..207c04c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt @@ -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, // 市场ID(condition 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() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/MarketRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/MarketRepository.kt new file mode 100644 index 0000000..c6169d4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/MarketRepository.kt @@ -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 { + fun findByMarketId(marketId: String): Market? + fun findByMarketIdIn(marketIds: List): List +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt index ee25e64..59e09c4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt @@ -62,6 +62,19 @@ class PositionCheckService( // 记录已发送提示的配置(避免重复推送) private val notifiedConfigs = ConcurrentHashMap() // 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, + val firstDetectedTime: Long // 首次检测到仓位不存在的时间 + ) + private val pendingPositionChecks = ConcurrentHashMap() + // 同步锁,确保订阅任务的启动和停止是线程安全的 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() + val toMarkAsSold = mutableListOf() + + // 遍历所有待检查的仓位 + 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. 总订单数量 = 所有未卖出订单的剩余数量总和 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPollingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPollingService.kt new file mode 100644 index 0000000..c25007c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPollingService.kt @@ -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) + } + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt new file mode 100644 index 0000000..16f1dae --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt @@ -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() + + /** + * 根据市场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): Map { + val result = mutableMapOf() + val missingIds = mutableListOf() + + // 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) { + 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() + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt index c875847..e2c23cb 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt @@ -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(), diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 68f0b3a..39089e4 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 配置 diff --git a/backend/src/main/resources/db/migration/V19__create_markets_table.sql b/backend/src/main/resources/db/migration/V19__create_markets_table.sql new file mode 100644 index 0000000..15d610f --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__create_markets_table.sql @@ -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 '市场ID(condition 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='市场信息表'; + diff --git a/deploy.sh b/deploy.sh index d622803..89dce2f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 "生产环境建议修改以下参数:" diff --git a/docker-compose.prod.env.example b/docker-compose.prod.env.example index 040738a..20413d0 100644 --- a/docker-compose.prod.env.example +++ b/docker-compose.prod.env.example @@ -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 + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 12d1765..fd616ef 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c1efeb3..a1b4706 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx index d011f61..08fd9e6 100644 --- a/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx +++ b/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx @@ -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 = ({ copyTradingId, active = fal return {config.text} } + 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) => ( - - {isMobile - ? `${text.slice(0, 6)}...${text.slice(-4)}` - : `${text.slice(0, 8)}...${text.slice(-6)}` - } - - ) + width: isMobile ? 120 : 180, + render: (text: string) => { + const isAuto = isAutoGeneratedOrderId(text) + return ( +
+ + {isMobile + ? `${text.slice(0, 6)}...${text.slice(-4)}` + : `${text.slice(0, 8)}...${text.slice(-6)}` + } + + {!isAuto && ( +
+ ) + } }, { title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID', @@ -104,14 +128,21 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal title: t('copyTradingOrders.market') || '市场', dataIndex: 'marketId', key: 'marketId', - width: isMobile ? 100 : 150, - render: (text: string) => ( - - {isMobile - ? `${text.slice(0, 6)}...${text.slice(-4)}` - : `${text.slice(0, 8)}...${text.slice(-6)}` - } - + width: isMobile ? 120 : 200, + render: (text: string, record: BuyOrderInfo) => ( +
+ {record.marketTitle ? ( + + {record.marketTitle} + + ) : null} + + {isMobile + ? `${text.slice(0, 6)}...${text.slice(-4)}` + : `${text.slice(0, 8)}...${text.slice(-6)}` + } + +
) }, { @@ -272,9 +303,22 @@ const BuyOrdersTab: React.FC = ({ 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)} + {order.orderId.slice(0, 8)}...{order.orderId.slice(-6)} + {!isAutoGeneratedOrderId(order.orderId) && ( +