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.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
||||||
|
import com.wrbug.polymarketbot.service.system.GeoblockService
|
||||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.*
|
|||||||
class ProxyConfigController(
|
class ProxyConfigController(
|
||||||
private val proxyConfigService: ProxyConfigService,
|
private val proxyConfigService: ProxyConfigService,
|
||||||
private val apiHealthCheckService: ApiHealthCheckService,
|
private val apiHealthCheckService: ApiHealthCheckService,
|
||||||
|
private val geoblockService: GeoblockService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -123,6 +125,32 @@ class ProxyConfigController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource))
|
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
|
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 success: Boolean,
|
||||||
val message: String,
|
val message: String,
|
||||||
val responseTime: Long? = null, // 响应时间(毫秒)
|
val responseTime: Long? = null, // 响应时间(毫秒)
|
||||||
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||||
|
val geoblock: ProxyCheckGeoblockResult? = null
|
||||||
) {
|
) {
|
||||||
companion object {
|
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(
|
return ProxyCheckResponse(
|
||||||
success = success,
|
success = success,
|
||||||
message = message,
|
message = message,
|
||||||
responseTime = responseTime,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-41
@@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class ProxyConfigService(
|
class ProxyConfigService(
|
||||||
private val proxyConfigRepository: ProxyConfigRepository
|
private val proxyConfigRepository: ProxyConfigRepository,
|
||||||
|
private val geoblockService: GeoblockService
|
||||||
) : ApplicationContextAware {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private var applicationContext: ApplicationContext? = null
|
private var applicationContext: ApplicationContext? = null
|
||||||
@@ -148,58 +149,38 @@ class ProxyConfigService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查代理是否可用
|
* 检查代理是否可用,并检测经该代理访问 Polymarket 时的地域限制
|
||||||
* 使用配置的代理请求 Polymarket 健康检查接口
|
|
||||||
*/
|
*/
|
||||||
fun checkProxy(): ProxyCheckResponse {
|
fun checkProxy(): ProxyCheckResponse {
|
||||||
return try {
|
return try {
|
||||||
val config = proxyConfigRepository.findByEnabledTrue()
|
val config = proxyConfigRepository.findByEnabledTrue()
|
||||||
?: return ProxyCheckResponse.create(
|
if (config == null) {
|
||||||
|
return ProxyCheckResponse.create(
|
||||||
success = false,
|
success = false,
|
||||||
message = "未配置代理或代理未启用"
|
message = "未配置代理或代理未启用",
|
||||||
|
geoblock = checkGeoblockForProxy(null)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (config.type != "HTTP") {
|
if (config.type != "HTTP") {
|
||||||
return ProxyCheckResponse.create(
|
return ProxyCheckResponse.create(
|
||||||
success = false,
|
success = false,
|
||||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)"
|
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)",
|
||||||
|
geoblock = checkGeoblockForProxy(null)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.host == null || config.port == null) {
|
if (config.host == null || config.port == null) {
|
||||||
return ProxyCheckResponse.create(
|
return ProxyCheckResponse.create(
|
||||||
success = false,
|
success = false,
|
||||||
message = "代理配置不完整:缺少主机或端口"
|
message = "代理配置不完整:缺少主机或端口",
|
||||||
|
geoblock = checkGeoblockForProxy(null)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建代理
|
val client = buildProxyHttpClient(config)
|
||||||
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))
|
val geoblock = checkGeoblockForProxy(client)
|
||||||
|
|
||||||
// 创建 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()
|
val request = Request.Builder()
|
||||||
.url("https://data-api.polymarket.com/")
|
.url("https://data-api.polymarket.com/")
|
||||||
.get()
|
.get()
|
||||||
@@ -212,37 +193,110 @@ class ProxyConfigService(
|
|||||||
val responseBody = response.body?.string()
|
val responseBody = response.body?.string()
|
||||||
|
|
||||||
if (response.isSuccessful && responseBody != null) {
|
if (response.isSuccessful && responseBody != null) {
|
||||||
// 检查响应内容是否为 {"data": "OK"}
|
|
||||||
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
|
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
|
||||||
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
|
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
|
||||||
|
val message = buildProxyCheckMessage("代理连接成功", geoblock)
|
||||||
ProxyCheckResponse.create(
|
ProxyCheckResponse.create(
|
||||||
success = true,
|
success = true,
|
||||||
message = "代理连接成功",
|
message = message,
|
||||||
responseTime = responseTime
|
responseTime = responseTime,
|
||||||
|
geoblock = geoblock
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ProxyCheckResponse.create(
|
ProxyCheckResponse.create(
|
||||||
success = false,
|
success = false,
|
||||||
message = "代理连接成功,但响应格式不正确:$responseBody",
|
message = buildProxyCheckMessage(
|
||||||
responseTime = responseTime
|
"代理连接成功,但响应格式不正确:$responseBody",
|
||||||
|
geoblock
|
||||||
|
),
|
||||||
|
responseTime = responseTime,
|
||||||
|
geoblock = geoblock
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ProxyCheckResponse.create(
|
ProxyCheckResponse.create(
|
||||||
success = false,
|
success = false,
|
||||||
message = "代理连接失败:HTTP ${response.code} ${response.message}",
|
message = buildProxyCheckMessage(
|
||||||
responseTime = responseTime
|
"代理连接失败:HTTP ${response.code} ${response.message}",
|
||||||
|
geoblock
|
||||||
|
),
|
||||||
|
responseTime = responseTime,
|
||||||
|
geoblock = geoblock
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("代理检查异常", e)
|
logger.error("代理检查异常", e)
|
||||||
ProxyCheckResponse.create(
|
ProxyCheckResponse.create(
|
||||||
success = false,
|
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'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
COMPOSE_FILES=(-f docker-compose.yml)
|
||||||
|
|
||||||
# 打印信息
|
# 打印信息
|
||||||
info() {
|
info() {
|
||||||
@@ -24,6 +25,40 @@ error() {
|
|||||||
echo -e "${RED}[ERROR]${NC} $1"
|
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 环境
|
# 检查 Docker 环境
|
||||||
check_docker() {
|
check_docker() {
|
||||||
if ! command -v docker &> /dev/null; then
|
if ! command -v docker &> /dev/null; then
|
||||||
@@ -148,6 +183,7 @@ check_security_config() {
|
|||||||
deploy() {
|
deploy() {
|
||||||
# 检查安全配置
|
# 检查安全配置
|
||||||
check_security_config
|
check_security_config
|
||||||
|
configure_compose_files
|
||||||
|
|
||||||
# 检查是否使用 Docker Hub 镜像
|
# 检查是否使用 Docker Hub 镜像
|
||||||
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
|
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
|
||||||
@@ -182,20 +218,20 @@ deploy() {
|
|||||||
export GIT_TAG=${DOCKER_VERSION}
|
export GIT_TAG=${DOCKER_VERSION}
|
||||||
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||||
|
|
||||||
docker-compose build
|
compose build
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "启动服务..."
|
info "启动服务..."
|
||||||
docker-compose up -d
|
compose up -d
|
||||||
|
|
||||||
info "等待服务启动..."
|
info "等待服务启动..."
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
||||||
info "检查服务状态..."
|
info "检查服务状态..."
|
||||||
docker-compose ps
|
compose ps
|
||||||
|
|
||||||
info "查看日志: docker-compose logs -f"
|
info "查看日志: docker-compose ${COMPOSE_FILES[*]} logs -f"
|
||||||
info "停止服务: docker-compose down"
|
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 { wsManager } from '../services/websocket'
|
||||||
import { apiClient } from '../services/api'
|
import { apiClient } from '../services/api'
|
||||||
import Logo from './Logo'
|
import Logo from './Logo'
|
||||||
|
import GeoblockStatusTrigger from './GeoblockStatusTrigger'
|
||||||
|
|
||||||
const { Header, Content, Sider } = AntLayout
|
const { Header, Content, Sider } = AntLayout
|
||||||
|
|
||||||
@@ -346,6 +347,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<SendOutlined />
|
<SendOutlined />
|
||||||
</a>
|
</a>
|
||||||
|
<GeoblockStatusTrigger iconSize={16} dotSize={10} />
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<MenuOutlined />}
|
icon={<MenuOutlined />}
|
||||||
@@ -393,7 +395,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
overflow: 'hidden'
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -473,6 +477,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<SendOutlined />
|
<SendOutlined />
|
||||||
</a>
|
</a>
|
||||||
|
<GeoblockStatusTrigger iconSize={18} dotSize={12} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
@@ -483,7 +488,8 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
style={{
|
style={{
|
||||||
height: 'calc(100vh - 100px)',
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
borderRight: 0,
|
borderRight: 0,
|
||||||
overflowY: 'auto'
|
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",
|
"saveSuccess": "Configuration saved successfully",
|
||||||
"saveFailed": "Failed to save configuration",
|
"saveFailed": "Failed to save configuration",
|
||||||
"getFailed": "Failed to get proxy 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": {
|
"configPage": {
|
||||||
"title": "Global Configuration",
|
"title": "Global Configuration",
|
||||||
@@ -1286,6 +1300,36 @@
|
|||||||
"webhookUrlPlaceholder": "Webhook URL (from Slack App settings)",
|
"webhookUrlPlaceholder": "Webhook URL (from Slack App settings)",
|
||||||
"webhookUrlRequired": "Please enter Webhook URL"
|
"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": {
|
"announcements": {
|
||||||
"title": "Announcements",
|
"title": "Announcements",
|
||||||
"noAnnouncements": "No announcements",
|
"noAnnouncements": "No announcements",
|
||||||
|
|||||||
@@ -366,11 +366,25 @@
|
|||||||
"passwordHelpUpdate": "留空则不更新密码,输入新密码则更新",
|
"passwordHelpUpdate": "留空则不更新密码,输入新密码则更新",
|
||||||
"check": "检查代理",
|
"check": "检查代理",
|
||||||
"checkSuccess": "代理检查成功",
|
"checkSuccess": "代理检查成功",
|
||||||
|
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||||
"checkFailed": "代理检查失败",
|
"checkFailed": "代理检查失败",
|
||||||
|
"geoblockTitle": "地域检测",
|
||||||
|
"geoblockAvailable": "出口 IP 可下单({{location}})",
|
||||||
|
"geoblockBlocked": "出口 IP 受限,无法下单({{location}})",
|
||||||
"saveSuccess": "保存配置成功",
|
"saveSuccess": "保存配置成功",
|
||||||
"saveFailed": "保存配置失败",
|
"saveFailed": "保存配置失败",
|
||||||
"getFailed": "获取代理配置失败",
|
"getFailed": "获取代理配置失败",
|
||||||
"latency": "延迟",
|
"latency": "延迟",
|
||||||
|
"checkResult": {
|
||||||
|
"connection": "代理连接",
|
||||||
|
"connected": "正常",
|
||||||
|
"failed": "失败",
|
||||||
|
"latency": "响应延迟",
|
||||||
|
"detail": "详情",
|
||||||
|
"geoblockOk": "可下单",
|
||||||
|
"geoblockBlocked": "地域受限",
|
||||||
|
"geoblockUnknown": "检测异常"
|
||||||
|
},
|
||||||
"hostInvalid": "请输入有效的主机地址",
|
"hostInvalid": "请输入有效的主机地址",
|
||||||
"portInvalid": "端口必须在 1-65535 之间"
|
"portInvalid": "端口必须在 1-65535 之间"
|
||||||
},
|
},
|
||||||
@@ -1286,6 +1300,36 @@
|
|||||||
"webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)",
|
"webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)",
|
||||||
"webhookUrlRequired": "请输入 Webhook URL"
|
"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": {
|
"announcements": {
|
||||||
"title": "公告",
|
"title": "公告",
|
||||||
"noAnnouncements": "暂无公告",
|
"noAnnouncements": "暂无公告",
|
||||||
|
|||||||
@@ -381,7 +381,21 @@
|
|||||||
"saveSuccess": "保存配置成功",
|
"saveSuccess": "保存配置成功",
|
||||||
"saveFailed": "保存配置失敗",
|
"saveFailed": "保存配置失敗",
|
||||||
"getFailed": "獲取代理配置失敗",
|
"getFailed": "獲取代理配置失敗",
|
||||||
"latency": "延遲"
|
"latency": "延遲",
|
||||||
|
"checkResult": {
|
||||||
|
"connection": "代理連線",
|
||||||
|
"connected": "正常",
|
||||||
|
"failed": "失敗",
|
||||||
|
"latency": "回應延遲",
|
||||||
|
"detail": "詳情",
|
||||||
|
"geoblockOk": "可下單",
|
||||||
|
"geoblockBlocked": "地域受限",
|
||||||
|
"geoblockUnknown": "檢測異常"
|
||||||
|
},
|
||||||
|
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||||
|
"geoblockTitle": "地域檢測",
|
||||||
|
"geoblockAvailable": "出口 IP 可下單({{location}})",
|
||||||
|
"geoblockBlocked": "出口 IP 受限,無法下單({{location}})"
|
||||||
},
|
},
|
||||||
"configPage": {
|
"configPage": {
|
||||||
"title": "全局配置",
|
"title": "全局配置",
|
||||||
@@ -1286,6 +1300,36 @@
|
|||||||
"webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)",
|
"webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)",
|
||||||
"webhookUrlRequired": "請輸入 Webhook URL"
|
"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": {
|
"announcements": {
|
||||||
"title": "公告",
|
"title": "公告",
|
||||||
"noAnnouncements": "暫無公告",
|
"noAnnouncements": "暫無公告",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { apiService } from '../services/api'
|
|||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
interface Reactions {
|
interface Reactions {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { SaveOutlined, CheckCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title } = Typography
|
||||||
|
|
||||||
interface ProxyConfig {
|
interface ProxyConfig {
|
||||||
id?: number
|
id?: number
|
||||||
@@ -20,13 +21,6 @@ interface ProxyConfig {
|
|||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxyCheckResponse {
|
|
||||||
success: boolean
|
|
||||||
message: string
|
|
||||||
responseTime?: number
|
|
||||||
latency?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProxySettings: React.FC = () => {
|
const ProxySettings: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
@@ -211,24 +205,7 @@ const ProxySettings: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{checkResult && (
|
{checkResult && (
|
||||||
<Alert
|
<ProxyCheckResultAlert result={checkResult} style={{ marginTop: '16px' }} />
|
||||||
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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useMediaQuery } from 'react-responsive'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
|
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
|
||||||
import SystemUpdate from './SystemUpdate'
|
import SystemUpdate from './SystemUpdate'
|
||||||
|
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
interface ProxyConfig {
|
interface ProxyConfig {
|
||||||
@@ -23,13 +23,6 @@ interface ProxyConfig {
|
|||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxyCheckResponse {
|
|
||||||
success: boolean
|
|
||||||
message: string
|
|
||||||
responseTime?: number
|
|
||||||
latency?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const SystemSettings: React.FC = () => {
|
const SystemSettings: React.FC = () => {
|
||||||
const { t, i18n: i18nInstance } = useTranslation()
|
const { t, i18n: i18nInstance } = useTranslation()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
@@ -228,7 +221,12 @@ const SystemSettings: React.FC = () => {
|
|||||||
const result = response.data.data
|
const result = response.data.data
|
||||||
setProxyCheckResult(result)
|
setProxyCheckResult(result)
|
||||||
if (result.success) {
|
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 {
|
} else {
|
||||||
message.warning(`代理检查失败:${result.message}`)
|
message.warning(`代理检查失败:${result.message}`)
|
||||||
}
|
}
|
||||||
@@ -576,24 +574,7 @@ const SystemSettings: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{proxyCheckResult && (
|
{proxyCheckResult && (
|
||||||
<Alert
|
<ProxyCheckResultAlert result={proxyCheckResult} style={{ marginTop: '16px' }} />
|
||||||
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>
|
</Card>
|
||||||
|
|||||||
@@ -625,6 +625,15 @@ export const apiService = {
|
|||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
responseTime?: number
|
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', {}),
|
}>>('/system/proxy/check', {}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -645,7 +654,20 @@ export const apiService = {
|
|||||||
message: string
|
message: string
|
||||||
responseTime?: number
|
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