diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/ProxyConfigController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/ProxyConfigController.kt index 8a2335b..442be61 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/ProxyConfigController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/ProxyConfigController.kt @@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.system.ApiHealthCheckService +import com.wrbug.polymarketbot.service.system.GeoblockService import com.wrbug.polymarketbot.service.system.ProxyConfigService import jakarta.servlet.http.HttpServletRequest import kotlinx.coroutines.runBlocking @@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.* class ProxyConfigController( private val proxyConfigService: ProxyConfigService, private val apiHealthCheckService: ApiHealthCheckService, + private val geoblockService: GeoblockService, private val messageSource: MessageSource ) { @@ -123,6 +125,32 @@ class ProxyConfigController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource)) } } + + /** + * 检查交易服务器出口 IP 的地域限制(Polymarket Geoblock) + */ + @PostMapping("/geoblock-check") + fun checkGeoblock(): ResponseEntity> { + return try { + val result = runBlocking { geoblockService.checkGeoblock() } + if (result.isSuccess) { + ResponseEntity.ok(ApiResponse.success(result.getOrNull())) + } else { + val error = result.exceptionOrNull() + logger.error("Geoblock 检查失败", error) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_ERROR, + error?.message ?: "Geoblock 检查失败", + messageSource + ) + ) + } + } catch (e: Exception) { + logger.error("Geoblock 检查异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "Geoblock 检查失败:${e.message}", messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/GeoblockDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/GeoblockDto.kt new file mode 100644 index 0000000..3084c78 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/GeoblockDto.kt @@ -0,0 +1,23 @@ +package com.wrbug.polymarketbot.dto + +/** + * Polymarket Geoblock API 原始响应 + */ +data class PolymarketGeoblockApiResponse( + val blocked: Boolean = false, + val ip: String = "", + val country: String = "", + val region: String = "" +) + +/** + * 地域限制检查结果(返回给前端) + */ +data class GeoblockCheckDto( + val blocked: Boolean, + val ip: String, + val country: String, + val region: String, + val checkedAt: Long, + val source: String = "server" +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt index a9378f3..a51b432 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt @@ -36,6 +36,18 @@ data class SubscriptionProxyConfigRequest( val type: String // CLASH 或 SS ) +/** + * 代理检查时的 Polymarket 地域限制检测结果 + */ +data class ProxyCheckGeoblockResult( + val checked: Boolean, + val blocked: Boolean? = null, + val ip: String? = null, + val country: String? = null, + val region: String? = null, + val message: String? = null +) + /** * 代理检查响应 */ @@ -43,15 +55,22 @@ data class ProxyCheckResponse( val success: Boolean, val message: String, val responseTime: Long? = null, // 响应时间(毫秒) - val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示 + val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示 + val geoblock: ProxyCheckGeoblockResult? = null ) { companion object { - fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse { + fun create( + success: Boolean, + message: String, + responseTime: Long? = null, + geoblock: ProxyCheckGeoblockResult? = null + ): ProxyCheckResponse { return ProxyCheckResponse( success = success, message = message, responseTime = responseTime, - latency = responseTime // latency 和 responseTime 相同 + latency = responseTime, + geoblock = geoblock ) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/GeoblockService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/GeoblockService.kt new file mode 100644 index 0000000..3a4abd9 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/GeoblockService.kt @@ -0,0 +1,67 @@ +package com.wrbug.polymarketbot.service.system + +import com.google.gson.Gson +import com.wrbug.polymarketbot.dto.GeoblockCheckDto +import com.wrbug.polymarketbot.dto.PolymarketGeoblockApiResponse +import com.wrbug.polymarketbot.util.createClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit + +@Service +class GeoblockService( + private val gson: Gson +) { + + private val logger = LoggerFactory.getLogger(GeoblockService::class.java) + + companion object { + private const val GEOBLOCK_URL = "https://polymarket.com/api/geoblock" + } + + suspend fun checkGeoblock(): Result = withContext(Dispatchers.IO) { + val client = createClient() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + checkGeoblockWithClient(client) + } + + fun checkGeoblockWithClient(client: OkHttpClient): Result { + return try { + val request = Request.Builder() + .url(GEOBLOCK_URL) + .get() + .header("Accept", "application/json") + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return Result.failure( + IllegalStateException("HTTP ${response.code}: ${response.message}") + ) + } + val body = response.body?.string() + ?: return Result.failure(IllegalStateException("Empty response body")) + val apiResponse = gson.fromJson(body, PolymarketGeoblockApiResponse::class.java) + Result.success( + GeoblockCheckDto( + blocked = apiResponse.blocked, + ip = apiResponse.ip, + country = apiResponse.country, + region = apiResponse.region, + checkedAt = System.currentTimeMillis() + ) + ) + } + } catch (e: Exception) { + logger.warn("Geoblock 检查失败", e) + Result.failure(e) + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ProxyConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ProxyConfigService.kt index 4e2430c..7d01d2b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ProxyConfigService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ProxyConfigService.kt @@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit */ @Service class ProxyConfigService( - private val proxyConfigRepository: ProxyConfigRepository + private val proxyConfigRepository: ProxyConfigRepository, + private val geoblockService: GeoblockService ) : ApplicationContextAware { private var applicationContext: ApplicationContext? = null @@ -148,100 +149,153 @@ class ProxyConfigService( } /** - * 检查代理是否可用 - * 使用配置的代理请求 Polymarket 健康检查接口 + * 检查代理是否可用,并检测经该代理访问 Polymarket 时的地域限制 */ fun checkProxy(): ProxyCheckResponse { return try { val config = proxyConfigRepository.findByEnabledTrue() - ?: return ProxyCheckResponse.create( + if (config == null) { + return ProxyCheckResponse.create( success = false, - message = "未配置代理或代理未启用" + message = "未配置代理或代理未启用", + geoblock = checkGeoblockForProxy(null) ) - + } + if (config.type != "HTTP") { return ProxyCheckResponse.create( success = false, - message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)" + message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)", + geoblock = checkGeoblockForProxy(null) ) } - + if (config.host == null || config.port == null) { return ProxyCheckResponse.create( success = false, - message = "代理配置不完整:缺少主机或端口" + message = "代理配置不完整:缺少主机或端口", + geoblock = checkGeoblockForProxy(null) ) } - - // 创建代理 - val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port)) - - // 创建 OkHttpClient - val clientBuilder = OkHttpClient.Builder() - .proxy(proxy) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - - // 配置 SSL:信任所有证书(用于代理连接) - clientBuilder.createSSLSocketFactory() - clientBuilder.hostnameVerifier(TrustAllHostnameVerifier()) - - // 如果配置了用户名和密码,添加代理认证 - if (config.username != null && config.password != null) { - clientBuilder.proxyAuthenticator { _, response -> - val credential = okhttp3.Credentials.basic(config.username, config.password) - response.request.newBuilder() - .header("Proxy-Authorization", credential) - .build() - } - } - - val client = clientBuilder.build() - - // 请求 Polymarket 健康检查接口 + + val client = buildProxyHttpClient(config) + val geoblock = checkGeoblockForProxy(client) + val request = Request.Builder() .url("https://data-api.polymarket.com/") .get() .build() - + val startTime = System.currentTimeMillis() val response = client.newCall(request).execute() val responseTime = System.currentTimeMillis() - startTime - + val responseBody = response.body?.string() - + if (response.isSuccessful && responseBody != null) { - // 检查响应内容是否为 {"data": "OK"} if (responseBody.contains("\"data\"") && responseBody.contains("OK")) { logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms") + val message = buildProxyCheckMessage("代理连接成功", geoblock) ProxyCheckResponse.create( success = true, - message = "代理连接成功", - responseTime = responseTime + message = message, + responseTime = responseTime, + geoblock = geoblock ) } else { ProxyCheckResponse.create( success = false, - message = "代理连接成功,但响应格式不正确:$responseBody", - responseTime = responseTime + message = buildProxyCheckMessage( + "代理连接成功,但响应格式不正确:$responseBody", + geoblock + ), + responseTime = responseTime, + geoblock = geoblock ) } } else { ProxyCheckResponse.create( success = false, - message = "代理连接失败:HTTP ${response.code} ${response.message}", - responseTime = responseTime + message = buildProxyCheckMessage( + "代理连接失败:HTTP ${response.code} ${response.message}", + geoblock + ), + responseTime = responseTime, + geoblock = geoblock ) } } catch (e: Exception) { logger.error("代理检查异常", e) ProxyCheckResponse.create( success = false, - message = "代理检查失败:${e.message}" + message = "代理检查失败:${e.message}", + geoblock = checkGeoblockForProxy(null) ) } } + + private fun buildProxyHttpClient(config: ProxyConfig): OkHttpClient { + val host = requireNotNull(config.host) { "代理主机不能为空" } + val port = requireNotNull(config.port) { "代理端口不能为空" } + val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port)) + val clientBuilder = OkHttpClient.Builder() + .proxy(proxy) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + + clientBuilder.createSSLSocketFactory() + clientBuilder.hostnameVerifier(TrustAllHostnameVerifier()) + + if (config.username != null && config.password != null) { + clientBuilder.proxyAuthenticator { _, response -> + val credential = Credentials.basic(config.username, config.password) + response.request.newBuilder() + .header("Proxy-Authorization", credential) + .build() + } + } + + return clientBuilder.build() + } + + private fun checkGeoblockForProxy(client: OkHttpClient?): ProxyCheckGeoblockResult { + val httpClient = client ?: com.wrbug.polymarketbot.util.createClient() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + + return geoblockService.checkGeoblockWithClient(httpClient).fold( + onSuccess = { dto -> + ProxyCheckGeoblockResult( + checked = true, + blocked = dto.blocked, + ip = dto.ip, + country = dto.country, + region = dto.region, + message = if (dto.blocked) { + "当前出口 IP(${dto.country}/${dto.region})在 Polymarket 受限,无法下单" + } else { + "当前出口 IP(${dto.country}/${dto.region})可向 Polymarket 下单" + } + ) + }, + onFailure = { e -> + ProxyCheckGeoblockResult( + checked = true, + message = "地域限制检测失败:${e.message}" + ) + } + ) + } + + private fun buildProxyCheckMessage(baseMessage: String, geoblock: ProxyCheckGeoblockResult): String { + if (!geoblock.checked || geoblock.message.isNullOrBlank()) { + return baseMessage + } + return "$baseMessage;${geoblock.message}" + } /** * 删除代理配置 diff --git a/frontend/src/components/GeoblockStatusTrigger.tsx b/frontend/src/components/GeoblockStatusTrigger.tsx new file mode 100644 index 0000000..0ecc386 --- /dev/null +++ b/frontend/src/components/GeoblockStatusTrigger.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react' +import { Button, Popover, Space } from 'antd' +import { GlobalOutlined, LinkOutlined, LoadingOutlined, ReloadOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useGeoblockCheck } from '../hooks/useGeoblockCheck' + +const GEOBLOCK_DOCS_URL = 'https://docs.polymarket.com/api-reference/geoblock' + +type StatusTone = 'loading' | 'ok' | 'blocked' | 'warn' + +const TONE_COLOR: Record = { + loading: 'rgba(255, 255, 255, 0.45)', + ok: '#52c41a', + blocked: '#ff7875', + warn: '#faad14' +} + +function formatLocation(country: string, region: string): string { + if (country && region) { + return `${country}/${region}` + } + return country || region || '—' +} + +interface GeoblockStatusTriggerProps { + iconSize?: number + dotSize?: number +} + +const GeoblockStatusTrigger: React.FC = ({ iconSize = 18, dotSize = 12 }) => { + const { t } = useTranslation() + const { status, data, refresh, loading } = useGeoblockCheck(true) + + const locationText = useMemo(() => { + if (!data) return '—' + return formatLocation(data.country, data.region) + }, [data]) + + let tone: StatusTone = 'loading' + let label = t('geoblock.checkingShort') + + if (status === 'loading' || status === 'idle') { + tone = 'loading' + label = t('geoblock.checkingShort') + } else if (status === 'error') { + tone = 'warn' + label = t('geoblock.unknown.short') + } else if (data?.blocked) { + tone = 'blocked' + label = t('geoblock.menu.blocked', { location: locationText }) + } else if (status === 'success' && data) { + tone = 'ok' + label = t('geoblock.menu.ok', { location: locationText }) + } + + const accent = TONE_COLOR[tone] + + const popoverContent = ( +
+
{t('geoblock.title')}
+
{label}
+ {data && ( +
+
IP: {data.ip || '—'}
+
{t('geoblock.location')}: {locationText}
+
+ )} + + + + +
+ ) + + return ( + + + + ) +} + +export default GeoblockStatusTrigger diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4ddd575..7f054a6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -31,6 +31,7 @@ import { removeToken, getVersionText, getVersionInfo } from '../utils' import { wsManager } from '../services/websocket' import { apiClient } from '../services/api' import Logo from './Logo' +import GeoblockStatusTrigger from './GeoblockStatusTrigger' const { Header, Content, Sider } = AntLayout @@ -346,6 +347,7 @@ const Layout: React.FC = ({ children }) => { > +