Compare commits

..

2 Commits

Author SHA1 Message Date
WrBug 83a415fb7e 优化代理检查结果展示与地域检测文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 00:54:22 +08:00
WrBug 727fa7ed58 fix: 支持部署时连接宿主机 MySQL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 01:02:55 +08:00
18 changed files with 935 additions and 116 deletions
@@ -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)
}
}
}
@@ -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}"
}
/**
* 删除代理配置
+41 -5
View File
@@ -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"
}
# 主函数
+50
View File
@@ -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
+8 -2
View File
@@ -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
+105
View File
@@ -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'
}
}
+45 -1
View File
@@ -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",
+44
View File
@@ -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": "暂无公告",
+45 -1
View File
@@ -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": "暫無公告",
-1
View File
@@ -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 {
+4 -27
View File
@@ -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>
+8 -27
View File
@@ -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>
+23 -1
View File
@@ -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', {})
},
/**