feat: 添加关键字过滤功能并优化市场 slug 处理

主要变更:
1. 关键字过滤功能
   - 添加关键字过滤模式(白名单/黑名单/禁用)
   - 支持关键字列表配置(JSON 存储)
   - 实现关键字匹配逻辑(不区分大小写)
   - 数据库迁移:V20__add_keyword_filter.sql

2. 市场 slug 优化
   - 区分显示用 slug 和跳转用 eventSlug
   - 添加 event_slug 字段到 markets 表(V21__add_event_slug_to_markets.sql)
   - 从 events[0].slug 获取跳转用的 slug 并存入数据库
   - 减少 API 请求,提高性能

3. UI 优化
   - 将创建/编辑跟单配置改为 Modal 方式
   - 删除独立的 CopyTradingAdd 和 CopyTradingEdit 页面
   - 更新前端跳转逻辑,优先使用 eventSlug

4. API 响应优化
   - 更新 MarketResponse 添加 events 字段
   - 统一从 events[0].slug 获取跳转用的 slug
   - 更新所有相关服务使用新的 slug 获取逻辑
This commit is contained in:
WrBug
2026-01-08 17:47:00 +08:00
parent 3d5a923caf
commit 9ed5190bfe
33 changed files with 738 additions and 773 deletions
@@ -28,6 +28,23 @@ interface PolymarketGammaApi {
): Response<List<MarketResponse>>
}
/**
* 事件响应(从 MarketResponse.events 解析)
*/
data class EventResponse(
val id: String? = null,
val ticker: String? = null,
val slug: String,
val title: String,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val startDate: String? = null,
val endDate: String? = null,
val createdAt: String? = null
)
/**
* 市场响应(根据 Gamma API 文档)
*/
@@ -54,6 +71,7 @@ data class MarketResponse(
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null,
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug
// 以下字段可能存在于响应中,但不在标准文档中
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
@@ -125,7 +125,8 @@ data class AccountPositionDto(
val proxyAddress: String,
val marketId: String,
val marketTitle: String?,
val marketSlug: String?,
val marketSlug: String?, // 显示用的 slug
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketIcon: String?, // 市场图标 URL
val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId
@@ -37,6 +37,9 @@ data class CopyTradingCreateRequest(
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
@@ -71,6 +74,9 @@ data class CopyTradingUpdateRequest(
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选,但提供时必须非空)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
@@ -142,6 +148,9 @@ data class CopyTradingDto(
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
@@ -41,7 +41,8 @@ data class BuyOrderInfo(
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于构建 URL
val marketSlug: String? = null, // 市场 slug(用于显示
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val side: String,
val quantity: String,
@@ -61,7 +62,8 @@ data class SellOrderInfo(
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于构建 URL
val marketSlug: String? = null, // 市场 slug(用于显示
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val side: String,
val quantity: String,
@@ -79,7 +81,8 @@ data class MatchedOrderInfo(
val buyOrderId: String,
val marketId: String? = null, // 市场ID(从买入订单获取)
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于构建 URL
val marketSlug: String? = null, // 市场 slug(用于显示
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val matchedQuantity: String,
val buyPrice: String,
@@ -142,7 +145,8 @@ data class MarketOrderStats(
data class MarketOrderGroup(
val marketId: String,
val marketTitle: String?,
val marketSlug: String?,
val marketSlug: String?, // 显示用的 slug
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String?,
val stats: MarketOrderStats,
val orders: List<Any> // BuyOrderInfo, SellOrderInfo 或 MatchedOrderInfo 的列表
@@ -25,7 +25,8 @@ data class MarketDto(
val liquidityNum: Double?,
val bestBid: Double?,
val bestAsk: Double?,
val lastTradePrice: Double?
val lastTradePrice: Double?,
val events: List<MarketDto>? = null // 事件列表(从 events[0] 获取 slug
)
/**
@@ -37,9 +38,9 @@ data class OutcomeDto(
)
/**
* 事件 DTO
* 事件 DTO(用于其他 API 调用)
*/
data class EventDto(
data class EventListDto(
val id: String,
val title: String,
val category: String,
@@ -55,7 +56,7 @@ data class SeriesDto(
val id: String,
val title: String,
val category: String,
val events: List<EventDto>?,
val events: List<EventListDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
@@ -70,4 +71,3 @@ data class CommentDto(
val createdAt: Long, // 时间戳(毫秒)
val user: String?
)
@@ -91,6 +91,13 @@ data class CopyTrading(
@Column(name = "max_position_count")
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null, // 关键字列表(JSON数组),例如:["NBA", "足球", "NBA总决赛"],当keywordFilterMode为DISABLED时为NULL
// 新增配置字段
@Column(name = "config_name", length = 255)
val configName: String? = null, // 配置名(可选)
@@ -22,7 +22,10 @@ data class Market(
val title: String, // 市场名称(question
@Column(name = "slug", length = 200)
val slug: String? = null, // 市场slug
val slug: String? = null, // 市场slug(用于显示)
@Column(name = "event_slug", length = 200)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
@Column(name = "category", length = 50)
val category: String? = null, // 市场分类
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
@@ -656,7 +657,8 @@ class AccountService(
proxyAddress = account.proxyAddress,
marketId = pos.conditionId ?: "",
marketTitle = pos.title ?: "",
marketSlug = pos.slug ?: "",
marketSlug = pos.slug ?: "", // 显示用的 slug
eventSlug = pos.eventSlug, // 跳转用的 slug(从 events[0].slug 获取)
marketIcon = pos.icon, // 市场图标
side = pos.outcome ?: "",
outcomeIndex = pos.outcomeIndex, // 添加 outcomeIndex
@@ -949,7 +951,6 @@ class AccountService(
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
@@ -962,7 +963,7 @@ class AccountService(
orderId = orderId,
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
marketSlug = marketInfo.getEventSlug(), // 跳转用的 slug
side = request.side,
price = sellPrice, // 直接传递卖出价格
size = sellQuantity.toPlainString(), // 直接传递卖出数量
@@ -1017,7 +1018,6 @@ class AccountService(
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
@@ -1029,7 +1029,7 @@ class AccountService(
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
marketSlug = marketInfo.getEventSlug(), // 跳转用的 slug
side = request.side,
outcome = null, // 失败时可能没有 outcome
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
@@ -1075,7 +1075,6 @@ class AccountService(
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
@@ -1090,7 +1089,7 @@ class AccountService(
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
marketSlug = marketInfo.getEventSlug(), // 跳转用的 slug
side = request.side,
outcome = null, // 失败时可能没有 outcome
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
@@ -5,6 +5,7 @@ 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 com.wrbug.polymarketbot.util.getEventSlug
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -151,11 +152,17 @@ class MarketService(
return try {
val existingMarket = marketRepository.findByMarketId(marketId)
// 保存原来的 slug(用于显示)
val slug = marketResponse.slug
// 保存跳转用的 slug(从 events[0].slug 获取)
val eventSlug = marketResponse.getEventSlug()
val market = if (existingMarket != null) {
// 更新现有市场信息
existingMarket.copy(
title = marketResponse.question ?: existingMarket.title,
slug = marketResponse.slug ?: existingMarket.slug,
slug = slug ?: existingMarket.slug,
eventSlug = eventSlug ?: existingMarket.eventSlug,
category = marketResponse.category ?: existingMarket.category,
icon = marketResponse.icon ?: existingMarket.icon,
image = marketResponse.image ?: existingMarket.image,
@@ -170,14 +177,17 @@ class MarketService(
Market(
marketId = marketId,
title = marketResponse.question ?: marketId,
slug = marketResponse.slug,
slug = slug,
eventSlug = eventSlug,
category = marketResponse.category,
icon = marketResponse.icon,
image = marketResponse.image,
description = marketResponse.description,
active = marketResponse.active ?: true,
closed = marketResponse.closed ?: false,
archived = marketResponse.archived ?: false
archived = marketResponse.archived ?: false,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
@@ -6,6 +6,7 @@ import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
@@ -20,7 +21,8 @@ import java.math.BigDecimal
class CopyTradingFilterService(
private val clobService: PolymarketClobService,
private val accountService: AccountService,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val jsonUtils: JsonUtils
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
@@ -32,6 +34,7 @@ class CopyTradingFilterService(
* @param tradePrice Leader 交易价格,用于价格区间检查
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
* @param marketTitle 市场标题,用于关键字过滤
* @return 过滤结果
*/
suspend fun checkFilters(
@@ -39,9 +42,18 @@ class CopyTradingFilterService(
tokenId: String,
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
marketTitle: String? = null // 市场标题,用于关键字过滤
): FilterResult {
// 1. 价格区间检查(如果配置了价格区间
// 1. 关键字过滤检查(如果配置了关键字过滤
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
val keywordCheck = checkKeywordFilter(copyTrading, marketTitle)
if (!keywordCheck.isPassed) {
return keywordCheck
}
}
// 2. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.isPassed) {
@@ -49,7 +61,7 @@ class CopyTradingFilterService(
}
}
// 2. 检查是否需要获取订单簿
// 3. 检查是否需要获取订单簿
// 只有在配置了需要订单簿的过滤条件时才获取
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
@@ -58,7 +70,7 @@ class CopyTradingFilterService(
return FilterResult.passed()
}
// 3. 获取订单簿(仅在需要时,只请求一次)
// 4. 获取订单簿(仅在需要时,只请求一次)
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
@@ -68,7 +80,7 @@ class CopyTradingFilterService(
val orderbook = orderbookResult.getOrNull()
?: return FilterResult.orderbookEmpty()
// 4. 买一卖一价差过滤(如果配置了)
// 5. 买一卖一价差过滤(如果配置了)
if (copyTrading.maxSpread != null) {
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.isPassed) {
@@ -76,7 +88,7 @@ class CopyTradingFilterService(
}
}
// 5. 订单深度过滤(如果配置了,检查所有方向)
// 6. 订单深度过滤(如果配置了,检查所有方向)
if (copyTrading.minOrderDepth != null) {
val depthCheck = checkOrderDepth(copyTrading, orderbook)
if (!depthCheck.isPassed) {
@@ -84,7 +96,7 @@ class CopyTradingFilterService(
}
}
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
if (!positionCheck.isPassed) {
@@ -95,6 +107,65 @@ class CopyTradingFilterService(
return FilterResult.passed(orderbook)
}
/**
* 检查关键字过滤
* @param copyTrading 跟单配置
* @param marketTitle 市场标题
* @return 过滤结果
*/
private fun checkKeywordFilter(
copyTrading: CopyTrading,
marketTitle: String?
): FilterResult {
// 如果未启用关键字过滤,直接通过
if (copyTrading.keywordFilterMode == null || copyTrading.keywordFilterMode == "DISABLED") {
return FilterResult.passed()
}
// 如果没有市场标题,无法进行关键字过滤,为了安全起见,不通过
if (marketTitle.isNullOrBlank()) {
return FilterResult.keywordFilterFailed("市场标题为空,无法进行关键字过滤")
}
// 解析关键字列表
val keywords = jsonUtils.parseStringArray(copyTrading.keywords)
if (keywords.isEmpty()) {
// 如果关键字列表为空,白名单模式不通过,黑名单模式通过
return if (copyTrading.keywordFilterMode == "WHITELIST") {
FilterResult.keywordFilterFailed("白名单模式但关键字列表为空")
} else {
FilterResult.passed()
}
}
// 将市场标题转换为小写,用于不区分大小写的匹配
val titleLower = marketTitle.lowercase()
// 检查市场标题是否包含任意关键字
val containsKeyword = keywords.any { keyword ->
titleLower.contains(keyword.lowercase())
}
// 根据过滤模式决定是否通过
return when (copyTrading.keywordFilterMode) {
"WHITELIST" -> {
if (containsKeyword) {
FilterResult.passed()
} else {
FilterResult.keywordFilterFailed("白名单模式:市场标题不包含任何关键字。市场标题:$marketTitle,关键字列表:${keywords.joinToString(", ")}")
}
}
"BLACKLIST" -> {
if (containsKeyword) {
FilterResult.keywordFilterFailed("黑名单模式:市场标题包含关键字。市场标题:$marketTitle,匹配的关键字:${keywords.filter { titleLower.contains(it.lowercase()) }.joinToString(", ")}")
} else {
FilterResult.passed()
}
}
else -> FilterResult.passed()
}
}
/**
* 检查价格区间
* @param copyTrading 跟单配置
@@ -9,6 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.google.gson.Gson
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -24,7 +26,9 @@ class CopyTradingService(
private val accountRepository: AccountRepository,
private val templateRepository: CopyTradingTemplateRepository,
private val leaderRepository: LeaderRepository,
private val monitorService: CopyTradingMonitorService
private val monitorService: CopyTradingMonitorService,
private val jsonUtils: JsonUtils,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
@@ -88,7 +92,9 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords)
)
} else {
// 手动输入(所有字段必须提供)
@@ -116,7 +122,9 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords)
)
}
@@ -145,6 +153,8 @@ class CopyTradingService(
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: false
)
@@ -213,6 +223,14 @@ class CopyTradingService(
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords)
} else if (request.keywordFilterMode != null && request.keywordFilterMode == "DISABLED") {
null
} else {
copyTrading.keywords
},
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
updatedAt = System.currentTimeMillis()
@@ -424,6 +442,8 @@ class CopyTradingService(
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
maxPositionCount = copyTrading.maxPositionCount,
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
pushFailedOrders = copyTrading.pushFailedOrders,
createdAt = copyTrading.createdAt,
@@ -431,6 +451,36 @@ class CopyTradingService(
)
}
/**
* 将关键字列表转换为 JSON 字符串
*/
private fun convertKeywordsToJson(keywords: List<String>?): String? {
if (keywords == null || keywords.isEmpty()) {
return null
}
return try {
gson.toJson(keywords)
} catch (e: Exception) {
logger.error("转换关键字列表为 JSON 失败", e)
null
}
}
/**
* 将 JSON 字符串转换为关键字列表
*/
private fun convertJsonToKeywords(jsonString: String?): List<String>? {
if (jsonString.isNullOrBlank()) {
return null
}
return try {
jsonUtils.parseStringArray(jsonString)
} catch (e: Exception) {
logger.error("解析关键字 JSON 失败", e)
null
}
}
/**
* 内部配置类(用于构建 CopyTrading 实体)
*/
@@ -454,6 +504,8 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String? // JSON 字符串
)
}
@@ -21,7 +21,9 @@ enum class FilterStatus {
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER
}
/**
@@ -89,6 +91,12 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
/** 关键字过滤失败 */
fun keywordFilterFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_KEYWORD_FILTER,
reason = reason
)
}
}
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.orders
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.entity.Account
@@ -437,7 +438,7 @@ class OrderPushService(
status = openOrder.status,
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
marketName = marketInfo?.question,
marketSlug = marketInfo?.slug,
marketSlug = marketInfo?.slug, // 显示用的 slug
marketIcon = marketInfo?.icon
)
},
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.repository.*
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.*
import com.wrbug.polymarketbot.util.getEventSlug
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -264,16 +265,32 @@ open class CopyOrderTrackingService(
// 计算跟单金额(USDC)= 买入数量 × 价格
val copyOrderAmount = buyQuantity.multi(tradePrice)
// 如果启用了关键字过滤,需要先获取市场标题
var marketTitle: String? = null
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketTitle = marketResponse.body()!!.firstOrNull()?.question
}
} catch (e: Exception) {
logger.warn("获取市场标题失败(关键字过滤需要): ${e.message}", e)
}
}
// 过滤条件检查(在计算订单参数之前)
// 传入 Leader 交易价格,用于价格区间检查
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
// 传入市场标题,用于关键字过滤
// 订单簿只请求一次,返回给后续逻辑使用
val filterResult = filterService.checkFilters(
copyTrading,
tokenId,
tradePrice = tradePrice,
copyOrderAmount = copyOrderAmount,
marketId = trade.market
marketId = trade.market,
marketTitle = marketTitle
)
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
if (!filterResult.isPassed) {
@@ -299,7 +316,7 @@ open class CopyOrderTrackingService(
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
val marketSlug = marketInfo?.slug // 显示用的 slug
// 从过滤结果中提取 filterType
val filterType = extractFilterType(filterResult.status, filterResult.reason)
@@ -348,7 +365,7 @@ open class CopyOrderTrackingService(
telegramNotificationService?.sendOrderFilteredNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketSlug = marketSlug,
marketSlug = marketInfo.getEventSlug(), // 跳转用的 slug
side = "BUY",
outcome = trade.outcome,
price = trade.price,
@@ -571,7 +588,6 @@ open class CopyOrderTrackingService(
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
@@ -583,7 +599,7 @@ open class CopyOrderTrackingService(
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketSlug = marketSlug,
marketSlug = marketInfo.getEventSlug(), // 跳转用的 slug
side = "BUY",
outcome = null, // 失败时可能没有 outcome
price = buyPrice.toString(),
@@ -1416,6 +1432,7 @@ open class CopyOrderTrackingService(
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
}
}
@@ -179,7 +179,8 @@ class CopyTradingStatisticsService(
leaderTradeId = order.leaderBuyTradeId,
marketId = order.marketId,
marketTitle = market?.title,
marketSlug = market?.slug,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = order.side,
quantity = order.quantity.toString(),
@@ -234,7 +235,8 @@ class CopyTradingStatisticsService(
leaderTradeId = record.leaderSellTradeId,
marketId = record.marketId,
marketTitle = market?.title,
marketSlug = market?.slug,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = record.side,
quantity = record.totalMatchedQuantity.toString(),
@@ -297,7 +299,8 @@ class CopyTradingStatisticsService(
buyOrderId = detail.buyOrderId,
marketId = matchRecord?.marketId,
marketTitle = market?.title,
marketSlug = market?.slug,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
matchedQuantity = detail.matchedQuantity.toString(),
buyPrice = detail.buyPrice.toString(),
@@ -791,7 +794,8 @@ class CopyTradingStatisticsService(
leaderTradeId = order.leaderBuyTradeId,
marketId = order.marketId,
marketTitle = market?.title,
marketSlug = market?.slug,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = order.side,
quantity = order.quantity.toString(),
@@ -807,7 +811,8 @@ class CopyTradingStatisticsService(
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
marketSlug = markets[marketId]?.slug,
marketSlug = markets[marketId]?.slug, // 显示用的 slug
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = markets[marketId]?.category,
stats = stats,
orders = orderDtos as List<Any>
@@ -895,7 +900,8 @@ class CopyTradingStatisticsService(
leaderTradeId = record.leaderSellTradeId,
marketId = record.marketId,
marketTitle = market?.title,
marketSlug = market?.slug,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = record.side,
quantity = record.totalMatchedQuantity.toString(),
@@ -909,7 +915,8 @@ class CopyTradingStatisticsService(
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
marketSlug = markets[marketId]?.slug,
marketSlug = markets[marketId]?.slug, // 显示用的 slug
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = markets[marketId]?.category,
stats = stats,
orders = orderDtos as List<Any>
@@ -938,4 +945,5 @@ class CopyTradingStatisticsService(
Result.failure(e)
}
}
}
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.getEventSlug
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.boot.context.event.ApplicationReadyEvent
@@ -730,7 +731,6 @@ class OrderStatusUpdateService(
}
val marketTitle = marketInfo?.question ?: order.marketId
val marketSlug = marketInfo?.slug
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(order.leaderId).orElse(null)
@@ -761,7 +761,7 @@ class OrderStatusUpdateService(
orderId = order.buyOrderId,
marketTitle = marketTitle,
marketId = order.marketId,
marketSlug = marketSlug,
marketSlug = marketInfo.getEventSlug(), // 跳转用的 slug
side = "BUY",
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
@@ -835,7 +835,6 @@ class OrderStatusUpdateService(
}
val marketTitle = marketInfo?.question ?: record.marketId
val marketSlug = marketInfo?.slug
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null)
@@ -866,7 +865,7 @@ class OrderStatusUpdateService(
orderId = record.sellOrderId,
marketTitle = marketTitle,
marketId = record.marketId,
marketSlug = marketSlug,
marketSlug = marketInfo.getEventSlug(), // 跳转用的 slug
side = "SELL",
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
@@ -0,0 +1,20 @@
package com.wrbug.polymarketbot.util
import com.wrbug.polymarketbot.api.MarketResponse
/**
* MarketResponse 扩展函数
* 从 events[0].slug 获取 slug,用于网页跳转
*/
fun MarketResponse?.getEventSlug(): String? {
return this?.events?.firstOrNull()?.slug ?: this?.slug
}
/**
* MarketResponse 扩展函数
* 获取显示用的 slug(使用原来的 slug 字段)
*/
fun MarketResponse?.getDisplaySlug(): String? {
return this?.slug
}
@@ -0,0 +1,19 @@
-- ============================================
-- V20: 添加关键字过滤字段
-- 在 copy_trading 表中添加关键字过滤配置
-- 支持白名单和黑名单两种模式
-- ============================================
-- 添加关键字过滤模式字段
-- DISABLED: 不启用关键字过滤(默认)
-- WHITELIST: 白名单模式,只跟单包含关键字的市场
-- BLACKLIST: 黑名单模式,不跟单包含关键字的市场
ALTER TABLE copy_trading
ADD COLUMN keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式(DISABLED/WHITELIST/BLACKLIST' AFTER max_position_count;
-- 添加关键字列表字段(JSON 数组)
-- 当 keyword_filter_mode 为 DISABLED 时,此字段为 NULL
-- 当 keyword_filter_mode 为 WHITELIST 或 BLACKLIST 时,此字段存储关键字数组,例如:["NBA", "足球", "NBA总决赛"]
ALTER TABLE copy_trading
ADD COLUMN keywords JSON NULL COMMENT '关键字列表(JSON数组),仅在WHITELIST或BLACKLIST模式下使用' AFTER keyword_filter_mode;
@@ -0,0 +1,8 @@
-- ============================================
-- V21: 添加 event_slug 字段到 markets 表
-- 用于存储跳转用的 slug(从 events[0].slug 获取)
-- ============================================
ALTER TABLE markets
ADD COLUMN event_slug VARCHAR(200) NULL COMMENT '跳转用的 slug(从 events[0].slug 获取,用于构建 URL' AFTER slug;