Compare commits

..

2 Commits

Author SHA1 Message Date
WrBug 6a6af6f5e9 fix: handle neg_risk market in sellPosition
Fixes #38

The sellPosition method was not checking if the market uses Neg Risk
Exchange contract. For neg_risk markets, the order signing must use the
Neg Risk Exchange address, otherwise the order will be rejected by
Polymarket.

The copy trading service already handles this correctly, but the manual
sell position flow was missing this check.
2026-04-29 14:35:43 +08:00
WrBug 4b58b7cd46 Merge pull request #57 from WrBug/dev 2026-04-28 22:32:45 +08:00
19 changed files with 127 additions and 938 deletions
@@ -3,7 +3,6 @@ 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
@@ -20,7 +19,6 @@ import org.springframework.web.bind.annotation.*
class ProxyConfigController(
private val proxyConfigService: ProxyConfigService,
private val apiHealthCheckService: ApiHealthCheckService,
private val geoblockService: GeoblockService,
private val messageSource: MessageSource
) {
@@ -125,32 +123,6 @@ 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))
}
}
}
@@ -1,23 +0,0 @@
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,18 +36,6 @@ 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
)
/**
* 代理检查响应
*/
@@ -55,22 +43,15 @@ data class ProxyCheckResponse(
val success: Boolean,
val message: String,
val responseTime: Long? = null, // 响应时间(毫秒)
val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
val geoblock: ProxyCheckGeoblockResult? = null
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
) {
companion object {
fun create(
success: Boolean,
message: String,
responseTime: Long? = null,
geoblock: ProxyCheckGeoblockResult? = null
): ProxyCheckResponse {
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse {
return ProxyCheckResponse(
success = success,
message = message,
responseTime = responseTime,
latency = responseTime,
geoblock = geoblock
latency = responseTime // latency 和 responseTime 相同
)
}
}
@@ -1247,7 +1247,14 @@ class AccountService(
// 7. 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
// 11. 检查市场是否为 Neg Risk 市场,获取正确的 Exchange 合约地址
val negRisk = marketService.getNegRiskByConditionId(request.marketId) == true
val exchangeContract = orderSigningService.getExchangeContract(negRisk)
if (negRisk) {
logger.debug("市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=${request.marketId}")
}
// 12. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -1256,14 +1263,15 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
exchangeContract = exchangeContract
)
} catch (e: Exception) {
logger.error("创建并签名订单失败", e)
return Result.failure(Exception("创建并签名订单失败: ${e.message}"))
}
// 12. 构建订单请求
// 13. 构建订单请求
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
order = signedOrder,
@@ -1,67 +0,0 @@
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,8 +24,7 @@ import java.util.concurrent.TimeUnit
*/
@Service
class ProxyConfigService(
private val proxyConfigRepository: ProxyConfigRepository,
private val geoblockService: GeoblockService
private val proxyConfigRepository: ProxyConfigRepository
) : ApplicationContextAware {
private var applicationContext: ApplicationContext? = null
@@ -149,153 +148,100 @@ class ProxyConfigService(
}
/**
* 检查代理是否可用,并检测经该代理访问 Polymarket 时的地域限制
* 检查代理是否可用
* 使用配置的代理请求 Polymarket 健康检查接口
*/
fun checkProxy(): ProxyCheckResponse {
return try {
val config = proxyConfigRepository.findByEnabledTrue()
if (config == null) {
return ProxyCheckResponse.create(
?: return ProxyCheckResponse.create(
success = false,
message = "未配置代理或代理未启用",
geoblock = checkGeoblockForProxy(null)
message = "未配置代理或代理未启用"
)
}
if (config.type != "HTTP") {
return ProxyCheckResponse.create(
success = false,
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)",
geoblock = checkGeoblockForProxy(null)
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)"
)
}
if (config.host == null || config.port == null) {
return ProxyCheckResponse.create(
success = false,
message = "代理配置不完整:缺少主机或端口",
geoblock = checkGeoblockForProxy(null)
message = "代理配置不完整:缺少主机或端口"
)
}
val client = buildProxyHttpClient(config)
val geoblock = checkGeoblockForProxy(client)
// 创建代理
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 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 = message,
responseTime = responseTime,
geoblock = geoblock
message = "代理连接成功",
responseTime = responseTime
)
} else {
ProxyCheckResponse.create(
success = false,
message = buildProxyCheckMessage(
"代理连接成功,但响应格式不正确:$responseBody",
geoblock
),
responseTime = responseTime,
geoblock = geoblock
message = "代理连接成功,但响应格式不正确:$responseBody",
responseTime = responseTime
)
}
} else {
ProxyCheckResponse.create(
success = false,
message = buildProxyCheckMessage(
"代理连接失败:HTTP ${response.code} ${response.message}",
geoblock
),
responseTime = responseTime,
geoblock = geoblock
message = "代理连接失败:HTTP ${response.code} ${response.message}",
responseTime = responseTime
)
}
} catch (e: Exception) {
logger.error("代理检查异常", e)
ProxyCheckResponse.create(
success = false,
message = "代理检查失败:${e.message}",
geoblock = checkGeoblockForProxy(null)
message = "代理检查失败:${e.message}"
)
}
}
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}"
}
/**
* 删除代理配置
+5 -41
View File
@@ -10,7 +10,6 @@ RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
COMPOSE_FILES=(-f docker-compose.yml)
# 打印信息
info() {
@@ -25,40 +24,6 @@ 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
@@ -183,7 +148,6 @@ check_security_config() {
deploy() {
# 检查安全配置
check_security_config
configure_compose_files
# 检查是否使用 Docker Hub 镜像
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
@@ -218,20 +182,20 @@ deploy() {
export GIT_TAG=${DOCKER_VERSION}
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
compose build
docker-compose build
fi
info "启动服务..."
compose up -d
docker-compose up -d
info "等待服务启动..."
sleep 5
info "检查服务状态..."
compose ps
docker-compose ps
info "查看日志: docker-compose ${COMPOSE_FILES[*]} logs -f"
info "停止服务: docker-compose ${COMPOSE_FILES[*]} down"
info "查看日志: docker-compose logs -f"
info "停止服务: docker-compose down"
}
# 主函数
-50
View File
@@ -1,50 +0,0 @@
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
@@ -1,139 +0,0 @@
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
+2 -8
View File
@@ -31,7 +31,6 @@ 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
@@ -347,7 +346,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
>
<SendOutlined />
</a>
<GeoblockStatusTrigger iconSize={16} dotSize={10} />
<Button
type="text"
icon={<MenuOutlined />}
@@ -395,9 +393,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
position: 'fixed',
left: 0,
top: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
overflow: 'hidden'
}}
>
<div style={{
@@ -477,7 +473,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
>
<SendOutlined />
</a>
<GeoblockStatusTrigger iconSize={18} dotSize={12} />
</div>
</div>
<Menu
@@ -488,8 +483,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
items={menuItems}
onClick={handleMenuClick}
style={{
flex: 1,
minHeight: 0,
height: 'calc(100vh - 100px)',
borderRight: 0,
overflowY: 'auto'
}}
@@ -1,181 +0,0 @@
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
@@ -1,105 +0,0 @@
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'
}
}
+1 -45
View File
@@ -381,21 +381,7 @@
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration",
"getFailed": "Failed to get proxy configuration",
"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}})"
"latency": "Latency"
},
"configPage": {
"title": "Global Configuration",
@@ -1300,36 +1286,6 @@
"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,25 +366,11 @@
"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 之间"
},
@@ -1300,36 +1286,6 @@
"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 -45
View File
@@ -381,21 +381,7 @@
"saveSuccess": "保存配置成功",
"saveFailed": "保存配置失敗",
"getFailed": "獲取代理配置失敗",
"latency": "延遲",
"checkResult": {
"connection": "代理連線",
"connected": "正常",
"failed": "失敗",
"latency": "回應延遲",
"detail": "詳情",
"geoblockOk": "可下單",
"geoblockBlocked": "地域受限",
"geoblockUnknown": "檢測異常"
},
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
"geoblockTitle": "地域檢測",
"geoblockAvailable": "出口 IP 可下單({{location}}",
"geoblockBlocked": "出口 IP 受限,無法下單({{location}}"
"latency": "延遲"
},
"configPage": {
"title": "全局配置",
@@ -1300,36 +1286,6 @@
"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,6 +6,7 @@ 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 {
+27 -4
View File
@@ -1,12 +1,11 @@
import { useEffect, useState } from 'react'
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space } from 'antd'
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert } 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 } = Typography
const { Title, Text } = Typography
interface ProxyConfig {
id?: number
@@ -21,6 +20,13 @@ 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 })
@@ -205,7 +211,24 @@ const ProxySettings: React.FC = () => {
</Form>
{checkResult && (
<ProxyCheckResultAlert result={checkResult} style={{ marginTop: '16px' }} />
<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
/>
)}
</Card>
</div>
+27 -8
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,6 +23,13 @@ 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 })
@@ -221,12 +228,7 @@ const SystemSettings: React.FC = () => {
const result = response.data.data
setProxyCheckResult(result)
if (result.success) {
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}`)
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`)
} else {
message.warning(`代理检查失败:${result.message}`)
}
@@ -574,7 +576,24 @@ const SystemSettings: React.FC = () => {
</Form>
{proxyCheckResult && (
<ProxyCheckResultAlert result={proxyCheckResult} style={{ marginTop: '16px' }} />
<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
/>
)}
</Card>
+1 -23
View File
@@ -625,15 +625,6 @@ 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', {}),
/**
@@ -654,20 +645,7 @@ export const apiService = {
message: string
responseTime?: number
}>
}>>('/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', {})
}>>('/system/proxy/api-health-check', {})
},
/**