Compare commits
2 Commits
fix/issue-38
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 83a415fb7e | |||
| 727fa7ed58 |
+28
@@ -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<ApiResponse<GeoblockCheckDto>> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GeoblockCheckDto> = 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<GeoblockCheckDto> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
-48
@@ -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}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
|
||||
@@ -10,6 +10,7 @@ RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
COMPOSE_FILES=(-f docker-compose.yml)
|
||||
|
||||
# 打印信息
|
||||
info() {
|
||||
@@ -24,6 +25,40 @@ error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 从环境变量或 .env 文件读取配置值,避免 source .env 时被特殊字符影响
|
||||
get_config_value() {
|
||||
local key="$1"
|
||||
local value="${!key}"
|
||||
|
||||
if [ -n "$value" ]; then
|
||||
echo "$value"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f ".env" ]; then
|
||||
grep "^${key}=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r' || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 根据数据库配置选择 compose 文件
|
||||
configure_compose_files() {
|
||||
local db_url
|
||||
db_url=$(get_config_value "DB_URL")
|
||||
|
||||
COMPOSE_FILES=(-f docker-compose.yml)
|
||||
|
||||
if [[ "$db_url" == *"host.docker.internal"* ]]; then
|
||||
COMPOSE_FILES=(-f docker-compose.host-mysql.yml)
|
||||
info "检测到 DB_URL 指向宿主机 MySQL,将使用 docker-compose.host-mysql.yml"
|
||||
else
|
||||
info "使用默认 docker-compose.yml(包含内置 MySQL 服务)"
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker-compose "${COMPOSE_FILES[@]}" "$@"
|
||||
}
|
||||
|
||||
# 检查 Docker 环境
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
@@ -148,6 +183,7 @@ check_security_config() {
|
||||
deploy() {
|
||||
# 检查安全配置
|
||||
check_security_config
|
||||
configure_compose_files
|
||||
|
||||
# 检查是否使用 Docker Hub 镜像
|
||||
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
|
||||
@@ -182,20 +218,20 @@ deploy() {
|
||||
export GIT_TAG=${DOCKER_VERSION}
|
||||
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
|
||||
docker-compose build
|
||||
compose build
|
||||
fi
|
||||
|
||||
info "启动服务..."
|
||||
docker-compose up -d
|
||||
compose up -d
|
||||
|
||||
info "等待服务启动..."
|
||||
sleep 5
|
||||
|
||||
info "检查服务状态..."
|
||||
docker-compose ps
|
||||
compose ps
|
||||
|
||||
info "查看日志: docker-compose logs -f"
|
||||
info "停止服务: docker-compose down"
|
||||
info "查看日志: docker-compose ${COMPOSE_FILES[*]} logs -f"
|
||||
info "停止服务: docker-compose ${COMPOSE_FILES[*]} down"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
version: '3.8'
|
||||
|
||||
# 使用宿主机 MySQL 的部署配置。
|
||||
# 适用于 DB_URL 指向 host.docker.internal 的场景,不会启动内置 MySQL 容器。
|
||||
services:
|
||||
app:
|
||||
# 使用 Docker Hub 镜像(推荐生产环境)
|
||||
# 取消注释下面一行,并注释掉 build 部分,即可使用 Docker Hub 的镜像
|
||||
# image: wrbug/polyhermes:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# 本地构建时可以传递版本号参数(自动使用当前分支名)
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
GIT_TAG: ${GIT_TAG:-${VERSION:-dev}}
|
||||
GITHUB_REPO_URL: ${GITHUB_REPO_URL:-https://github.com/WrBug/PolyHermes}
|
||||
container_name: polyhermes
|
||||
ports:
|
||||
- "${SERVER_PORT:-80}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:?DB_URL is required when using host MySQL}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- SERVER_PORT=8000
|
||||
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
||||
# 请在 .env 文件中设置,或通过环境变量传入
|
||||
# 生成随机密钥: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=WARN, app=INFO)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
# 动态更新配置
|
||||
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
|
||||
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- polyhermes-network
|
||||
|
||||
networks:
|
||||
polyhermes-network:
|
||||
driver: bridge
|
||||
@@ -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<StatusTone, string> = {
|
||||
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<GeoblockStatusTriggerProps> = ({ 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 = (
|
||||
<div style={{ maxWidth: 240 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('geoblock.title')}</div>
|
||||
<div style={{ fontSize: 13, marginBottom: 8, lineHeight: 1.5 }}>{label}</div>
|
||||
{data && (
|
||||
<div style={{ fontSize: 12, color: 'rgba(0, 0, 0, 0.45)', marginBottom: 10 }}>
|
||||
<div>IP: {data.ip || '—'}</div>
|
||||
<div>{t('geoblock.location')}: {locationText}</div>
|
||||
</div>
|
||||
)}
|
||||
<Space size={8}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={loading ? <LoadingOutlined /> : <ReloadOutlined />}
|
||||
onClick={() => refresh()}
|
||||
loading={loading}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{t('geoblock.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
href={GEOBLOCK_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{t('geoblock.viewDocsShort')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="click" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={t('geoblock.title')}
|
||||
style={{
|
||||
position: 'relative',
|
||||
color: '#fff',
|
||||
fontSize: iconSize,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
{tone === 'loading' ? (
|
||||
<LoadingOutlined style={{ fontSize: iconSize, color: accent }} />
|
||||
) : (
|
||||
<GlobalOutlined style={{ fontSize: iconSize }} />
|
||||
)}
|
||||
{tone !== 'loading' && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -Math.round(dotSize / 3),
|
||||
bottom: -Math.round(dotSize / 3),
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
borderRadius: '50%',
|
||||
background: accent,
|
||||
border: '2px solid #001529',
|
||||
boxShadow: tone === 'ok' ? `0 0 5px ${accent}` : undefined
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeoblockStatusTrigger
|
||||
@@ -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<LayoutProps> = ({ children }) => {
|
||||
>
|
||||
<SendOutlined />
|
||||
</a>
|
||||
<GeoblockStatusTrigger iconSize={16} dotSize={10} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
@@ -393,7 +395,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
@@ -473,6 +477,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
>
|
||||
<SendOutlined />
|
||||
</a>
|
||||
<GeoblockStatusTrigger iconSize={18} dotSize={12} />
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
@@ -483,7 +488,8 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{
|
||||
height: 'calc(100vh - 100px)',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderRight: 0,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Alert, Tag, Typography } from 'antd'
|
||||
import { CloseCircleOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface ProxyCheckGeoblockResult {
|
||||
checked: boolean
|
||||
blocked?: boolean | null
|
||||
ip?: string | null
|
||||
country?: string | null
|
||||
region?: string | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number
|
||||
geoblock?: ProxyCheckGeoblockResult | null
|
||||
}
|
||||
|
||||
interface ProxyCheckResultAlertProps {
|
||||
result: ProxyCheckResponse
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
interface ResultRowProps {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
fullWidth?: boolean
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
function formatLocation(country?: string | null, region?: string | null): string {
|
||||
if (country && region) {
|
||||
return `${country} / ${region}`
|
||||
}
|
||||
return country || region || '—'
|
||||
}
|
||||
|
||||
function stripGeoblockSuffix(message: string, geoblockMessage?: string | null): string {
|
||||
if (!geoblockMessage) {
|
||||
return message
|
||||
}
|
||||
const suffix = `;${geoblockMessage}`
|
||||
if (message.endsWith(suffix)) {
|
||||
return message.slice(0, -suffix.length)
|
||||
}
|
||||
const semi = message.indexOf(';')
|
||||
if (semi > 0) {
|
||||
return message.slice(0, semi)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
const ResultRow: React.FC<ResultRowProps> = ({ label, children, fullWidth, isMobile }) => (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: fullWidth && !isMobile ? '1 / -1' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
minWidth: 0,
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ flexShrink: 0, width: isMobile ? 96 : 108, fontSize: 13 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ProxyCheckResultAlert: React.FC<ProxyCheckResultAlertProps> = ({ result, style }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const geoblock = result.geoblock
|
||||
const geoblockChecked = Boolean(geoblock?.checked)
|
||||
const geoblockBlocked = geoblockChecked && geoblock?.blocked === true
|
||||
const geoblockUnknown = geoblockChecked && geoblock?.blocked == null
|
||||
|
||||
const alertType = !result.success ? 'error' : geoblockBlocked ? 'warning' : 'success'
|
||||
const alertMessage = !result.success
|
||||
? t('proxySettings.checkFailed')
|
||||
: geoblockBlocked
|
||||
? t('proxySettings.checkSuccessWithGeoblockWarning')
|
||||
: t('proxySettings.checkSuccess')
|
||||
|
||||
const latencyMs = result.latency ?? result.responseTime
|
||||
const connectionSummary = stripGeoblockSuffix(result.message, geoblock?.message)
|
||||
const locationText = formatLocation(geoblock?.country, geoblock?.region)
|
||||
|
||||
const renderGeoblockValue = () => {
|
||||
if (!geoblockChecked) {
|
||||
return null
|
||||
}
|
||||
if (geoblockUnknown) {
|
||||
return (
|
||||
<Tag icon={<WarningOutlined />} color="warning">
|
||||
{t('proxySettings.checkResult.geoblockUnknown')}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (geoblockBlocked) {
|
||||
return (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
{t('proxySettings.checkResult.geoblockBlocked')}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return <Text>{t('proxySettings.checkResult.geoblockOk')}</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type={alertType}
|
||||
message={alertMessage}
|
||||
description={
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
|
||||
gap: isMobile ? 10 : '10px 24px'
|
||||
}}
|
||||
>
|
||||
<ResultRow label={t('proxySettings.checkResult.connection')} isMobile={isMobile}>
|
||||
{result.success ? (
|
||||
<Text>{t('proxySettings.checkResult.connected')}</Text>
|
||||
) : (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
{t('proxySettings.checkResult.failed')}
|
||||
</Tag>
|
||||
)}
|
||||
</ResultRow>
|
||||
|
||||
{latencyMs !== undefined && (
|
||||
<ResultRow label={t('proxySettings.checkResult.latency')} isMobile={isMobile}>
|
||||
<Text type={latencyMs >= 3000 ? 'warning' : undefined}>{latencyMs} ms</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
|
||||
{geoblockChecked && (
|
||||
<>
|
||||
<ResultRow label={t('proxySettings.geoblockTitle')} isMobile={isMobile}>
|
||||
{renderGeoblockValue()}
|
||||
</ResultRow>
|
||||
<ResultRow label={t('geoblock.location')} isMobile={isMobile}>
|
||||
<Text>{locationText}</Text>
|
||||
</ResultRow>
|
||||
{geoblock?.ip && (
|
||||
<ResultRow label={t('geoblock.ip')} isMobile={isMobile} fullWidth>
|
||||
<Text style={{ wordBreak: 'break-all' }}>{geoblock.ip}</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
{geoblockUnknown && geoblock?.message && (
|
||||
<ResultRow label={t('proxySettings.checkResult.detail')} isMobile={isMobile} fullWidth>
|
||||
<Text type="secondary">{geoblock.message}</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!result.success && connectionSummary && (
|
||||
<ResultRow label={t('proxySettings.checkResult.detail')} isMobile={isMobile} fullWidth>
|
||||
<Text type="secondary">{connectionSummary}</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
style={style}
|
||||
showIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProxyCheckResultAlert
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { apiService } from '../services/api'
|
||||
|
||||
export interface GeoblockCheckResult {
|
||||
blocked: boolean
|
||||
ip: string
|
||||
country: string
|
||||
region: string
|
||||
checkedAt: number
|
||||
source: string
|
||||
}
|
||||
|
||||
export type GeoblockCheckStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
const CACHE_KEY = 'geoblock_check_cache'
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface GeoblockCacheEntry {
|
||||
data: GeoblockCheckResult
|
||||
cachedAt: number
|
||||
}
|
||||
|
||||
function readCache(): GeoblockCheckResult | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const entry = JSON.parse(raw) as GeoblockCacheEntry
|
||||
if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
|
||||
sessionStorage.removeItem(CACHE_KEY)
|
||||
return null
|
||||
}
|
||||
return entry.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(data: GeoblockCheckResult): void {
|
||||
const entry: GeoblockCacheEntry = { data, cachedAt: Date.now() }
|
||||
sessionStorage.setItem(CACHE_KEY, JSON.stringify(entry))
|
||||
}
|
||||
|
||||
export function useGeoblockCheck(autoFetch = true) {
|
||||
const [status, setStatus] = useState<GeoblockCheckStatus>('idle')
|
||||
const [data, setData] = useState<GeoblockCheckResult | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const fetchingRef = useRef(false)
|
||||
|
||||
const fetchGeoblock = useCallback(async (force = false) => {
|
||||
if (fetchingRef.current) return
|
||||
if (!force) {
|
||||
const cached = readCache()
|
||||
if (cached) {
|
||||
setData(cached)
|
||||
setStatus('success')
|
||||
setErrorMessage(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
fetchingRef.current = true
|
||||
setStatus('loading')
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.checkGeoblock()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const result: GeoblockCheckResult = {
|
||||
blocked: response.data.data.blocked,
|
||||
ip: response.data.data.ip,
|
||||
country: response.data.data.country,
|
||||
region: response.data.data.region,
|
||||
checkedAt: response.data.data.checkedAt,
|
||||
source: response.data.data.source ?? 'server'
|
||||
}
|
||||
writeCache(result)
|
||||
setData(result)
|
||||
setStatus('success')
|
||||
} else {
|
||||
setStatus('error')
|
||||
setErrorMessage(response.data.msg ?? 'Geoblock check failed')
|
||||
setData(null)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setStatus('error')
|
||||
const message = err instanceof Error ? err.message : 'Geoblock check failed'
|
||||
setErrorMessage(message)
|
||||
setData(null)
|
||||
} finally {
|
||||
fetchingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchGeoblock()
|
||||
}
|
||||
}, [autoFetch, fetchGeoblock])
|
||||
|
||||
return {
|
||||
status,
|
||||
data,
|
||||
errorMessage,
|
||||
refresh: () => fetchGeoblock(true),
|
||||
loading: status === 'loading'
|
||||
}
|
||||
}
|
||||
@@ -381,7 +381,21 @@
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"getFailed": "Failed to get proxy configuration",
|
||||
"latency": "Latency"
|
||||
"latency": "Latency",
|
||||
"checkResult": {
|
||||
"connection": "Proxy connection",
|
||||
"connected": "OK",
|
||||
"failed": "Failed",
|
||||
"latency": "Response time",
|
||||
"detail": "Details",
|
||||
"geoblockOk": "Can trade",
|
||||
"geoblockBlocked": "Region restricted",
|
||||
"geoblockUnknown": "Check failed"
|
||||
},
|
||||
"checkSuccessWithGeoblockWarning": "Proxy is OK, but trading region is restricted",
|
||||
"geoblockTitle": "Geo check",
|
||||
"geoblockAvailable": "Egress IP can trade ({{location}})",
|
||||
"geoblockBlocked": "Egress IP is restricted and orders are blocked ({{location}})"
|
||||
},
|
||||
"configPage": {
|
||||
"title": "Global Configuration",
|
||||
@@ -1286,6 +1300,36 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL (from Slack App settings)",
|
||||
"webhookUrlRequired": "Please enter Webhook URL"
|
||||
},
|
||||
"geoblock": {
|
||||
"title": "Trading Region Check",
|
||||
"checking": "Checking geographic restrictions for the trading server egress IP…",
|
||||
"checkingShort": "Checking server region…",
|
||||
"ip": "Detected IP",
|
||||
"location": "Region",
|
||||
"refresh": "Refresh",
|
||||
"viewDocs": "View official geo restrictions",
|
||||
"viewDocsShort": "Docs",
|
||||
"sourceServer": "Source: trading server",
|
||||
"menu": {
|
||||
"ok": "Region OK · {{location}}",
|
||||
"blocked": "Restricted · {{location}}"
|
||||
},
|
||||
"available": {
|
||||
"title": "Trading server can place orders on Polymarket",
|
||||
"description": "IP: {{ip}}, region: {{location}}",
|
||||
"short": "Server OK · {{location}} · {{ip}}"
|
||||
},
|
||||
"blocked": {
|
||||
"title": "Trading server IP is in a restricted region",
|
||||
"description": "Polymarket rejects orders from restricted regions. Configure a compliant network or proxy for your trading server before copy trading.",
|
||||
"short": "Server IP restricted ({{location}} · {{ip}}) — orders blocked"
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Could not complete region check",
|
||||
"description": "Check server network, proxy settings, or try again later. You can still read announcements, but verify your network before copy trading.",
|
||||
"short": "Region check failed"
|
||||
}
|
||||
},
|
||||
"announcements": {
|
||||
"title": "Announcements",
|
||||
"noAnnouncements": "No announcements",
|
||||
|
||||
@@ -366,11 +366,25 @@
|
||||
"passwordHelpUpdate": "留空则不更新密码,输入新密码则更新",
|
||||
"check": "检查代理",
|
||||
"checkSuccess": "代理检查成功",
|
||||
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||
"checkFailed": "代理检查失败",
|
||||
"geoblockTitle": "地域检测",
|
||||
"geoblockAvailable": "出口 IP 可下单({{location}})",
|
||||
"geoblockBlocked": "出口 IP 受限,无法下单({{location}})",
|
||||
"saveSuccess": "保存配置成功",
|
||||
"saveFailed": "保存配置失败",
|
||||
"getFailed": "获取代理配置失败",
|
||||
"latency": "延迟",
|
||||
"checkResult": {
|
||||
"connection": "代理连接",
|
||||
"connected": "正常",
|
||||
"failed": "失败",
|
||||
"latency": "响应延迟",
|
||||
"detail": "详情",
|
||||
"geoblockOk": "可下单",
|
||||
"geoblockBlocked": "地域受限",
|
||||
"geoblockUnknown": "检测异常"
|
||||
},
|
||||
"hostInvalid": "请输入有效的主机地址",
|
||||
"portInvalid": "端口必须在 1-65535 之间"
|
||||
},
|
||||
@@ -1286,6 +1300,36 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)",
|
||||
"webhookUrlRequired": "请输入 Webhook URL"
|
||||
},
|
||||
"geoblock": {
|
||||
"title": "交易地域检查",
|
||||
"checking": "正在检测交易服务器出口 IP 的地域限制…",
|
||||
"checkingShort": "正在检测服务器地域…",
|
||||
"ip": "检测 IP",
|
||||
"location": "地区",
|
||||
"refresh": "刷新",
|
||||
"viewDocs": "查看官方地域限制说明",
|
||||
"viewDocsShort": "说明",
|
||||
"sourceServer": "检测对象:交易服务器",
|
||||
"menu": {
|
||||
"ok": "地域可用 · {{location}}",
|
||||
"blocked": "地域受限 · {{location}}"
|
||||
},
|
||||
"available": {
|
||||
"title": "当前服务器网络可向 Polymarket 提交订单",
|
||||
"description": "检测 IP:{{ip}},地区:{{location}}",
|
||||
"short": "服务器可交易 · {{location}} · {{ip}}"
|
||||
},
|
||||
"blocked": {
|
||||
"title": "当前服务器 IP 所在地区无法向 Polymarket 下单",
|
||||
"description": "Polymarket 会拒绝来自受限地区的订单。请为交易服务器配置合规的网络或代理环境后再进行跟单。",
|
||||
"short": "服务器 IP 受限({{location}} · {{ip}}),无法下单"
|
||||
},
|
||||
"unknown": {
|
||||
"title": "无法完成地域检测",
|
||||
"description": "请检查服务器网络、代理配置或稍后重试。检测失败不会阻止您查看公告,但跟单前请自行确认网络环境。",
|
||||
"short": "地域检测失败"
|
||||
}
|
||||
},
|
||||
"announcements": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暂无公告",
|
||||
|
||||
@@ -381,7 +381,21 @@
|
||||
"saveSuccess": "保存配置成功",
|
||||
"saveFailed": "保存配置失敗",
|
||||
"getFailed": "獲取代理配置失敗",
|
||||
"latency": "延遲"
|
||||
"latency": "延遲",
|
||||
"checkResult": {
|
||||
"connection": "代理連線",
|
||||
"connected": "正常",
|
||||
"failed": "失敗",
|
||||
"latency": "回應延遲",
|
||||
"detail": "詳情",
|
||||
"geoblockOk": "可下單",
|
||||
"geoblockBlocked": "地域受限",
|
||||
"geoblockUnknown": "檢測異常"
|
||||
},
|
||||
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||
"geoblockTitle": "地域檢測",
|
||||
"geoblockAvailable": "出口 IP 可下單({{location}})",
|
||||
"geoblockBlocked": "出口 IP 受限,無法下單({{location}})"
|
||||
},
|
||||
"configPage": {
|
||||
"title": "全局配置",
|
||||
@@ -1286,6 +1300,36 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)",
|
||||
"webhookUrlRequired": "請輸入 Webhook URL"
|
||||
},
|
||||
"geoblock": {
|
||||
"title": "交易地域檢查",
|
||||
"checking": "正在檢測交易伺服器出口 IP 的地域限制…",
|
||||
"checkingShort": "正在檢測伺服器地域…",
|
||||
"ip": "檢測 IP",
|
||||
"location": "地區",
|
||||
"refresh": "刷新",
|
||||
"viewDocs": "查看官方地域限制說明",
|
||||
"viewDocsShort": "說明",
|
||||
"sourceServer": "檢測對象:交易伺服器",
|
||||
"menu": {
|
||||
"ok": "地域可用 · {{location}}",
|
||||
"blocked": "地域受限 · {{location}}"
|
||||
},
|
||||
"available": {
|
||||
"title": "當前伺服器網路可向 Polymarket 提交訂單",
|
||||
"description": "檢測 IP:{{ip}},地區:{{location}}",
|
||||
"short": "伺服器可交易 · {{location}} · {{ip}}"
|
||||
},
|
||||
"blocked": {
|
||||
"title": "當前伺服器 IP 所在地區無法向 Polymarket 下單",
|
||||
"description": "Polymarket 會拒絕來自受限地區的訂單。請為交易伺服器配置合規的網路或代理環境後再進行跟單。",
|
||||
"short": "伺服器 IP 受限({{location}} · {{ip}}),無法下單"
|
||||
},
|
||||
"unknown": {
|
||||
"title": "無法完成地域檢測",
|
||||
"description": "請檢查伺服器網路、代理配置或稍後重試。檢測失敗不會阻止您查看公告,但跟單前請自行確認網路環境。",
|
||||
"short": "地域檢測失敗"
|
||||
}
|
||||
},
|
||||
"announcements": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暫無公告",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Reactions {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert } from 'antd'
|
||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space } from 'antd'
|
||||
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { Title } = Typography
|
||||
|
||||
interface ProxyConfig {
|
||||
id?: number
|
||||
@@ -20,13 +21,6 @@ interface ProxyConfig {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number
|
||||
}
|
||||
|
||||
const ProxySettings: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
@@ -211,24 +205,7 @@ const ProxySettings: React.FC = () => {
|
||||
</Form>
|
||||
|
||||
{checkResult && (
|
||||
<Alert
|
||||
type={checkResult.success ? 'success' : 'error'}
|
||||
message={checkResult.success ? (t('proxySettings.checkSuccess') || '代理检查成功') : (t('proxySettings.checkFailed') || '代理检查失败')}
|
||||
description={
|
||||
<div>
|
||||
<Text>{checkResult.message}</Text>
|
||||
{(checkResult.responseTime !== undefined || checkResult.latency !== undefined) && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">
|
||||
{t('proxySettings.latency') || '延迟'}: {(checkResult.latency ?? checkResult.responseTime) ?? 0}ms
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
style={{ marginTop: '16px' }}
|
||||
showIcon
|
||||
/>
|
||||
<ProxyCheckResultAlert result={checkResult} style={{ marginTop: '16px' }} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
|
||||
import SystemUpdate from './SystemUpdate'
|
||||
|
||||
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
interface ProxyConfig {
|
||||
@@ -23,13 +23,6 @@ interface ProxyConfig {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number
|
||||
}
|
||||
|
||||
const SystemSettings: React.FC = () => {
|
||||
const { t, i18n: i18nInstance } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
@@ -228,7 +221,12 @@ const SystemSettings: React.FC = () => {
|
||||
const result = response.data.data
|
||||
setProxyCheckResult(result)
|
||||
if (result.success) {
|
||||
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`)
|
||||
const geoblockHint = result.geoblock?.blocked
|
||||
? `;${result.geoblock.message ?? t('proxySettings.geoblockBlocked', { location: `${result.geoblock.country}/${result.geoblock.region}` })}`
|
||||
: result.geoblock?.message
|
||||
? `;${result.geoblock.message}`
|
||||
: ''
|
||||
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}${geoblockHint}`)
|
||||
} else {
|
||||
message.warning(`代理检查失败:${result.message}`)
|
||||
}
|
||||
@@ -576,24 +574,7 @@ const SystemSettings: React.FC = () => {
|
||||
</Form>
|
||||
|
||||
{proxyCheckResult && (
|
||||
<Alert
|
||||
type={proxyCheckResult.success ? 'success' : 'error'}
|
||||
message={proxyCheckResult.success ? (t('proxySettings.checkSuccess') || '代理检查成功') : (t('proxySettings.checkFailed') || '代理检查失败')}
|
||||
description={
|
||||
<div>
|
||||
<Text>{proxyCheckResult.message}</Text>
|
||||
{(proxyCheckResult.responseTime !== undefined || proxyCheckResult.latency !== undefined) && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">
|
||||
{t('proxySettings.latency') || '延迟'}: {(proxyCheckResult.latency ?? proxyCheckResult.responseTime) ?? 0}ms
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
style={{ marginTop: '16px' }}
|
||||
showIcon
|
||||
/>
|
||||
<ProxyCheckResultAlert result={proxyCheckResult} style={{ marginTop: '16px' }} />
|
||||
)}
|
||||
|
||||
</Card>
|
||||
|
||||
@@ -625,6 +625,15 @@ export const apiService = {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number
|
||||
geoblock?: {
|
||||
checked: boolean
|
||||
blocked?: boolean | null
|
||||
ip?: string | null
|
||||
country?: string | null
|
||||
region?: string | null
|
||||
message?: string | null
|
||||
} | null
|
||||
}>>('/system/proxy/check', {}),
|
||||
|
||||
/**
|
||||
@@ -645,7 +654,20 @@ export const apiService = {
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>
|
||||
}>>('/system/proxy/api-health-check', {})
|
||||
}>>('/system/proxy/api-health-check', {}),
|
||||
|
||||
/**
|
||||
* 检查交易服务器出口 IP 的地域限制(Polymarket Geoblock)
|
||||
*/
|
||||
checkGeoblock: () =>
|
||||
apiClient.post<ApiResponse<{
|
||||
blocked: boolean
|
||||
ip: string
|
||||
country: string
|
||||
region: string
|
||||
checkedAt: number
|
||||
source: string
|
||||
}>>('/system/proxy/geoblock-check', {})
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user